Codersee

Reactive REST API with Micronaut and MongoDB

Image shows Sally - Micronaut logo in the foreground and blurred image of a person sitting next to the table and writting something on a piece of paper.

1. Introduction

Hello 😀 In this article, I will show you how to implement a Reactive REST API with Micronaut and MongoDB (+ Project Reactor).

Nevertheless, if you are not interested in a reactive approach, and you would like to create a simple REST API, then check out my previous article related to Micronaut and Mongo.

Finally, just wanted to let you know that after this tutorial, you will know precisely:

  • how to spin up a MongoDB instance with Docker,
  • create a new Micronaut project with all dependencies using the Launch page,
  • how to expose REST endpoints,
  • and how to validate user input.

2. MongoDB Instance Setup

But before we create our Micronaut project, we need a MongoDB instance up and running on our local machine.

If you don’t have one, you can either install it with the official documentation or simply run the following Docker commands:

docker pull mongo

 

Image shows two ebooks people can get for free after joining newsletter

 

Then, start a new container named mongodb in a detached mode:

docker run -d -p 27017:27017 --name mongodb mongo

And finally, to validate use the docker ps command:

CONTAINER ID IMAGE COMMAND                CREATED       STATUS       PORTS                    NAMES
ce86244c3fe1 mongo "docker-entrypoint.s…" 5 seconds ago Up 4 seconds 0.0.0.0:27017->27017/tcp mongodb

3. Generate Reactive Micronaut with MongoDB Project

As the next step, let’s navigate to the Micronaut Launch. It is a web application, which allows us to generate new projects from scratch with ease. Of course, we have other possibilities, like Micronaut CLI, or cURL, but for simplicity, let’s go with the web approach:

 

The image is a screenshot from the Micronaut Launch page showing all configuration required to set up a new reactive REST API with Micronaut and MongoDB project from scratch.

As we can see, we will be using Micronaut 3.7.1 with Kotlin and JUnit.

Additionally, to set up reactive Micronaut with MongoDB, we will need the following features:

  • reactor – adding reactive support using Project Reactor,
  • mongo-reactive – bringing support for the MongoDB Reactive Streams Driver,
  • data-mongodb-reactive – adding reactive data repositories for MongoDB.

With all of that being selected, let’s click the Generate Project button, download the ZIP file, and import it to our IDE.

4. application.yaml

As the next step after import, let’s make sure that our application.yaml is configured properly:

micronaut:
  application:
    name: mongodbasync
netty:
  default:
    allocator:
      max-order: 3
mongodb.uri: mongodb://${MONGO_HOST:localhost}:${MONGO_PORT:27017}/someDb

Please keep in mind to set the mongodb.uri to match your case.

With the above configuration, Micronaut will try to connect to the instance using localhost:27017 and use the someDb database. It’s worth mentioning here, that we don’t have to create the database manually- it will be created automatically if it does not exist.

5. Create Models and DTOs

Following, let’s add classes responsible for persisting and fetching data from MongoDB.

And as an introduction- in this project, we will expose functionality to manage articles and their authors.

5.1. Add Article Class

Firstly, let’s implement the Article:

@MappedEntity
data class Article(
  @field:Id
  @field:GeneratedValue
  val id: String? = null,
  val title: String,
  val category: ArticleCategory,
  val author: Author
)

As we can clearly see, we must annotate this class with:

  • @MappedEntity– a generic annotation used to identify a persistent type. Without it, we would end up with an error: Internal Server Error: Can't find a codec for class com.codersee.model.Article, when adding a repository later.
  • @Id – responsible for marking the identifier field. Without this one, on the other hand, the code won’t compile with the following message: Unable to implement Repository method: ArticleRepository.delete(Object entity). Delete all not supported for entities with no ID
  • @GeneratedValue – annotating our property as a generated value.

Additionally, we make use of the @field to specify how exactly annotation should be generated in the Java bytecode.

5.2. Implement ArticleCategory

Nextly, let’s add a simple enum called ArticleCategory:

enum class ArticleCategory {
  JAVA, KOTLIN, JAVASCRIPT
}

It’s a simple enum, which will identify categories of our articles.

5.3. Create Author

Following, let’s implement the Author data class:

@Serializable
@Deserializable
data class Author(
  val firstName: String,
  val lastName: String,
  val email: String
)

This time, we have to mark the class with @Serializable and @Deserializable.

And although we don’t have to mark this class as an entity (it’s an inner JSON in the MongoDB document, not a separate one), we have to utilize these two annotations.

Without them, everything will fail with either:

Caused by: io.micronaut.core.beans.exceptions.IntrospectionException: No serializable introspection present for type Author author. Consider adding Serdeable. Serializable annotate to type Author author. Alternatively if you are not in control of the project’s source code, you can use @serdeimport(Author.class) to enable serialization of this type.

or

Caused by: io.micronaut.core.beans.exceptions.IntrospectionException: No deserializable introspection present for type: Author. Consider adding Serdeable.Deserializable annotate to type Author. Alternatively if you are not in control of the project’s source code, you can use @serdeimport(Author.class) to enable deserialization of this type.

5.4. Implement Requests

Finally, let’s implement two DTOs responsible for deserializing JSON payloads sent by the user: ArticleRequest and SearchRequest:

data class ArticleRequest(
  val title: String,
  val category: ArticleCategory,
  val author: Author
)

data class SearchRequest(
  val title: String
)

As we can see, these are just two, plain data classes with no annotations. And as mentioned above, their only responsibility will be to transfer the data deserialized from request bodies.

6. Make Use Of Data Repositories

With that being done, we can add the ArticleRepository to our reactive Micronaut with MongoDB project:

@MongoRepository
interface ArticleRepository : ReactorCrudRepository<Article, String> {

  @MongoFindQuery("{ title: { \$regex: :title, '\$options' : 'i'}}")
  fun findByTitleLikeCaseInsensitive(title: String): Flux<Article>

}

This time, we have to implement the desired repository and annotate the interface with @MongoRepository to make use of the data repositories in Micronaut.

Additionally, we’ve added a custom find query with @MongoFindQuery annotation. This function will be responsible for fetching articles by a given title.

Note: when we check the GenericRepository interface, we will see that we have plenty of possibilities to pick from:

 

The image is a screen of all interfaces extending the Micronaut GenericRepository.

 

In our case, we will go with the ReactorCrudRepository, which exposes basic CRUD operations with Fluxes and Monos.

7. Add Service Layer

Nextly, let’s implement the ArticleService:

@Singleton
class ArticleService(
  private val articleRepository: ArticleRepository
) {

  fun create(article: Article): Mono<Article> =
    articleRepository.save(article)

  fun findAll(): Flux<Article> =
    articleRepository.findAll()

  fun findById(id: String): Mono<Article> =
    articleRepository.findById(id)
      .switchIfEmpty(
        Mono.error(
          HttpStatusException(HttpStatus.NOT_FOUND, "Article with id: $id does not exists.")
        )
      )

  fun update(id: String, updated: Article): Mono<Article> =
    findById(id)
      .map { foundArticle -> updated.copy(id = foundArticle.id) }
      .flatMap(articleRepository::update)

  fun deleteById(id: String): Mono<Void> =
    findById(id)
      .flatMap(articleRepository::delete)
      .flatMap { deletedCount ->
        if (deletedCount > 0L)
          Mono.empty()
        else
          Mono.error(
            HttpStatusException(HttpStatus.NOT_FOUND, "Article with id: $id was not deleted.")
          )
      }

  fun findByTitleLike(name: String): Flux<Article> =
    articleRepository.findByTitleLikeCaseInsensitive(name)
}

As we can see, a lot is happening here, so let’s take a minute to understand this code snippet.

As the first thing, we mark the service with @Singleton, which means that the injector will instantiate this class only once. Then, we inject a previously created repository to make use of it.

7.1. create(), findAll() and findByTitleLike()

When it comes to these three functions there’s not too much to say about:

fun create(article: Article): Mono<Article> =
  articleRepository.save(article)

fun findAll(): Flux<Article> =
  articleRepository.findAll()

fun findByTitleLike(name: String): Flux<Article> =
  articleRepository.findByTitleLikeCaseInsensitive(name)

As we can clearly see, they are responsible for invoking appropriate functions from our repository and returning either Flux (multiple) or Mono (single) Article(s).

7.2. findById()

Nextly, let’s see the function responsible for fetching articles with their identifiers:

fun findById(id: String): Mono<Article> =
  articleRepository.findById(id)
    .switchIfEmpty(
      Mono.error(
        HttpStatusException(HttpStatus.NOT_FOUND, "Article with id: $id does not exists.")
      )
    )

Right here, we first invoke the findById(id) function from our repository. Then, it the article was found, we simply return the Mono<Article>.

Nevertheless, if the article does not exist in our MongoDB instance, the repository returns an empty Mono. And to handle this case, we use the switchIfEmpty() and translate it to the error Mono.

Finally, to return a meaningful status code and message to the user later, we use the HttpStatusException, which is a dedicated exception for that.

7.3. update()

Following, let’s take a look at the update() implementation:

fun update(id: String, updated: Article): Mono<Article> =
  findById(id)
    .map { foundArticle -> updated.copy(id = foundArticle.id) }
    .flatMap(articleRepository::update)

Right here, we first try to fetch the article by id. If it was found, then we simply copy its identifier to the instance with updated fields called updated.

As the last step, we invoke the update method from our repository using the function reference.

Note:  Function reference is a great syntatic sugar here and is an equivalent of: .flatMap{ articleRepository.update(it) } , or .flatMap{ updated -> articleRepository.update(updated) }.

7.4. deleteById()

Lastly, let’s check the delete functionality:

fun deleteById(id: String): Mono<Void> =
  findById(id)
    .flatMap(articleRepository::delete)
    .flatMap { deletedCount ->
      if (deletedCount > 0L)
        Mono.empty()
      else
        Mono.error(
          HttpStatusException(HttpStatus.NOT_FOUND, "Article with id: $id was not deleted.")
        )
    }

Similarly, we first try to find the article by id, and on success, we invoke the delete function from our repository.

As a result, we get a Long value indicating the number of affected (here- deleted) documents. So if this value is greater than 0, we simply return an empty Mono. Otherwise, we return an error Mono with HttpStatusException.

8. Controller For Reactive Micronaut with MongoDB

With all of that being done, let’s add the ArticleController class:

@Controller("/articles")
@ExecuteOn(TaskExecutors.IO)
class ArticleController(
  private val articleService: ArticleService
) {

  @Get
  fun findAll(): Flux<Article> =
    articleService.findAll()

  @Get("/{id}")
  fun findById(@PathVariable id: String): Mono<Article> =
    articleService.findById(id)

  @Post
  @Status(CREATED)
  fun create(@Body request: ArticleRequest): Mono<Article> =
    articleService.create(
      article = request.toArticle()
    )

  @Post("/search")
  fun search(@Body searchRequest: SearchRequest): Flux<Article> =
    articleService.findByTitleLike(
      name = searchRequest.title
    )

  @Put("/{id}")
  fun updateById(
    @PathVariable id: String,
    @Body request: ArticleRequest
  ): Mono<Article> =
    articleService.update(id, request.toArticle())

  @Delete("/{id}")
  @Status(NO_CONTENT)
  fun deleteById(@PathVariable id: String) =
    articleService.deleteById(id)

  private fun ArticleRequest.toArticle(): Article =
    Article(
      id = null,
      title = this.title,
      category = this.category,
      author = this.author
    )
}

As we can clearly see, we have to mark our controller class with the @Controller annotation and specify the base URI – /articles in our case.

Moreover, we make use of the @ExecuteOn(TaskExecutors.IO) to indicate which executor service a particular task should run on.

When it comes to particular functions, each one is responsible for handling different requests. Depending on which HTTP method should they respond to, we mark them with meaningful annotation: @Get, @Post, @Put, or @Delete. These annotations let us narrow down the URI route, like /search, or /{id}.

Additionally, we access path variables and request bodies with @PathVariable and @Body annotations (and DTOs implemented in paragraph 3), and set custom response codes with @Status. And of course, we have to remember that the status code can be changed with HttpStatusException in the service layer, as well.

9. Validation

At this point, our reactive Micronaut application can be run and we would be able to test the endpoints.

Nevertheless, before we do so, let’s add one more, crucial functionality: user input validation. In the following example, I will show you just a couple of possible validations, but please keep in mind that there are way more possibilities, which I encourage you to check out.

9.1. Edit Controller

As the first step, let’s make a few adjustments to the previously created controller:

// Make class open
open class ArticleController

// Add @Valid annotation and make functions open, as well
open fun create(@Valid @Body request: ArticleRequest): Mono<Article>

open fun search(@Valid @Body searchRequest: SearchRequest): Flux<Article>

open fun updateById(@PathVariable id: String, @Valid @Body request: ArticleRequest): Mono<Article>

As we can clearly see, to trigger the validation process for particular request bodies, we have to add the @Valid annotation.

Moreover, functions with validation must be marked as open. Without that, our Micronaut app won’t compile with the following error:

Method defines AOP advice but is declared final. Change the method to be non-final in order for AOP advice to be applied.

9.2. Change DTOs

Nextly, let’s make a couple of changes to the ArticleRequest, SearchRequest, and Author classes:

@Serializable
@Deserializable
data class Author(
  @field:NotBlank
  @field:Size(max = 50)
  val firstName: String,

  @field:NotBlank
  @field:Size(max = 50)
  val lastName: String,

  @field:Size(max = 50)
  @field:Email
  val email: String
)

@Introspected
data class ArticleRequest(
  @field:NotBlank val title: String,
  val category: ArticleCategory,
  @field:Valid val author: Author
)

@Introspected
data class SearchRequest(
  @field:NotBlank
  @field:Size(max = 50)
  val title: String
)

Firstly, we have to mark fields intended for validation with appropriate annotations, like @NotBlank, @Size, etc. These annotations are a part of the javax.validation.constraints package, which you might already know, for example from Spring Boot.

It’s worth mentioning two additional things we can see above: @Introspected and a @Valid annotation applied to the author.

With the first one, we state that the type should produce an io.micronaut.core.beans.BeanIntrospection at compilation time. Without it, Mirconaut won’t be able to process our annotations and all requests will fail at the runtime with the example message:

request: Cannot validate com.codersee.model.dto.ArticleRequest. No bean introspection present.
Please add @Introspected to the class and ensure Micronaut annotation processing is enabled
Additionally, we have to mark the author field with a @Valid annotation, so that it will be checked each time an ArticleRequest is validated. However, this time we don’t have to use an @Introspected as it is always used as a property of ArticleRequest, not a separate request body.

10. Testing

As the last step, we can finally run our reactive Micronaut application and check out if everything is working as expected with our MongoDB instance.

10.1. Create a New Article

Let’s start with creating a new Article:

curl --location --request POST 'http://localhost:8080/articles' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "title": "Title 1",
    "category": "JAVA",
      "author": {
        "firstName": "First Name 1",
        "lastName": "Last Name 1",
        "email": "one@gmail.com"
      }
  }'

# Result when successful: 
Status Code: 201 Created
Response Body: 
{
  "id": "634f9e825b223f0572585007",
  "title": "Title 1",
  "category": "JAVA",
  "author":
    {
      "firstName": "First Name 1",
      "lastName": "Last Name 1",
      "email": "one@gmail.com"
    }
}

# Response when validations failed: 
Status: 400 Bad Request
Response Body:
{
  "message": "Bad Request",
  "_embedded": {
  "errors": [
    {
      "message": "request.author.email: must be a well-formed email address"
    },
    {
      "message": "request.author.firstName: must not be blank"
    },
    {
      "message": "request.author.lastName: size must be between 0 and 50"
    },
    {
      "message": "request.title: must not be blank"
    }
  ]
},
  "_links": {
    "self": {
      "href": "/articles",
      "templated": false
    }
  }
}

As can be seen, everything is working correctly and validation works for both article and author payload.

10.2. List All Articles

Nextly, let’s list all articles:

curl --location --request GET 'http://localhost:8080/articles'

# Response:
Status Code: 200 OK 
Example response body: 
[
  {
    "id": "634f9fff5b223f0572585008",
    "title": "Title 1",
    "category": "JAVA",
    "author": {
      "firstName": "First Name 1",
      "lastName": "Last Name 1",
      "email": "one@gmail.com"
    }
  }
]

Everything is fine in this case.

10.3. GET Article By Id

Following, let’s get the details of the article by ID:

curl --location --request GET 'http://localhost:8080/articles/634fa0f05b223f057258500a'

# Response:
Status Code: 200 OK 
Example response body when found: 
{
  "id": "634fa0f05b223f057258500a",
  "title": "Another 2",
  "category": "JAVA",
  "author": {
    "firstName": "First Name 1",
    "lastName": "Last Name 1",
    "email": "one@gmail.com"
  }
}

# Response when article not found:
{
  "message": "Not Found",
  "_embedded": {
    "errors": [
      {
        "message": "Article with id: 634fa0f05b223f057258500b does not exists."
      }
    ]
  },
  "_links": {
    "self": {
      "href": "/articles/634fa0f05b223f057258500b",
      "templated": false
    }
  }
}

Similarly, works flawlessly for both cases.

10.4. Homework

The rest of the endpoints will be your homework and I highly encourage you to check them all.

Below, you can find the remaining cURLs, which can be used for testing:

# Update endpoint:
curl --location --request PUT 'http://localhost:8080/articles/634fa0f05b223f057258500a' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "title": "Changed Title",
    "category": "JAVASCRIPT",
    "author": {
      "firstName": "First Name 4",
      "lastName": "Last Name 4",
      "email": "two@gmail.com"
    }
  }'

# Search endpoint:
curl --location --request POST 'http://localhost:8080/articles/search' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "title": "1"
  }'

# Delete endpoint:
curl --location --request DELETE 'http://localhost:8080/articles/634cf9d458a6a33cdd19fdab'

10. Reactive Micronaut with MongoDB Summary

And that would be all for this article on how to expose REST API using Reactive Micronaut with MongoDB.

As always, you can find the whole source code in this GitHub repository.

If you find this material helpful (or not) or would like to ask me about anything, please leave a comment in the section below. I always highly appreciate your feedback and I am happy to chat with you 😀

Take care and have a great week!

5 Responses

  1. Another great one Piotr! It inspired me to pull up some info on Flux and Mono. I didn’t see the point at first given Java streams, but I now see the reason for their existence and they are quite powerful and useful. I will spend more time mining the depths of their capability. QUESTION: why flatmap vs. just map usage for the update function, for example? That function (and the others) is quite elegant, but makes my brain hurt a little 🙂

    1. A reactive approach and to be more specific here- a Project Reactor definitely are worth learning (and even if we won’t use them in any project it’s a great way to strech our brains and think outside the box). On the other hand, this shift from a “standard” approach is a bit painful (and the infographics from the documentation do not help- at least in my case when I saw them for the first time 🙂).
      Either way- as with probably every new technology- a bit of pain and sweat and it becomes easier.
      Finally, answering the question (I know, I had to add a bit of storytelling 😏):

      • a map function takes a Function as an argument
      • a flatMap takes a Function<T, Publisher>

      In the example above- the articleRepository.update function returns a Mono (which implements a Publisher), so we have to use the flatMap– otherwise we would end up with a Mono<Mono<T>> instead (just like flatMap in streams API flattens the structure and allows to avoid Stream<Stream> structure).
      And maybe I wouldn’t use the term “flatten” in terms of the Project Reactor, I believe this comparison is a good way to start with in the beginning and gradually learn other concepts, like subscriptions etc.

      Hope this answer helps (and even I sweated a bit when trying to describe it)! 😁

  2. Totally answered it! I think the “flatten” term here might be getting in the way? There’s probably not a better name, but the word “flat”, in this context, has never really resonated with me for some reason. Maybe something like “single” would make more sense to me? If you’re dealing with multiple dimensions of arrays, or in this case, a compounding of objects, and use flatMap, you’re really after a single array or single object. Unless something makes the word “flat” make sense to me, I think I’m calling this a singleMap (in my head), from now on 🙂

    1. Of course agree with you, but I remember that I once saw on StackOverflow that someone explained it that “flat” is the opposite of “nested” and flattening is a removal of one nesting level 🙂
      Since then it resonates a bit better with me 😁

  3. “nested” 🤔
    That’s interesting. A flatmap untangles the nest. I like it!
    Or maybe think of it like how a flat iron, that women use to straighten hair, gets rid of the curls. We’re eliminating data curliness 😆
    These are great images to help me “untangle” the idea of a flatmap.

Leave a Reply

Your email address will not be published. Required fields are marked *

Categories

Author

Hi there! 👋

Hi there! 👋

My name is Piotr and I've created Codersee to share my knowledge about Kotlin, Spring Framework, and other related topics through practical, step-by-step guides. Always eager to chat and exchange knowledge.

Join the FREE weekly newsletter and get two free eBooks:

Image shows the covers of free ebooks accessible for newsletter subscribers.

You may opt out any time. Terms of Use and Privacy Policy.

To make Codersee work, we log user data. By using our site, you agree to our Privacy Policy and Terms of Use.