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
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:
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:
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
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
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 🙂
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 😏):
Function
as an argumentFunction<T, Publisher>
In the example above- the
articleRepository.update
function returns a Mono (which implements a Publisher), so we have to use theflatMap
– otherwise we would end up with aMono<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)! 😁
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 🙂
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 😁
“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.