Functional Endpoints: Alternative to Controllers in WebFlux
The article discusses functional endpoints in Spring WebFlux as an alternative to traditional controllers, using RouterFunction and HandlerFunction.
Join the DZone community and get the full member experience.
Join For FreeIntroduction
In general, we’re used to exposing APIs via annotated controllers like this:
@GetMapping("/products")
public Flux<ProductDto> allProducts() {
return this.productService.getAllProducts();
}
It’s simple and straightforward, and we always know what to expect.
WebFlux provides an alternative way to expose APIs, and it is a functional endpoint. The main logic of writing revolves around RouterFunction
and HandlerFunction
.
- HandlerFunction is a functional interface that returns a generated response —
Mono<ServerResponse>
for each incoming request. - RouterFunction is the equivalent of the
@RequestMapping
annotation.
Here is an example:
@Bean
public RouterFunction<ServerResponse> route(ProductService productService) {
return RouterFunctions.route()
.GET("/products",
req ->
this.productService
.getAllProducts()
.as(productFlux -> ServerResponse.ok().body(productFlux, ProductDto.class)))
.build();
}
In fact, we will expose all our routing logic as beans in the functional style.
ServerRequest
Functional endpoints use ServerRequest
to retrieve data from an HTTP request. This class provides methods to access all parts of the request: path variables, query params, headers, cookies, and the request body.
PathVariable
String productId = request.pathVariable("id");
This method allows you to get data from parts of a URL. For example, from /products/{id}
we got {id}
.
Query Parameters
String page = request.queryParam("page").orElse("0");
This method allows you to get data from the query string after the ?
character.
Headers
String contentType = request.headers()
.contentType()
.orElse(MediaType.APPLICATION_JSON).toString();
Body
Mono<User> productMono = request.bodyToMono(Product.class);
Flux<User> productFlux = request.bodyToFlux(Product.class);
The request body can be converted to an object using the bodyToMono
or bodyToFlux
methods, depending on whether we expect a single object or a data stream.
ServerResponse
This is used to send an HTTP response to the client. There are many different methods to customize response status, headers, response body, and other aspects
Basic Methods of ServerResponse
1. Creating a simple response and setting the response status:
Mono<ServerResponse> responseOk = ServerResponse.ok().build();
Mono<ServerResponse> responseNotFound = ServerResponse.notFound().build();
Mono<ServerResponse> responseConflict = ServerResponse.status(HttpStatus.CONFLICT).build();
2. Adding headers:
Mono<ServerResponse> responseOkWithHeadersV1 =
ServerResponse.ok()
.header("Custom-Header1", "value1")
.header("Custom-Header2", "value2")
.build();
Mono<ServerResponse> responseOkWithHeadersV2 =
ServerResponse.ok()
.headers(httpHeaders -> {
httpHeaders.add("Custom-Header1", "value1");
httpHeaders.setBasicAuth("user", "password");
httpHeaders.setBearerAuth("some-value");
})
.build();
Give preference to the second one, as httpHeaders
already has many ready-made methods that will cover most of your business cases
3. Adding a body:
Mono<ServerResponse> responseOkWithBodyValue =
ServerResponse.ok().bodyValue(product);
Mono<ServerResponse> responseOkWithBodyMono =
ServerResponse.ok().body(productMono);
The body
and bodyValue
methods are used to specify the body of the response. You can send the body as an object or as a data stream (reactive type).
Importance of Order in RouterFunction
The order of the routes is important — the first matching route will be called, as routes are processed from top to bottom.
@Bean
public RouterFunction<ServerResponse> route() {
return RouterFunctions.route()
.GET("/products", this.requestHandler::allProducts)
.GET("/products/{id}", this.requestHandler::getOneProduct)
.GET("/products/paginated", this.requestHandler::pageProducts)
.build();
}
If you reverse the order, then GET /products/paginated
will never be reached, as GET /products/{id}
will intercept the request.
So you need to be careful when setting up endpoints.
Multiply RouterFunctions
If you have many endpoints, you can create several router functions and set them as beans. This approach is more understandable because we divide the code into logical blocks.
@Configuration
public class RouteConfiguration {
@Bean
public RouterFunction<ServerResponse> productRoute(ProductHandler productHandler, ExceptionHandler exceptionHandler) {
return RouterFunctions.route()
.GET("/products", request -> productHandler.getAll())
.GET("/products/{id}", productHandler::getOne)
.POST("/products", productHandler::save)
.PUT("/products/{id}", productHandler::update)
.DELETE("/products/{id}", productHandler::delete)
.onError(EntityNotFoundException.class, exceptionHandler::handleException)
.build();
}
@Bean
public RouterFunction<ServerResponse> orderRoute(OrderHandler orderHandler, ExceptionHandler exceptionHandler) {
return RouterFunctions.route()
.GET("/orders", orderHandler::getAllForUser)
.GET("/orders/{id}", orderHandler::getOne)
.POST("/orders", orderHandler::save)
.PUT("/orders/{id}", orderHandler::update)
.DELETE("/orders/{id}", orderHandler::delete)
.onError(EntityNotFoundException.class, exceptionHandler::handleException)
.build();
}
}
If the endpoints for products and orders throw an EntityNotFoundException
, then the error handler needs to be added to two routers.
Nested RouterFunction
If you don’t like to expose them as separate beans, then you can have one high-level router that can route to the child router functions. Let’s transform our RouteConfiguration
using a nested router function:
@Bean
public RouterFunction<ServerResponse> nestedRoute(
ProductHandler productHandler, OrderHandler orderHandler, ExceptionHandler exceptionHandler) {
return RouterFunctions.route()
.GET("/products", request -> productHandler.getAll())
.GET("/products/{id}", productHandler::getOne)
.POST("/products", productHandler::save)
.PUT("/products/{id}", productHandler::update)
.DELETE("/products/{id}", productHandler::delete)
.path("orders", () -> orderRouteNested(orderHandler))
.onError(EntityNotFoundException.class, exceptionHandler::handleException)
.build();
}
private RouterFunction<ServerResponse> orderRouteNested(OrderHandler orderHandler) {
return RouterFunctions.route()
.GET(orderHandler::getAllForUser)
.GET("/{id}", orderHandler::getOne)
.POST(orderHandler::save)
.PUT("/{id}", orderHandler::update)
.DELETE("/{id}", orderHandler::delete)
.build();
}
In this case, error handling is needed in only one place.
WebFilters
It will also be possible to configure filters for implementing cross-cutting logic.
Filters can be added as we used to do it — implement WebFilter
and override the method. We also have the ability to add filters to functional endpoints at the level of individual routers.
We will implement with you an authorization filter that will work only for /orders
:
@Component
public class SecurityFilter {
public Mono<ServerResponse> adminRoleFilter(ServerRequest request, HandlerFunction<ServerResponse> next) {
return SecurityFilter.requireRole("ADMIN", request.exchange())
.flatMap(exchange -> next.handle(request))
.onErrorResume(SecurityException.class, ex -> ServerResponse.status(HttpStatus.FORBIDDEN).build());
}
private static Mono<ServerWebExchange> requireRole(String role, ServerWebExchange exchange) {
return ReactiveSecurityContextHolder.getContext()
.flatMap(securityContext -> {
Authentication authentication = securityContext.getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
Object principal = authentication.getPrincipal();
if (principal instanceof UserDetails userDetails) {
if (userDetails.getAuthorities().stream()
.anyMatch(grantedAuthority -> grantedAuthority.getAuthority().equals("ROLE_" + role))) {
return Mono.just(exchange);
}
}
}
return Mono.error(new SecurityException("Access Denied"));
});
}
}
Add in RouterFunction
:
private RouterFunction<ServerResponse> orderRouteNested(
OrderHandler orderHandler, SecurityFilter securityFilter) {
return RouterFunctions.route()
.GET(orderHandler::getAllForUser)
.GET("/{id}", orderHandler::getOne)
.POST(orderHandler::save)
.PUT("/{id}", orderHandler::update)
.DELETE("/{id}", orderHandler::delete)
.filter(securityFilter::adminRoleFilter) //<--- this
.build();
}
In this example, the filter will only work for /orders
endpoints.
It is important to note that the order of adding filters is important, so if you need a strict sequence of filters, you should add them one by one:
…
.filter(filter1) //will be executed first
.filter(filter2) //second
.filter(filter3) //third
…
RequestPredicate
Request predicates are used to define the conditions for routing requests to the appropriate handlers. They allow you to flexibly and accurately configure routes based on various characteristics of HTTP requests, such as method, path, headers, request parameters, etc.
@Bean
public RouterFunction<ServerResponse> nestedRoute(
ProductHandler productHandler,
OrderHandler orderHandler,
ExceptionHandler exceptionHandler,
SecurityFilter securityFilter,
LoggingFilter loggingFilter) {
return RouterFunctions.route()
.GET(
"/products",
RequestPredicates.accept(MediaType.APPLICATION_JSON),
request -> productHandler.getAll())
.GET(
"/products/{id}",
RequestPredicates.headers(headers ->
null != headers.firstHeader("x-api-key")
&& headers.firstHeader("x-api-key").equals("some-val")
),
productHandler::getOne)
...
RequestPredicates
is a class with a lot of auxiliary methods.
In this example:
/products
– We can call it only ifAccept = application/json
, otherwise there will be an error that the path was not found./products/{id}
– We will be able to call only when we have thex-api-key
header and it is equal tosome-val
, otherwise there will be a404
.
You can also have the same path but route to different methods depending on the logic we put in predicates:
private RouterFunction<ServerResponse> orderRouteNested(
OrderHandler orderHandler, SecurityFilter securityFilter) {
return RouterFunctions.route()
.GET(isOperation("get-for-user"), orderHandler::getAllForUser)
.GET(isOperation("get-for-all"), orderHandler::getAll)
...
.build();
}
private RequestPredicate isOperation(String operation) {
return RequestPredicates.headers(h -> operation.equals(h.firstHeader("operation")));
}
Conclusion
We have discussed how to use functional endpoints in WebFlux in order to create APIs. By using RouterFunction
and HandlerFunction
to define routes and handlers in the code, this technique blends together routes with filters besides combining routing functions so as to enable us to produce scalable web applications
We managed to achieve this with the help of functional endpoints:
- Flexibility and control. Dynamic route definition and combination improved control over request processing.
- Readability and support. Separating logic into separate beans improved the code structure and simplified maintenance.
- Modularity and flexibility. Creating multiple
RouterFunctions
for different parts of the application increased the modularity and flexibility of the code.
Here is the code.
Opinions expressed by DZone contributors are their own.
Comments