Router Functions en Spring WebFlux¶
Las Router Functions son el estilo funcional de definir rutas HTTP en Spring WebFlux. En lugar de anotaciones (@GetMapping, @Controller), se definen rutas con lambdas, lo que facilita la composición, las pruebas unitarias y el control explícito del flujo de request/response.
Router vs Controller¶
| Aspecto | Router Functions | @Controller |
|---|---|---|
| Definición de rutas | Programática (lambdas) | Declarativa (anotaciones) |
| Testing | Prueba unitaria de handlers sin contexto Spring | Requiere @WebFluxTest |
| Composición | andRoute, nest para agrupar rutas |
Herencia o múltiples controllers |
| Flexibilidad | Alta — control total del pipeline | Media — convenciones Spring MVC |
| Verbosidad | Mayor (más código) | Menor (más conciso) |
Estructura básica¶
@Configuration
public class RecursoRouter {
@Bean
public RouterFunction<ServerResponse> recursoRoutes(RecursoHandler handler) {
return RouterFunctions.route()
// Rutas públicas
.GET("/api/v1/health", handler::health)
// Rutas protegidas — agrupadas con nest
.nest(RequestPredicates.path("/api/v1/recursos"), builder -> builder
.GET("", handler::listar)
.GET("/{id}", handler::obtener)
.POST("", handler::crear)
.PUT("/{id}", handler::actualizar)
.DELETE("/{id}", handler::eliminar)
)
.build();
}
}
Handler — implementación¶
@Component
@RequiredArgsConstructor
public class RecursoHandler {
private final RecursoService recursoService;
public Mono<ServerResponse> listar(ServerRequest request) {
// Obtener usuario del SecurityContext
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.flatMap(auth -> {
String userId = auth.getName();
return recursoService.findByUser(userId);
})
.flatMap(recursos -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(recursos, RecursoDto.class));
}
public Mono<ServerResponse> obtener(ServerRequest request) {
String id = request.pathVariable("id");
return recursoService.findById(id)
.flatMap(recurso -> ServerResponse.ok()
.bodyValue(recurso))
.switchIfEmpty(ServerResponse.notFound().build());
}
public Mono<ServerResponse> crear(ServerRequest request) {
return request.bodyToMono(RecursoRequest.class)
.flatMap(req -> ReactiveSecurityContextHolder.getContext()
.map(ctx -> ctx.getAuthentication().getName())
.flatMap(userId -> recursoService.create(req, userId)))
.flatMap(recurso -> ServerResponse
.created(URI.create("/api/v1/recursos/" + recurso.id()))
.bodyValue(recurso))
.onErrorResume(ValidationException.class, ex ->
ServerResponse.badRequest().bodyValue(Map.of("error", ex.getMessage())));
}
public Mono<ServerResponse> actualizar(ServerRequest request) {
String id = request.pathVariable("id");
return request.bodyToMono(RecursoRequest.class)
.flatMap(req -> recursoService.update(id, req))
.flatMap(recurso -> ServerResponse.ok().bodyValue(recurso))
.switchIfEmpty(ServerResponse.notFound().build());
}
public Mono<ServerResponse> eliminar(ServerRequest request) {
String id = request.pathVariable("id");
return recursoService.delete(id)
.then(ServerResponse.noContent().build())
.switchIfEmpty(ServerResponse.notFound().build());
}
public Mono<ServerResponse> health(ServerRequest request) {
return ServerResponse.ok().bodyValue(Map.of("status", "UP"));
}
}
Composición de routers¶
@Configuration
public class ApiRouter {
@Bean
public RouterFunction<ServerResponse> apiRoutes(
RecursoHandler recursoHandler,
UsuarioHandler usuarioHandler,
AuditHandler auditHandler) {
// Rutas públicas — sin autenticación
RouterFunction<ServerResponse> publicRoutes = RouterFunctions.route()
.GET("/api/health", req -> ServerResponse.ok().bodyValue("OK"))
.GET("/api/v1/info", req -> ServerResponse.ok().bodyValue(Map.of(
"version", "1.0",
"realm", "realm-internal"
)))
.build();
// Rutas de recursos — requieren autenticación (configurado en SecurityConfig)
RouterFunction<ServerResponse> recursoRoutes = RouterFunctions.route()
.nest(RequestPredicates.path("/api/v1/recursos"),
builder -> builder
.GET("", recursoHandler::listar)
.GET("/{id}", recursoHandler::obtener)
.POST("", recursoHandler::crear)
.DELETE("/{id}", recursoHandler::eliminar))
.build();
// Rutas de admin — requieren rol ADMIN (configurado en SecurityConfig)
RouterFunction<ServerResponse> adminRoutes = RouterFunctions.route()
.nest(RequestPredicates.path("/api/v1/admin"),
builder -> builder
.GET("/usuarios", usuarioHandler::listar)
.GET("/audit", auditHandler::listar))
.build();
return publicRoutes.and(recursoRoutes).and(adminRoutes);
}
}
Manejo de errores¶
@Configuration
public class ErrorHandlerConfig {
@Bean
@Order(-2) // Prioridad alta
public WebExceptionHandler globalErrorHandler() {
return (exchange, ex) -> {
int statusCode;
String message;
if (ex instanceof AccessDeniedException) {
statusCode = 403;
message = "Acceso denegado";
} else if (ex instanceof ResponseStatusException rse) {
statusCode = rse.getStatusCode().value();
message = rse.getReason();
} else {
statusCode = 500;
message = "Error interno del servidor";
}
exchange.getResponse().setStatusCode(HttpStatus.valueOf(statusCode));
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
byte[] bytes = ("{\"error\": \"" + message + "\"}").getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
return exchange.getResponse().writeWith(Mono.just(buffer));
};
}
}
Prueba unitaria de un handler¶
class RecursoHandlerTest {
private final RecursoService recursoService = mock(RecursoService.class);
private final RecursoHandler handler = new RecursoHandler(recursoService);
@Test
void obtener_recursoExistente_devuelve200() {
ServerRequest request = mock(ServerRequest.class);
when(request.pathVariable("id")).thenReturn("123");
when(recursoService.findById("123")).thenReturn(
Mono.just(new RecursoDto("123", "Mi Recurso")));
StepVerifier.create(handler.obtener(request))
.assertNext(response -> assertEquals(HttpStatus.OK, response.statusCode()))
.verifyComplete();
}
@Test
void obtener_recursoNoExistente_devuelve404() {
ServerRequest request = mock(ServerRequest.class);
when(request.pathVariable("id")).thenReturn("xxx");
when(recursoService.findById("xxx")).thenReturn(Mono.empty());
StepVerifier.create(handler.obtener(request))
.assertNext(response -> assertEquals(HttpStatus.NOT_FOUND, response.statusCode()))
.verifyComplete();
}
}