Respuestas API Consistentes: Un Wrapper Transversal con Spring WebFlux y WebFilter
- Mauricio ECR
- Snippets
- 30 Aug, 2025
En entornos modernos de microservicios, la consistencia en las respuestas de una API es más que una cuestión de estética: es un factor crítico para la mantenibilidad, observabilidad y experiencia del consumidor. Aplicaciones frontend, integraciones con terceros, herramientas de monitoreo y otros microservicios esperan estructuras de respuesta predecibles. Cada variación no planificada introduce fricción: más lógica en los clientes, validaciones dispersas y puntos ciegos en trazabilidad.
En este contexto, estandarizar las respuestas de manera transversal —sin ensuciar cada controlador con lógica repetitiva— no solo simplifica el desarrollo, también abre la puerta a métricas uniformes, trazabilidad distribuida y soporte para nuevas funcionalidades sin tocar el código de negocio.
Este artículo explica cómo lograrlo en aplicaciones reactivas con Spring WebFlux, donde la naturaleza streaming de la respuesta introduce desafíos distintos a los de un stack imperativo como Spring MVC.
El Contrato de Respuesta: Mucho más que Datos
Antes de modificar nada, debemos definir el destino. Una respuesta estándar debe separar claramente los datos de negocio de la información contextual que permite entender la petición en su conjunto.
Un diseño común y extensible puede lucir así:
package com.app247.api.shared.response_wrapper.model;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class ApiResponse<T> {
private Meta meta;
private T data;
@Data
@Builder
public static class Meta {
private String timestamp;
private String path;
private int status;
private String requestId;
}
}
Este contrato permite:
- Consistencia: cada respuesta, sin importar el endpoint, sigue la misma forma.
- Trazabilidad: con
requestIdytimestamppodemos correlacionar logs, métricas y reportes. - Extensibilidad: podemos agregar campos en
meta(e.g., tiempos de respuesta, versión del servicio) sin afectar al cliente.
En entornos con OpenAPI/Swagger, este modelo puede documentarse fácilmente para que los consumidores conozcan el formato exacto de las respuestas.
WebFlux y el Desafío del Streaming
En aplicaciones no reactivas, ResponseBodyAdvice permite interceptar y modificar respuestas antes de serializarse. Pero en WebFlux, las respuestas son streams (Publisher<DataBuffer>), no objetos finales en memoria.
Esto implica dos retos:
- Respetar el modelo reactivo: no bloquear el flujo ni forzar materializaciones tempranas.
- Actuar en el punto correcto: cuando la respuesta está completa, pero antes de enviarla al cliente.
Aquí entra en juego el dúo WebFilter + ServerHttpResponseDecorator. El filtro decide si aplicar la transformación; el decorador define cómo hacerlo.
El Filtro: Decidiendo Cuándo Intervenir
Nuestro WebFilter actúa como middleware, excluyendo rutas (por ejemplo, Swagger o Actuator) y habilitando/deshabilitando la lógica según configuración externa:
package com.app247.api.shared.response_wrapper.filter;
import com.app247.api.shared.response_wrapper.config.ResponseWrapperProperties;
import com.app247.api.shared.response_wrapper.decorator.ResponseWrapperDecorator;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
@Slf4j
@Component
@Order(-2)
@RequiredArgsConstructor
public class ResponseWrapperFilter implements WebFilter {
private final ResponseWrapperProperties properties;
private final ObjectMapper objectMapper;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// Verificamos si la ruta está excluida
boolean isExcluded = !properties.isEnabled() || properties.getExcludedPaths().stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path));
if (isExcluded) {
return chain.filter(exchange);
}
// Creamos una instancia de nuestro nuevo decorador
ServerHttpResponseDecorator decoratedResponse = new ResponseWrapperDecorator(
exchange.getResponse(),
path,
objectMapper
);
// Pasamos el exchange con la respuesta decorada al siguiente filtro en la cadena
return chain.filter(exchange.mutate().response(decoratedResponse).build());
}
}
Las rutas excluidas y la activación del wrapper se controlan con propiedades externas, evitando recompilar para cambios operativos:
package com.app247.api.shared.response_wrapper.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/*
Ejemplo:
api:
response:
wrapper:
enabled: true
# Patrones de URL para excluir. Usa el formato Ant.
excluded-paths:
- "/v3/api-docs/**"
- "/swagger-ui/**"
- "/webjars/**"
- "/swagger-resources/**"
- "/actuator/**"
*/
@Data
@Component
@ConfigurationProperties(prefix = "api.response.wrapper")
public class ResponseWrapperProperties {
private boolean enabled = true;
private List<String> excludedPaths = new ArrayList<>();
}
El Decorador: Interviniendo sin Romper el Flujo
ServerHttpResponseDecorator nos da acceso al cuerpo de la respuesta. El método clave es writeWith, que recibe el stream de datos antes de enviarlo al cliente.
package com.app247.api.shared.response_wrapper.decorator;
import com.app247.api.shared.response_wrapper.model.ApiResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.UUID;
/**
* Decorador para ServerHttpResponse que intercepta las respuestas exitosas
* y las envuelve en una estructura estandarizada de ApiResponse (meta y data).
*/
@Slf4j
public class ResponseWrapperDecorator extends ServerHttpResponseDecorator {
private final ObjectMapper objectMapper;
private final String path;
public ResponseWrapperDecorator(ServerHttpResponse delegate, String path, ObjectMapper objectMapper) {
super(delegate);
this.path = path;
this.objectMapper = objectMapper;
}
/**
* Sobrescribe el método que escribe el cuerpo de la respuesta en el flujo de salida.
* Aquí es donde ocurre toda la magia de la intercepción y transformación.
* @param body El publicador original del cuerpo de la respuesta.
* @return Un Mono<Void> que representa la finalización de la operación de escritura.
*/
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
// PASO 1: Almacenar el cuerpo completo en un búfer.
// DataBufferUtils.join() consume tod_o el flujo del 'body' y lo une en un solo DataBuffer.
// Esto es CRUCIAL porque crea un punto de sincronización. La lógica siguiente
// no se ejecutará hasta que el controlador haya terminado y el cuerpo completo esté disponible.
Mono<DataBuffer> bufferedBody = DataBufferUtils.join(body)
.defaultIfEmpty(new DefaultDataBufferFactory().wrap(new byte[0])); // Maneja cuerpos vacíos (ej: 204 No Content)
// PASO 2: Usar flatMap para transformar el cuerpo almacenado en búfer.
// El código dentro de flatMap está garantizado a ejecutarse DESPUÉS de que 'bufferedBody' se complete.
return bufferedBody.flatMap(originalBuffer -> {
// PASO 3: Obtener el código de estado.
// En este punto, la llamada a getStatusCode() es 100% fiable porque el controlador
// ya ha finalizado y el framework ha establecido el estado final de la respuesta.
HttpStatusCode statusCode = getStatusCode();
// PASO 4: Decidir si se debe envolver la respuesta.
// Si el estado es un error explícito (4xx o 5xx), no hacemos nada y devolvemos el cuerpo original.
if (statusCode != null && !statusCode.is2xxSuccessful()) {
// Se escribe el buffer original en la respuesta real.
return getDelegate().writeWith(Mono.just(originalBuffer));
}
// PASO 5: Manejar el caso del entorno de pruebas.
// En WebFluxTest, un 200 OK por defecto puede resultar en un statusCode 'null'.
// Asumimos HttpStatus.OK si el estado es null para que las pruebas pasen.
HttpStatusCode statusToUse = (statusCode != null) ? statusCode : HttpStatus.OK;
// PASO 6: Procesar y envolver el cuerpo de la respuesta.
byte[] bytes = new byte[originalBuffer.readableByteCount()];
originalBuffer.read(bytes);
DataBufferUtils.release(originalBuffer); // Liberar memoria del buffer original.
String originalBodyJson = new String(bytes, StandardCharsets.UTF_8);
// Evitar envolver una respuesta que ya tiene nuestro formato.
if (originalBodyJson.contains("\"meta\"")) {
return getDelegate().writeWith(Mono.just(new DefaultDataBufferFactory().wrap(bytes)));
}
try {
// Deserializar el cuerpo original para poder ponerlo dentro del campo 'data'.
// Si el cuerpo está vacío, se asigna 'null' a los datos.
Object originalBodyObject = originalBodyJson.isEmpty() ? null : objectMapper.readValue(originalBodyJson, Object.class);
// Construir la nueva respuesta envuelta.
ApiResponse<?> apiResponse = buildSuccessResponse(originalBodyObject, path, statusToUse);
// Serializar la respuesta envuelta a bytes.
byte[] responseBytes = objectMapper.writeValueAsBytes(apiResponse);
// Actualizar las cabeceras HTTP con la nueva longitud y tipo de contenido.
getHeaders().setContentLength(responseBytes.length);
getHeaders().setContentType(MediaType.APPLICATION_JSON);
// Crear un nuevo buffer con la respuesta envuelta.
DataBuffer wrappedBuffer = new DefaultDataBufferFactory().wrap(responseBytes);
// Escribir el nuevo cuerpo en la respuesta real. Esta es la llamada final y única
// que envía los datos al cliente, siguiendo las buenas prácticas reactivas.
return getDelegate().writeWith(Mono.just(wrappedBuffer));
} catch (Exception e) {
log.error("Error al envolver la respuesta para la ruta {}: {}", path, e.getMessage(), e);
return getDelegate().writeWith(Mono.just(new DefaultDataBufferFactory().wrap(bytes)));
}
});
}
/**
* Método de ayuda para construir la estructura estandarizada de ApiResponse.
* @param data El objeto de datos original que se incluirá en el campo 'data'.
* @param path La ruta de la petición actual.
* @param status El código de estado HTTP final.
* @return Una instancia de ApiResponse.
*/
private ApiResponse<?> buildSuccessResponse(Object data, String path, HttpStatusCode status) {
ApiResponse.Meta meta = ApiResponse.Meta.builder()
.timestamp(Instant.now().toString())
.path(path)
.requestId(UUID.randomUUID().toString().substring(0, 10))
.status(status.value())
.build();
return ApiResponse.builder()
.meta(meta)
.data(data)
.build();
}
}
Consideraciones Técnicas
- Performance:
DataBufferUtils.join()carga todo en memoria; para respuestas muy grandes, conviene evaluar streaming JSON. - Idempotencia: el filtro detecta si ya existe
"meta"para evitar doble envoltura. - Trazabilidad distribuida:
requestIdpuede integrarse con Spring Cloud Sleuth o MDC para correlacionar logs entre microservicios.
Pruebas: Validando Comportamiento y Robustez
Con @WebFluxTest podemos probar controladores y filtros en un entorno aislado.
package com.app247.api.shared.response_wrapper.filter;
import com.app247.api.shared.response_wrapper.config.ResponseWrapperProperties;
import com.app247.api.shared.response_wrapper.model.ApiResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.mockito.Mockito.when;
/**
* Pruebas de integración para el ResponseWrapperFilter.
* Valida que las respuestas exitosas se envuelvan y que las de error o excluidas se ignoren.
*/
@WebFluxTest
@Import(ResponseWrapperFilterTest.TestConfig.class)
class ResponseWrapperFilterTest {
@Autowired
private WebTestClient webTestClient;
@MockitoBean
private ResponseWrapperProperties responseWrapperProperties;
@BeforeEach
void setUp() {
when(responseWrapperProperties.isEnabled()).thenReturn(true);
when(responseWrapperProperties.getExcludedPaths()).thenReturn(List.of("/excluded/**"));
}
@Test
@DisplayName("Debería envolver una respuesta Mono exitosa en ApiResponse")
void shouldWrapSuccessfulMonoResponse() {
webTestClient.get().uri("/test/mono")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.meta").exists()
.jsonPath("$.meta.status").isEqualTo(200)
.jsonPath("$.meta.path").isEqualTo("/test/mono")
.jsonPath("$.data").exists()
.jsonPath("$.data.id").isEqualTo(1)
.jsonPath("$.data.name").isEqualTo("Test Mono");
}
@Test
@DisplayName("Debería envolver una respuesta Flux exitosa en ApiResponse con una lista")
void shouldWrapSuccessfulFluxResponse() {
webTestClient.get().uri("/test/flux")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.meta").exists()
.jsonPath("$.meta.status").isEqualTo(200)
.jsonPath("$.data").isArray()
.jsonPath("$.data[0].id").isEqualTo(1)
.jsonPath("$.data[0].name").isEqualTo("Test Flux 1")
.jsonPath("$.data[1].id").isEqualTo(2)
.jsonPath("$.data[1].name").isEqualTo("Test Flux 2");
}
@Test
@DisplayName("No debería envolver una respuesta de una ruta excluida")
void shouldNotWrapExcludedPath() {
webTestClient.get().uri("/excluded/path")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.meta").doesNotExist()
.jsonPath("$.data").doesNotExist()
.jsonPath("$.id").isEqualTo(99)
.jsonPath("$.name").isEqualTo("Excluded");
}
@Test
@DisplayName("No debería envolver una respuesta de error (ej: 400 Bad Request)")
void shouldNotWrapErrorResponse() {
webTestClient.get().uri("/test/error")
.exchange()
.expectStatus().isBadRequest()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.meta").exists()
.jsonPath("$.errors").exists()
.jsonPath("$.errors[0].code").isEqualTo("400-CUSTOM-ERROR")
.jsonPath("$.data").doesNotExist();
}
@Test
@DisplayName("No debería envolver una respuesta que ya tiene el formato ApiResponse")
void shouldNotDoubleWrapAlreadyFormattedResponse() {
webTestClient.get().uri("/test/pre-wrapped")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.meta").exists()
.jsonPath("$.meta.status").isEqualTo(200)
.jsonPath("$.data.message").isEqualTo("This is already wrapped")
.jsonPath("$.data.meta").doesNotExist(); // La comprobación clave: no hay un 'meta' dentro del 'data'.
}
@Test
@DisplayName("Debería devolver un error 500 estándar si la serialización del framework falla")
void shouldReturnStandard500ErrorOnFrameworkSerializationFailure() {
webTestClient.get().uri("/test/unserializable")
.exchange()
// 1. Aserción clave: el estado DEBE ser 500 Internal Server Error.
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
// 2. Aserciones sobre el cuerpo de error estándar de Spring Boot.
// Este cuerpo NO es el original, sino el generado por el manejador de errores de Spring.
.jsonPath("$.status").isEqualTo(500)
.jsonPath("$.error").isEqualTo("Internal Server Error")
.jsonPath("$.path").isEqualTo("/test/unserializable")
// 3. Confirmamos que no hay rastro del cuerpo original ni de nuestra envoltura personalizada.
.jsonPath("$.meta").doesNotExist()
.jsonPath("$.data").doesNotExist()
.jsonPath("$.id").doesNotExist();
}
// --- CONFIGURACIÓN INTERNA Y COMPONENTES DE PRUEBA ---
@Data
@NoArgsConstructor
@AllArgsConstructor
static class TestDto {
private int id;
private String name;
}
// DTO diseñado para fallar durante la serialización de Jackson debido a una referencia circular.
@Data
static class UnserializableDto {
private int id = 123;
private Object problematicField = this;
}
@RestController
static class TestController {
@GetMapping("/test/mono")
Mono<TestDto> getMono() {
return Mono.just(new TestDto(1, "Test Mono"));
}
@GetMapping("/test/flux")
Flux<TestDto> getFlux() {
return Flux.just(new TestDto(1, "Test Flux 1"), new TestDto(2, "Test Flux 2"));
}
@GetMapping("/excluded/path")
Mono<TestDto> getExcluded() {
return Mono.just(new TestDto(99, "Excluded"));
}
@GetMapping("/test/error")
Mono<TestDto> getError() {
return Mono.error(new BusinessException("Error forzado", "400-CUSTOM-ERROR"));
}
@GetMapping("/test/pre-wrapped")
Mono<ApiResponse<Map<String, String>>> getPreWrappedResponse() {
ApiResponse.Meta meta = ApiResponse.Meta.builder().status(200).build();
Map<String, String> data = Collections.singletonMap("message", "This is already wrapped");
return Mono.just(ApiResponse.<Map<String, String>>builder().meta(meta).data(data).build());
}
@GetMapping("/test/unserializable")
Mono<UnserializableDto> getUnserializableObject() {
return Mono.just(new UnserializableDto());
}
}
static class BusinessException extends RuntimeException {
private final String errorCode;
public BusinessException(String message, String errorCode) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
@org.springframework.web.bind.annotation.RestControllerAdvice
static class TestGlobalExceptionHandler {
@org.springframework.web.bind.annotation.ExceptionHandler(BusinessException.class)
@org.springframework.web.bind.annotation.ResponseStatus(HttpStatus.BAD_REQUEST)
public Mono<Map<String, Object>> handleBusinessException(BusinessException ex) {
Map<String, String> error = Map.of("code", ex.getErrorCode(), "message", ex.getMessage());
Map<String, Object> meta = Map.of("timestamp", Instant.now().toString());
return Mono.just(Map.of("meta", meta, "errors", List.of(error)));
}
}
@Configuration
static class TestConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
@Bean
public AntPathMatcher antPathMatcher() {
return new AntPathMatcher();
}
@Bean
public TestGlobalExceptionHandler testGlobalExceptionHandler() {
return new TestGlobalExceptionHandler();
}
@Bean
public ResponseWrapperFilter responseWrapperFilter(
ResponseWrapperProperties properties, ObjectMapper objectMapper
) {
return new ResponseWrapperFilter(properties, objectMapper);
}
@Bean
public TestController testController() {
return new TestController();
}
}
}
Podemos extender las pruebas con StepVerifier para validar que el flujo sigue siendo reactivo y no introduce bloqueos inesperados.
Próximos Pasos y Extensiones
La solución presentada puede evolucionar hacia:
- Trazabilidad distribuida: Propagando
requestIdcon Spring Cloud Sleuth, Zipkin o Jaeger. - Internacionalización: Soporte para mensajes localizados en errores o advertencias.
- Observabilidad avanzada: Tiempo de procesamiento en
meta, integración con Prometheus o Grafana. - Functional Endpoints: Adaptando la solución a APIs basadas en
RouterFunctionen lugar de anotaciones tradicionales.
Con esta base, la envoltura de respuestas deja de ser solo un detalle de formato y se convierte en una capa estratégica para consistencia, trazabilidad y mantenimiento a largo plazo.