Saltar al contenido principal
Versión: v0.1.0

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
InsufficientAuthenticationException401AUTHENTICATION_REQUIRED
AccessDeniedException403ACCESS_DENIED
Exception (fallback)500INTERNAL_ERROR

Las dos variantes de validación rellenan fieldErrors. 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.".

:::caution 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

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:

No hay springdoc-openapi. La sección 1.8 del spec pide documentación de API con org.springdoc:springdoc-openapi-starter-webmvc-ui, exponiendo /v3/api-docs y /docs, deshabilitado o protegido en prod. No está implementado: no hay ninguna referencia a springdoc en el código, ni en gradle/libs.versions.toml, ni en ningún build.gradle.kts. Esto también deja sin base la generación automática de @echotechs/api-client (spec 3.1) y el contract testing (spec 4.4).

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.