Saltar a contenido

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();
    }
}