spring java spring reactive functional endpoints docker mongoDB
Leveraging Spring Reactive, Functional Endpoints, Docker, and MongoDB Blocking is a feature of classic servlet-based web frameworks like Spring MVC. Introduced in Spring 5, Spring WebFlux is a reactive framework that operates on servers like Netty and is completely non-blocking. Two programming paradigms are supported by Spring WebFlux. Annotations (Aspect Oriented Programming) and WebFlux.fn (Functional Programming).
Elattar Saad Thu, 29th February 2024 4 min readWith that set, let's create the Song class:
The SongRepository interface will be referring to the Song class in its DB ops:
Song Functional Endpoint and HandlerNow, it's time for the Song Router, it will be responsible for router the incoming requests for the /songs ressource:
As you noticed the request are redirected to the SongHandler for a certain logic to be performed.
If you're having trouble understanding the syntax, make sure to know more about Java functional interfaces, lambda and method references.
The SongsHandler will act as Service as well, will perform a business logic and communicate with the SongRepository for operations with the database.
The
SongHandler can be annotated with @Component, since it performs a business logic I see it better have the @Service annotation instead.
As previously states, will be using the same old ControllerAdvice as Exception handler with two custom Exceptions as the following:
With all that been set, let's make use of our endpoint using Postman:
Sorry not a big fan of Madonna tbh :|
Checking the result of the delete op:
With that said, our functional songs endpoint will be good to go for further improvements and new features.
This is simple, in real industrial projects, I can assure you it can get complicated with more layers, for "getting started" purposes I avoided the use of advanced concepts such as validation, DTO, etc.
You can find the full source code here
services :
mongodb :
image : ' mongo:7.0.5 '
environment :
- ' MONGO_INITDB_DATABASE=songsDB '
- ' MONGO_INITDB_ROOT_PASSWORD=passw0rd '
- ' MONGO_INITDB_ROOT_USERNAME=root '
ports :
- ' 27017 '
import lombok . AllArgsConstructor ;
import lombok . Builder ;
import lombok . Getter ;
import lombok . Setter ;
import org . springframework . data . annotation . Id ;
import org . springframework . data . mongodb . core . mapping . Document ;
import java . util . UUID ;
@ Document
@ Getter
@ Setter
@ AllArgsConstructor
@ Builder
public class Song {
@ Id
private UUID id ;
private String title ;
private String artist ;
}
import org . springframework . data . repository . reactive . ReactiveCrudRepository ;
import org . springframework . stereotype . Repository ;
import reactor . core . publisher . Flux ;
import java . util . UUID ;
@ Repository
public interface SongRepository extends ReactiveCrudRepository < Song , UUID > {
Flux < Song > findAllByArtist ( final String artist );
}
import org . springframework . context . annotation . Bean ;
import org . springframework . context . annotation . Configuration ;
import org . springframework . web . reactive . function . server . RouterFunction ;
import org . springframework . web . reactive . function . server . ServerResponse ;
import static org . springframework . web . reactive . function . server . RouterFunctions . route ;
@ Configuration
public class SongRouterConfig {
private final SongHandler handler ;
public SongRouterConfig ( SongHandler handler ) {
this. handler = handler ;
}
@ Bean
public RouterFunction < ServerResponse > router () {
return route (). path (" /songs ", builder -> builder
. GET (" /artist ", handler :: findAllByArtist )
. GET ( handler :: findAll ) // Get endpoints' order is important
. POST (" /new ", handler :: create )
. DELETE (" /{id} ", handler :: delete )
). build ();
}
}
import io . daasrattale . webfluxmongofunctionalendpoints . song . exceptions . InvalidParamException ;
import io . daasrattale . webfluxmongofunctionalendpoints . song . exceptions . InvalidUUIDException ;
import org . springframework . http . HttpStatus ;
import org . springframework . stereotype . Service ;
import org . springframework . web . reactive . function . server . ServerRequest ;
import org . springframework . web . reactive . function . server . ServerResponse ;
import reactor . core . publisher . Mono ;
import java . util . Optional ;
import java . util . UUID ;
@ Service
public class SongHandler {
private final SongRepository repository ;
public SongHandler ( SongRepository repository ) {
this. repository = repository ;
}
public Mono < ServerResponse > findAll ( final ServerRequest request ) {
return ServerResponse
. ok ()
. body ( repository . findAll (), Song . class );
}
public Mono < ServerResponse > findAllByArtist ( final ServerRequest request ) {
return Mono . just ( request . queryParam (" artist "))
. switchIfEmpty ( Mono . error ( new InvalidParamException (" artist ")))
. map ( Optional :: get )
. map ( repository :: findAllByArtist )
. flatMap ( songFlux -> ServerResponse
. ok ()
. body ( songFlux , Song . class ));
}
public Mono < ServerResponse > create ( final ServerRequest request ) {
return request . bodyToMono ( Song . class )
. switchIfEmpty ( Mono . error ( new RuntimeException (" Song body not found "))) // you can use that or create a custom exception (recommended)
. doOnNext ( song -> song . setId ( UUID . randomUUID ()))
. flatMap ( song -> ServerResponse
. status ( HttpStatus . CREATED )
. body ( repository . save ( song ), Song . class )
);
}
public Mono < ServerResponse > delete ( final ServerRequest request ) {
return Mono . just ( request . pathVariable (" id "))
. map ( UUID :: fromString )
. doOnError ( throwable -> {
throw new InvalidUUIDException ( throwable );
})
. flatMap ( songId -> ServerResponse
. ok ()
. body ( repository . deleteById ( songId ), Void . class )
);
}
}
InvalidParamException.java
import lombok . Getter ;
@ Getter
public class InvalidParamException extends RuntimeException {
private final String paramName ;
public InvalidParamException ( final String paramName ) {
this. paramName = paramName ;
}
}
InvalidUUIDException.java
import lombok . Getter ;
@ Getter
public class InvalidUUIDException extends RuntimeException {
private final Throwable cause ;
public InvalidUUIDException ( final Throwable cause ) {
this. cause = cause ;
}
}
SongExceptionHandler.java
import io . daasrattale . webfluxmongofunctionalendpoints . song . exceptions . InvalidUUIDException ;
import lombok . extern . slf4j . Slf4j ;
import org . springframework . http . ResponseEntity ;
import org . springframework . web . bind . annotation . ControllerAdvice ;
import org . springframework . web . bind . annotation . ExceptionHandler ;
import java . util . Map ;
@ ControllerAdvice
@ Slf4j
public class SongExceptionHandler {
@ ExceptionHandler ( InvalidUUIDException . class )
public ResponseEntity < Map < String , ? >> handle ( final InvalidUUIDException exception ) {
return ResponseEntity
. badRequest ()
. body (
Map . of (
" status ", 400 ,
" message ", " Invalid UUID ",
" details ", exception . getCause (). getMessage ()
)
);
}
@ ExceptionHandler ( Exception . class )
public ResponseEntity < Map < String , ? >> handle ( final Exception exception ) {
log . error (" Unhandled Error, message: {} ", exception . getMessage ());
return ResponseEntity
. internalServerError ()
. body (
Map . of (
" status ", 500 ,
" message ", " Unknown Error ",
" details ", exception . getMessage ()
)
);
}
}
< dependencies >
< dependency >
< groupId > org.springframework.boot </ groupId >
< artifactId > spring-boot-starter-data-mongodb-reactive </ artifactId >
</ dependency >
< dependency >
< groupId > org.springframework.boot </ groupId >
< artifactId > spring-boot-starter-webflux </ artifactId >
</ dependency >
< dependency >
< groupId > org.springframework.boot </ groupId >
< artifactId > spring-boot-docker-compose </ artifactId >
< scope > runtime </ scope >
< optional > true </ optional >
</ dependency >
< dependency >
< groupId > org.projectlombok </ groupId >
< artifactId > lombok </ artifactId >
< optional > true </ optional >
</ dependency >
< dependency >
< groupId > org.springframework.boot </ groupId >
< artifactId > spring-boot-starter-test </ artifactId >
< scope > test </ scope >
</ dependency >
< dependency >
< groupId > io.projectreactor </ groupId >
< artifactId > reactor-test </ artifactId >
< scope > test </ scope >
</ dependency >
</ dependencies >
< dependencies >
< dependency >
< groupId >org.springframework.boot</ groupId >
< artifactId >spring-boot-starter-data-mongodb-reactive</ artifactId >
</ dependency >
< dependency >
< groupId >org.springframework.boot</ groupId >
< artifactId >spring-boot-starter-webflux</
services :
mongodb :
image : 'mongo:7.0.5'
environment :
- 'MONGO_INITDB_DATABASE=songsDB'
- 'MONGO_INITDB_ROOT_PASSWORD=passw0rd'
- 'MONGO_INITDB_ROOT_USERNAME=root'
ports :
- '27017'
import lombok.AllArgsConstructor ;
import lombok.Builder ;
import lombok.Getter ;
import lombok.Setter ;
import org.springframework.data.annotation.Id ;
import org.springframework.data.mongodb.core.mapping.Document ;
import java.util.UUID ;
@ Document
import org.springframework.data.repository.reactive.ReactiveCrudRepository ;
import org.springframework.stereotype.Repository ;
import reactor.core.publisher.Flux ;
import java.util.UUID ;
@ Repository
public interface SongRepository extends ReactiveCrudRepository < Song , UUID > {
import org.springframework.context.annotation.Bean ;
import org.springframework.context.annotation.Configuration ;
import org.springframework.web.reactive.function.server.RouterFunction ;
import org.springframework.web.reactive.function.server.ServerResponse ;
import static org.springframework.web.reactive.function.server.RouterFunctions.route ;
@ Configuration
public class SongRouterConfig {
import io.daasrattale.webfluxmongofunctionalendpoints.song.exceptions.InvalidParamException ;
import io.daasrattale.webfluxmongofunctionalendpoints.song.exceptions.InvalidUUIDException ;
import org.springframework.http.HttpStatus ;
import org.springframework.stereotype.Service ;
import org.springframework.web.reactive.function.server.ServerRequest ;
import org.springframework.web.reactive.function.server.ServerResponse ;
import reactor.core.publisher.Mono ;
import
InvalidParamException.java
import lombok.Getter ;
@ Getter
public class InvalidParamException extends RuntimeException {
private final String paramName ;
public InvalidParamException ( final String paramName ) {
this . paramName = paramName;
InvalidUUIDException.java
import lombok.Getter ;
@ Getter
public class InvalidUUIDException extends RuntimeException {
private final Throwable cause ;
public InvalidUUIDException ( final Throwable cause ) {
this . cause = cause;
SongExceptionHandler.java
import io.daasrattale.webfluxmongofunctionalendpoints.song.exceptions.InvalidUUIDException ;
import lombok.extern.slf4j.Slf4j ;
import org.springframework.http.ResponseEntity ;
import org.springframework.web.bind.annotation.ControllerAdvice ;
import org.springframework.web.bind.annotation.ExceptionHandler ;
import java.util.Map ;
@ ControllerAdvice
@ Slf4j
artifactId
>
< groupId >org.springframework.boot</ groupId >
< artifactId >spring-boot-docker-compose</ artifactId >
< optional >true</ optional >
< groupId >org.projectlombok</ groupId >
< artifactId >lombok</ artifactId >
< optional >true</ optional >
< groupId >org.springframework.boot</ groupId >
< artifactId >spring-boot-starter-test</ artifactId >
< groupId >io.projectreactor</ groupId >
< artifactId >reactor-test</ artifactId >
Flux < Song > findAllByArtist ( final String artist );
private final SongHandler handler ;
public SongRouterConfig ( SongHandler handler ) {
public RouterFunction < ServerResponse > router () {
return route (). path ( "/songs" , builder -> builder
. GET ( "/artist" , handler :: findAllByArtist)
. GET (handler :: findAll) // Get endpoints' order is important
. POST ( "/new" , handler :: create)
. DELETE ( "/{id}" , handler :: delete)
java.util.Optional
;
public class SongHandler {
private final SongRepository repository ;
public SongHandler ( SongRepository repository ) {
this . repository = repository;
public Mono < ServerResponse > findAll ( final ServerRequest request ) {
. body ( repository . findAll (), Song . class );
public Mono < ServerResponse > findAllByArtist ( final ServerRequest request ) {
return Mono . just ( request . queryParam ( "artist" ))
. switchIfEmpty ( Mono . error ( new InvalidParamException ( "artist" )))
. map (repository :: findAllByArtist)
. flatMap (songFlux -> ServerResponse
. body (songFlux, Song . class ));
public Mono < ServerResponse > create ( final ServerRequest request ) {
return request . bodyToMono ( Song . class )
. switchIfEmpty ( Mono . error ( new RuntimeException ( "Song body not found" ))) // you can use that or create a custom exception (recommended)
. doOnNext (song -> song . setId ( UUID . randomUUID ()))
. flatMap (song -> ServerResponse
. status ( HttpStatus . CREATED )
. body ( repository . save (song), Song . class )
public Mono < ServerResponse > delete ( final ServerRequest request ) {
return Mono . just ( request . pathVariable ( "id" ))
. doOnError (throwable -> {
throw new InvalidUUIDException (throwable);
. flatMap (songId -> ServerResponse
. body ( repository . deleteById (songId), Void . class )
public class SongExceptionHandler {
@ ExceptionHandler ( InvalidUUIDException . class )
public ResponseEntity < Map < String , ? >> handle ( final InvalidUUIDException exception ) {
"message" , "Invalid UUID" ,
"details" , exception . getCause (). getMessage ()
@ ExceptionHandler ( Exception . class )
public ResponseEntity < Map < String , ? >> handle ( final Exception exception ) {
log . error ( "Unhandled Error, message: {}" , exception . getMessage ());
"message" , "Unknown Error" ,
"details" , exception . getMessage ()