Reactive REST API with Micronaut and MongoDB | ninjasquad
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
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!
Source: Internet