Saltar al contenido principal
Versión: main (sin publicar)

core-web

Qué problema resuelve​

Cuando cada proyecto inventa su propio formato de error, el frontend termina con un catch distinto por API: unos devuelven {"error": "..."}, otros {"message": "..."}, otros el stacktrace de Spring. Lo mismo pasa con la paginación y con el mapeo DTO ↔ entidad.

core-web fija esas tres convenciones para todas las APIs de EchoTechs: un único cuerpo de error (ApiError) para cualquier 4xx/5xx, un único sobre de listado (PageResponse), y una configuración compartida de MapStruct. Es un módulo sin estado y sin base de datos — sólo convenciones y un @RestControllerAdvice.

Cómo se agrega​

Viene transitivamente con core-auth. Declaralo directo sólo si querés el manejo de errores y la paginación sin autenticación.

build.gradle.kts
dependencies {
implementation("dev.echotechs.core:core-web:0.1.0-SNAPSHOT")
}

Si vas a escribir mappers, necesitás además el processor de MapStruct en tu propio build — core-web lo declara como compileOnly, así que no se propaga:

dependencies {
implementation("dev.echotechs.core:core-web:0.1.0-SNAPSHOT")
compileOnly("org.mapstruct:mapstruct:1.6.3")
annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3")
}

Trae como api: spring-boot-starter-web, spring-boot-starter-validation, spring-data-commons y spring-security-core.

API pública​

ApiError — el cuerpo de error uniforme​

public record ApiError(
Instant timestamp,
int status,
String code,
String message,
String path,
List<ApiFieldError> fieldErrors
) {}

code es un identificador estable, legible por máquina ("RESOURCE_NOT_FOUND"); message es legible por humanos y seguro de mostrarle a un usuario final. El constructor compacto normaliza fieldErrors a lista vacía cuando llega null, así que el campo nunca es null en el JSON.

public record ApiFieldError(String field, String message, Object rejectedValue) {}

ApiException y sus subclases​

public class ApiException extends RuntimeException {
public ApiException(HttpStatus status, String code, String message);
public ApiException(HttpStatus status, String code, String message, List<ApiFieldError> fieldErrors);

public HttpStatus getStatus();
public String getCode();
public List<ApiFieldError> getFieldErrors();
}

Tres subclases listas para usar:

ClaseStatuscode
ResourceNotFoundException(String message)404RESOURCE_NOT_FOUND (fijo)
ConflictException(String code, String message)409el que le pases
UnauthorizedException(String code, String message)401el que le pases

Extendé ApiException en vez de lanzar RuntimeException crudas — es lo que hace que todos los caminos de error de todas las APIs terminen con la misma forma de respuesta.

GlobalExceptionHandler​

@RestControllerAdvice con @Order(Ordered.LOWEST_PRECEDENCE), registrado automáticamente por ErrorHandlingAutoConfiguration (no depende del component-scan de tu proyecto). Traduce:

ExcepciónStatuscode
ApiExceptionel suyoel suyo
MethodArgumentNotValidException400VALIDATION_FAILED
ConstraintViolationException400VALIDATION_FAILED
HttpMessageNotReadableException400MALFORMED_REQUEST_BODY
MethodArgumentTypeMismatchException400BAD_REQUEST (p.ej. un @PathVariable UUID malformado)
MissingServletRequestParameterException400BAD_REQUEST
HttpRequestMethodNotSupportedException405METHOD_NOT_ALLOWED
NoResourceFoundException404RESOURCE_NOT_FOUND (rutas inexistentes)
ErrorResponseExceptionel status que traederivado del status vía codeFor
InsufficientAuthenticationException401AUTHENTICATION_REQUIRED
AccessDeniedException403ACCESS_DENIED
Exception (fallback)500INTERNAL_ERROR

Las dos variantes de validación rellenan fieldErrors. ErrorResponseException (y las subclases de Spring 6 que ya traen su status code, como algunas de validación de params) se traduce con ese status en vez de colapsarlo a 500. El fallback a 500 loguea el stacktrace completo con método y URI, pero al cliente le devuelve sólo "Something went wrong. Please try again.".

Los errores del filter chain de Spring Security no pasan por acá

GlobalExceptionHandler sólo ve excepciones lanzadas dentro de un controller. Un 401 por falta de token lo produce el filter chain antes de llegar al DispatcherServlet. core-auth resuelve eso con ApiAuthenticationEntryPoint/ApiAccessDeniedHandler, que escriben exactamente el mismo ApiError — ver core-auth.

PageResponse<T>​

public record PageResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages,
boolean last
) {
public static <T> PageResponse<T> from(Page<T> page);
public static <T, R> PageResponse<R> from(Page<T> page, List<R> mappedContent);
}

Envolver Page en vez de devolverlo directo mantiene el formato de cable estable aunque Spring Data cambie su propia representación JSON. La segunda sobrecarga es para cuando ya mapeaste el contenido a DTOs.

Los defaults de paginación los siembra PaginationDefaultsEnvironmentPostProcessor como property source de menor prioridad, así que cualquier valor que pongas en tu application.yml los pisa normalmente:

PropiedadDefault
spring.data.web.pageable.default-page-size20
spring.data.web.pageable.max-page-size100
spring.data.web.pageable.one-indexed-parametersfalse

Documentación de API con springdoc-openapi​

SpringdocDefaultsEnvironmentPostProcessor (core-web/src/main/java/dev/echotechs/core/web/openapi/) siembra, como property source de menor prioridad, los defaults de la spec 1.8: /v3/api-docs y Swagger UI en /docs habilitados en cualquier perfil que no sea prod, deshabilitados en prod. Por ser property source de menor prioridad, cualquier valor que tu proyecto fije explícitamente (springdoc.api-docs.enabled, springdoc.swagger-ui.enabled) lo pisa — por ejemplo, para exponerlo en prod protegido detrás de core-authz en vez de deshabilitarlo del todo.

PropiedadDefault fuera de prodDefault en prod
springdoc.api-docs.enabledtruefalse
springdoc.swagger-ui.enabledtruefalse
springdoc.swagger-ui.path/docs/docs

core-web trae org.springdoc:springdoc-openapi-starter-webmvc-ui como api, así que no hace falta declararlo en tu proyecto. La documentación se genera en tiempo de request a partir de los @RestController reales del classpath — no hay anotaciones @Operation/@Schema obligatorias, aunque otros módulos (core-authz, core-config) las usan para mejorar los nombres de operación generados.

CORS opt-in​

CorsAutoConfiguration (core-web/src/main/java/dev/echotechs/core/web/cors/) registra un CorsFilter servlet en Ordered.HIGHEST_PRECEDENCE — corre antes que el SecurityFilterChain de core-auth, así que un preflight OPTIONS se resuelve sin pasar por autenticación. Está condicionado (@ConditionalOnProperty) a que echotechs.web.cors.allowed-origins esté seteado; sin eso, no se crea ningún bean y el comportamiento es exactamente el de antes de que la propiedad existiera.

application.yml
echotechs:
web:
cors:
allowed-origins: http://localhost:3000

allowed-origins es un único valor separado por comas, no una lista YAML — @ConditionalOnProperty no reconoce una secuencia YAML indexada como "presente". Siempre configura allowCredentials(true) (para que funcionen las cookies httpOnly de core-auth desde un frontend en otro origen, como un Next.js en localhost:3000), por lo que el CORS spec exige orígenes exactos — nunca "*".

La clave tiene que existir en CADA application-<perfil>.yml activo, no sólo en el que usás en dev

@ConditionalOnProperty mira el Environment resuelto para el perfil que arrancó, no "si la env var ECHOTECHS_WEB_CORS_ALLOWED_ORIGINS tiene un valor en algún lado". Si application-dev.yml referencia la propiedad (allowed-origins: ${ECHOTECHS_WEB_CORS_ALLOWED_ORIGINS:...}) pero copiaste application-prod.yml de otro proyecto que nunca la declaró, correr con prod activo nunca crea el bean de CORS — no importa qué valor le pongas a la env var en tu plataforma de despliegue (Coolify, etc.): la clave simplemente no está en el Environment de ese perfil. El síntoma es un preflight OPTIONS que responde 200 pero sin ningún header Access-Control-*, sin ningún error en los logs del backend — parece un problema del frontend o de la plataforma, no de un YAML que le falta una línea. Poné la propiedad en cada perfil que vayas a correr, aunque sea con default vacío: allowed-origins: ${ECHOTECHS_WEB_CORS_ALLOWED_ORIGINS:}.

CoreMapperConfig y EntityMapper<D, E>​

@MapperConfig(
componentModel = MappingConstants.ComponentModel.SPRING,
unmappedTargetPolicy = ReportingPolicy.IGNORE
)
public interface CoreMapperConfig {}
public interface EntityMapper<D, E> {
E toEntity(D dto);
D toDto(E entity);
List<D> toDtoList(List<E> entities);
}

Ejemplo de uso​

Un controller completo usando las tres convenciones:

@RestController
@RequestMapping("/api/purchase-requests")
public class PurchaseRequestController {

private final PurchaseRequestRepository repository;
private final PurchaseRequestMapper mapper;

public PurchaseRequestController(PurchaseRequestRepository repository, PurchaseRequestMapper mapper) {
this.repository = repository;
this.mapper = mapper;
}

@GetMapping
public PageResponse<PurchaseRequestDto> list(Pageable pageable) {
Page<PurchaseRequest> page = repository.findAll(pageable);
return PageResponse.from(page, mapper.toDtoList(page.getContent()));
}

@GetMapping("/{id}")
public PurchaseRequestDto get(@PathVariable UUID id) {
PurchaseRequest entity = repository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Purchase request " + id + " not found"));
return mapper.toDto(entity);
}

@PostMapping
public PurchaseRequestDto create(@Valid @RequestBody PurchaseRequestDto dto) {
return mapper.toDto(repository.save(mapper.toEntity(dto)));
}
}

Y el mapper:

@Mapper(config = CoreMapperConfig.class)
public interface PurchaseRequestMapper extends EntityMapper<PurchaseRequestDto, PurchaseRequest> {
}

El GET /api/purchase-requests/{id} de un id inexistente produce exactamente esto, verificado en GlobalExceptionHandlerTest:

{
"timestamp": "2026-07-28T12:00:00Z",
"status": 404,
"code": "RESOURCE_NOT_FOUND",
"message": "Purchase request 3f2... not found",
"path": "/api/purchase-requests/3f2...",
"fieldErrors": []
}

Un POST con body inválido produce un 400 con fieldErrors poblado:

{
"status": 400,
"code": "VALIDATION_FAILED",
"message": "Request payload is invalid.",
"fieldErrors": [
{"field": "title", "message": "must not be blank", "rejectedValue": ""}
]
}

Errores comunes​

Mi @ControllerAdvice propio no se aplica. GlobalExceptionHandler está en LOWEST_PRECEDENCE, así que cualquier advice tuyo con orden default gana. Pero si escribiste el tuyo con un @ExceptionHandler(Exception.class) sin orden explícito, el resultado entre dos advices de la misma precedencia no está definido — dale un @Order explícito.

Espero un 403 y recibo un 401 (o al revés). InsufficientAuthenticationException → 401, AccessDeniedException → 403. Si tirás AccessDeniedException desde un contexto sin principal autenticado, seguís obteniendo 403, no 401.

El rejectedValue de un campo sensible aparece en la respuesta. ApiFieldError incluye el valor rechazado tal cual. Si validás un campo de contraseña con @Size, el valor inválido viaja en el JSON de error. Evitá anotaciones de Bean Validation con mensajes automáticos sobre campos secretos, o filtralos en un advice propio de mayor precedencia.

MapStruct no genera nada. core-web declara MapStruct como compileOnly/annotationProcessor — no es transitivo. Tu proyecto tiene que declarar el annotationProcessor por su cuenta.

Esperaba que el mapper actualizara una entidad existente. EntityMapper no tiene método de merge. Los mappers generados construyen siempre una instancia nueva y nunca aceptan un target mutable — es deliberado, es la defensa contra el bug de "campos residuales de una solicitud anterior". Para update, leé la entidad y asignale los campos vos.

Notas de implementación​

Diferencias entre platform-core-spec.md y lo que hace el código:

springdoc-openapi ya está implementado (ver "Documentación de API con springdoc-openapi" más arriba). La sección 1.8 del spec queda cubierta en la parte de exposición de /v3/api-docs y /docs. Esto también desbloqueó @echotechs/api-client (spec 3.1) — generado con orval desde el OpenAPI real, ver Paquetes frontend — que ya existe. Lo que sigue sin implementarse es el contract testing (spec 4.4): CI todavía no valida que el OpenAPI generado en cada build coincida con el openapi.json commiteado en packages/api-client.

No hay i18n. El spec 1.8 pide MessageSource con messages_es.properties/messages_en.properties, resolución por Accept-Language vía AcceptHeaderLocaleResolver, integración con Bean Validation, e ICU4J opcional. Nada de eso existe en el código: no hay archivos messages*.properties ni configuración de MessageSource o LocaleResolver en ningún módulo. Los mensajes de GlobalExceptionHandler están hardcodeados en inglés ("Request payload is invalid.", "Something went wrong. Please try again.").

EnvironmentPostProcessor está deprecado para remoción. PaginationDefaultsEnvironmentPostProcessor usa una API marcada @Deprecated(forRemoval) desde Boot 4.0, suprimida con @SuppressWarnings("removal"). El comentario en el código explica que sigue siendo el único hook que corre lo bastante temprano como para sembrar defaults antes de que se bindeen las @ConfigurationProperties. Va a necesitar migración cuando Spring publique un reemplazo.