DEV Community

Cover image for Just a simple Songs API using Spring Reactive with Functional Endpoints, Docker and MongoDB
Elattar Saad
Elattar Saad

Posted on • Edited on

5

Just a simple Songs API using Spring Reactive with 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).

"Spring WebFlux includes WebFlux.fn, a lightweight functional programming model in which functions are used to route and handle requests and contracts are designed for immutability. It is an alternative to the annotation-based programming model but otherwise runs on the same Reactive Core foundation." Spring | Functional Endpoints

Project Description

As the title describe, this is a simple Songs API build using Spring, Docker and MongoDB, the endpoints are Functional Endpoints and will have the traditional ControllerAdvice as Exception handler.

Project Dependencies

  • Java Version 21
  • Spring Boot version 3.3.0-SNAPSHOT with Spring Reactive Starter.
  • Spring Docker Support.
  • Lombok (Optional).

Talking XML these are the project 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</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>


Enter fullscreen mode Exit fullscreen mode

Coding time!

First, let's setup the docker compose file /compose.yaml of the project (it should generated by spring via the docker support starter).



services:
  mongodb:
    image: 'mongo:7.0.5'
    environment:
      - 'MONGO_INITDB_DATABASE=songsDB'
      - 'MONGO_INITDB_ROOT_PASSWORD=passw0rd'
      - 'MONGO_INITDB_ROOT_USERNAME=root'
    ports:
      - '27017'


Enter fullscreen mode Exit fullscreen mode

With that set, let's create the Song class:




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


Enter fullscreen mode Exit fullscreen mode

The SongRepository interface will be referring to the Song class in its DB ops:



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

}


Enter fullscreen mode Exit fullscreen mode

Song Functional Endpoint and Handler

Now, it's time for the Song Router, it will be responsible for router the incoming requests for the /songs ressource:



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


Enter fullscreen mode Exit fullscreen mode

As you noticed the request are redirected to the SongHandler for a certain logic to be performed.

Note: If you 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.



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


Enter fullscreen mode Exit fullscreen mode

Note: The SongHandler can be annotated with @Component, since it performs a business logic I see it better have the @Service annotation instead.

Exception Handling

As previously states, will be using the same old ControllerAdvice as Exception handler with two custom Exceptions as the following:

Custom Exceptions



import lombok.Getter;

@Getter
public class InvalidParamException extends RuntimeException {

    private final String paramName;

    public InvalidParamException(final String paramName) {
        this.paramName = paramName;
    }
}


Enter fullscreen mode Exit fullscreen mode


import lombok.Getter;

@Getter
public class InvalidUUIDException extends RuntimeException {

    private final Throwable cause;

    public InvalidUUIDException(final Throwable cause) {
        this.cause = cause;
    }
}


Enter fullscreen mode Exit fullscreen mode

Custom Exception Handler



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


Enter fullscreen mode Exit fullscreen mode

With all that been set, let's make use of our endpoint using Postman:

  • Creating a new Song

Image description

  • Getting songs by artist:

Image description

  • Getting all songs:

Image description

  • Deleting a song:

Sorry not a big fan of Madonna tbh :|

Image description

  • Checking the result of the delete op:

Image description

Finally,

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 things 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 here

Also find more content on my personal personal website.

Heroku

Simplify your DevOps and maximize your time.

Since 2007, Heroku has been the go-to platform for developers as it monitors uptime, performance, and infrastructure concerns, allowing you to focus on writing code.

Learn More

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay