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.
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:
| Clase | Status | code |
|---|---|---|
ResourceNotFoundException(String message) | 404 | RESOURCE_NOT_FOUND (fijo) |
ConflictException(String code, String message) | 409 | el que le pases |
UnauthorizedException(String code, String message) | 401 | el 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ón | Status | code |
|---|---|---|
ApiException | el suyo | el suyo |
MethodArgumentNotValidException | 400 | VALIDATION_FAILED |
ConstraintViolationException | 400 | VALIDATION_FAILED |
HttpMessageNotReadableException | 400 | MALFORMED_REQUEST_BODY |
MethodArgumentTypeMismatchException | 400 | BAD_REQUEST (p.ej. un @PathVariable UUID malformado) |
MissingServletRequestParameterException | 400 | BAD_REQUEST |
HttpRequestMethodNotSupportedException | 405 | METHOD_NOT_ALLOWED |
NoResourceFoundException | 404 | RESOURCE_NOT_FOUND (rutas inexistentes) |
ErrorResponseException | el status que trae | derivado del status vía codeFor |
InsufficientAuthenticationException | 401 | AUTHENTICATION_REQUIRED |
AccessDeniedException | 403 | ACCESS_DENIED |
Exception (fallback) | 500 | INTERNAL_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.".
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:
| Propiedad | Default |
|---|---|
spring.data.web.pageable.default-page-size | 20 |
spring.data.web.pageable.max-page-size | 100 |
spring.data.web.pageable.one-indexed-parameters | false |
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.
| Propiedad | Default fuera de prod | Default en prod |
|---|---|---|
springdoc.api-docs.enabled | true | false |
springdoc.swagger-ui.enabled | true | false |
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.
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 "*".
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.