1. Introduction
Hi folks! In this step-by-step guide, I will show you how to create a REST API using Micronaut with MongoDB and Kotlin.
As you might have noticed, this article is a revisited version of another blog post, which I created a long time ago. And before we start I would like to commend for upgrade suggestions made via software development work from Software Prophets. Without Eric’s comments, involvement and feedback, this article would never have been written. Thanks! 😀
Finally, I just wanted to admit that after this blog post, you will know precisely:
- how to spin up a MongoDB instance with Docker,
- how to generate a new Micronaut project and add dependencies (like MongoDB) with ease,
- how to persist and read data with MongoClient and data repositories for MongoDB,
- how to expose REST endpoints.
2. New Micronaut with MongoDB and Kotlin Project
As the first step, let’s generate a new Micronaut project with MongoDB dependencies. We can do it in three ways:
- using Micronaut CLI,
- with cURL,
- or with the Micronaut Launch page.
And although the CLI is a preferred way to generate new projects, for simplicity, let’s have a look at how to do it with the Launch page:
As we can see, this website provides us with a clickable interface, where we can specify what exactly should our project consist of.
To be on the same page and make sure that everything works, as expected, let’s have a look at the options I’ve chosen:
- Application Type: Micronaut Application
- Micronaut Version: 3.7.1
- Java Version: 17
- Language: Kotlin
- Name: rest-mongodb (feel free to specify your own right here)
- Build Tool: Gradle Kotlin
- Base Package: com.codersee (similarly, you can pick whatever you want)
- Test Framework: JUnit
- Features: data-mongodb, mongo-sync (these two are important!)
With all of that being selected, let’s click the Generate Project button, download the Zip package, and open up the project with our favorite IDE (IntelliJ in my case).
3. Run MongoDB With Docker
Nextly, let’s set up a new MongoDB instance, which we will use to connect to it later. I’ll do it with Docker, but if you would like to install it on your local machine, you can find more information on this, official MongoDB page.
So, firstly, let’s pull the Mongo image:
docker pull mongo
As the next step, let’s start a new container named mongodb in a detached mode:
docker run -d -p 27017:27017 --name mongodb mongo
As we can see, additionally we have to expose the container’s port 27017 to some port of our local machine (and I used the exact same port to make everything simpler).
To validate, we can 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
The above output indicates that our container is up and running and that the ports are mapped correctly.
4. Edit application.yaml File
After that is done, let’s get back to our project and edit the mongodb.uri
property:
mongodb.uri: mongodb://localhost:27017/example
As we can see, apart from pointing to the desired host and location, we specify the database name we would like to use- example
in our case.
5. Create Model Classes
As the next step, let’s implement two classes we will use in our Micronaut app to exchange data with MongoDB:
- AppUser– which will be responsible for transferring the data, like first and last name, email, and address of the application user,
- Address– which will be the inner class responsible for keeping user address information.
Let’s start with the second one, the Address class:
@Serializable @Deserializable data class Address( val street: String, val city: String, val code: Int )
As we can see, in order to work with MongoDB, we have to mark our data class with @Serializable and @Deserializable annotations. No other annotation is necessary here as this class will not be mapped to any collection.
Additionally, the Micronaut creators deserve recognition here, because even if we forget to do so, we will see an exception with a meaningful message, like:
No serializable introspection present for type Address address. Consider adding Serdeable. Serializable annotate to type Address address. Alternatively if you are not in control of the project’s source code, you can use @SerdeImport(Address.class) to enable serialization of this type.
Or:
No deserializable introspection present for type: Address. Consider adding Serdeable.Deserializable annotate to type Address. Alternatively if you are not in control of the project’s source code, you can use @SerdeImport(Address.class) to enable deserialization of this type.
After that, let’s add the AppUser data class:
@MappedEntity data class AppUser( @field:Id @field:GeneratedValue val id: String? = null, val firstName: String, val lastName: String, val email: String, val address: Address )
This class, apart from the standard fields, has 4 features that are worth paying attention to:
- @MappedEntity – an annotation used to mark a class as being persisted.
- @field:Id – simply the @Id annotation applied to the backing field of Kotlin property. With this one, we designate that this field is the ID of an entity.
- @field:GeneratedValue– similarly, the @GeneratedValue annotation indicates that the property will have a generated value.
- val id: String? = null– a nullable id property, with a null set by default. Thanks to that, the ID will be generated automatically.
6. Create DTOs
As the next step, let’s add DTOs to our Micronaut project.
Firstly, let’s implement the AppUserRequest:
data class AppUserRequest( val firstName: String, val lastName: String, val email: String, val street: String, val city: String, val code: Int )
This class will be used to create and update users later.
Moreover, let’s add the SearchRequest:
data class SearchRequest(val name: String)
This one will be used later in search requests.
7. Micronaut With MongoDB and Data Repositories
As I mentioned in the introduction, in this Micronaut with MongoDB and Kotlin tutorial I would like to show you two different approaches on how to persist and fetch the data.
And in this paragraph, we’ll take a look at the synchronous data repositories for MongoDB.
7.1. Implement AppUserRepository
As the first step, let’s add the AppUserRepository interface:
@MongoRepository interface AppUserRepository : CrudRepository<AppUser, String>
As we can see, to make it a valid repository in terms of Micronaut, we have to mark our interface with the @MongoRepository annotation.
Additionally, we extend the generic CrudRepository providing the AppUser as the entity type and String as the ID type. As the name suggests, this repository let’s us perform CRUD operations, like save, update all, find, etc…
7.2. Make Use Of Finder Methods
Nextly, let’s make use of the finder methods feature. To put it simply, a finder method name will be translated to a valid query based on its name.
Let’s add the example definition to our interface:
fun findByFirstNameEquals(firstName: String): List<AppUser>
As the name suggests, when using this function all users with a first name equal to the passed argument will be returned. If you would like to learn a bit more about this functionality, then please check out this link to the documentation.
7.3. Custom Query With @MongoFindQuery
As the next step, let’s create a custom query with @MongoFindQuery annotation:
@MongoFindQuery("{ firstName: { \$regex: :name}}") fun findByFirstNameLike(name: String): List<AppUser>
This time, the users will be returned if their first name contains a given String value. It’s worth mentioning that this annotation lets us specify additional sorting, filtering, or custom fields projections, as well.
Of course, find is not the only supported operation and we can make use of other annotations to specify update, delete, or aggregate queries:
- @MongoUpdateQuery
- @MongoDeleteQuery
- @MongoAggregateQuery
7.4. Create AppUserService
With all of the above being done, we have everything to implement the AppUserService:
@Singleton class AppUserService( private val appUserRepository: AppUserRepository ) { fun create(userRequest: AppUserRequest): AppUser = appUserRepository.save( userRequest.toAppUserEntity() ) fun findAll(): List<AppUser> = appUserRepository .findAll() .toList() fun findById(id: String): AppUser = appUserRepository.findById(id) .orElseThrow { HttpStatusException(HttpStatus.NOT_FOUND, "User with id: $id was not found.") } fun update(id: String, updateRequest: AppUserRequest): AppUser { val foundUser = findById(id) val updatedEntity = updateRequest .toAppUserEntity() .copy(id = foundUser.id) return appUserRepository.update(updatedEntity) } fun deleteById(id: String) { val foundUser = findById(id) appUserRepository.delete(foundUser) } fun findByNameLike(name: String): List<AppUser> = appUserRepository .findByFirstNameLike(name) private fun AppUserRequest.toAppUserEntity(): AppUser { val address = Address( street = this.street, city = this.city, code = this.code ) return AppUser( id = null, firstName = this.firstName, lastName = this.lastName, email = this.email, address = address ) } }
As can be seen, we mark our service with a @Singleton annotation to indicate that it should be instantiated only once.
Let’s have a few words about each function:
- create(userRequest: AppUserRequest)– this one takes the AppUserRequest instance as an argument and invokes the
toAppUserEntity()
extension function returning a new AppUser instance. With this instance, we invoke thesave()
method returning the newly created object. - findAll()– this function invokes the
findAll()
method from CrudRepository and converts returned Iterable to list. - findById(id: String)– this one is a bit more interesting. Firstly, we invoke the
findById()
method returning an Optional of the AppUser. If it’s empty, we throw the HttpStatusException. In Micronaut, this class can be used to throw exceptions returning the desired status code and message. And that’s what we do here- as a result, the 400 Bad Request informing that the user with the given id was not found is returned. - update(id: String, updateRequest: AppUserRequest)– as we can see, this one takes the desired id and an update request as the arguments. Firstly, we make use of already implemented
findById()
to fetch the desired user thus making sure he exists. Then, we simply convert our request to a new entity, but compared to thecreate()
, have to set the id. Finally, we invoke theupdate()
returning an updated user instance - deleteById(id: String)– with this one we first make sure that the user exists and then, we invoke the
delete()
method of our repository. - findByNameLike(name: String)– finally, this one is responsible for passing the name argument to our custom function:
findByFirstNameLike(name)
.
8. Add Controller
As the last thing before we will be able to test our Micronaut with MongoDB and Kotlin application, let’s implement the AppUserController:
@Controller("/users") @ExecuteOn(TaskExecutors.IO) class AppUserController( private val appUserService: AppUserService ) { @Get fun findAllUsers(): List<AppUser> = appUserService.findAll() @Get("/{id}") fun findById(@PathVariable id: String): AppUser = appUserService.findById(id) @Post @Status(CREATED) fun createUser(@Body request: AppUserRequest): AppUser = appUserService.create(request) @Post("/search") fun searchUsers(@Body searchRequest: SearchRequest): List<AppUser> = appUserService.findByNameLike( name = searchRequest.name ) @Put("/{id}") fun updateById( @PathVariable id: String, @Body request: AppUserRequest ): AppUser = appUserService.update(id, request) @Delete("/{id}") @Status(NO_CONTENT) fun deleteById(@PathVariable id: String) = appUserService.deleteById(id) }
This time, everything starts with the @Controller annotation, where we put the base URI for this controller- “/users” in our case. As the name suggests, this annotation simply indicates that this class is a controller in our application.
Then, we specify the @ExecuteOn used to indicate which executor service use to run a particular task. In our case, we make use of the IO scheduler used to schedule I/O tasks (Thank you Captain Obvious 🙂 ) .
When it comes to the functions, we use the @Get, @Post, @Put, and @Delete annotations to specify what HTTP Methods they should handle and additional routes, like “/users/search”. To bind a parameter from a path variable, we make use of the @PathVariable and @Body to do the same with a request body.
Finally, the @Status annotation is really useful to set the desired response code.
9. Testing
Let’s start by creating a new user with a POST request (and I highly recommend creating a few different users to better see if everything works, as expected):
curl --location --request POST 'localhost:8080/users' \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Piotr", "lastName": "Wolak", "email": "contact@codersee.com", "street": "Some Street", "city": "Some city", "code": 1 }' # Response: { "id": "633c6fb788e9024f42951f18", "firstName": "Piotr", "lastName": "Wolak", "email": "contact@codersee.com", "address": { "street": "Some Street", "city": "Some city", "code": 1 } }
Secondly, let’s perform a GET request to see the list of all users:
curl --location --request GET 'localhost:8080/users/' # Example Response: [ { "id": "633c6fb788e9024f42951f18", "firstName": "Piotr", "lastName": "Wolak", "email": "contact@codersee.com", "address": { "street": "Some Street", "city": "Some city", "code": 1 } } ]
Thirdly, let’s check if querying by ID works as expected in both cases:
curl --location --request GET 'localhost:8080/users/633c6fb788e9024f42951f18' # User found: { "id": "633c6fb788e9024f42951f18", "firstName": "Piotr", "lastName": "Wolak", "email": "contact@codersee.com", "address": { "street": "Some Street", "city": "Some city", "code": 1 } } # User not found: # Status: 404 Not Found # Response body: { "message": "Not Found", "_embedded": { "errors": [ { "message": "User with id: 633c6fb788e9024f42951f19 was not found." } ] }, "_links": { "self": { "href": "/users/633c6fb788e9024f42951f19", "templated": false } } }
Following, let’s perform an update:
curl --location --request PUT 'localhost:8080/users/633c6fb788e9024f42951f18' \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "John", "lastName": "Doe", "email": "contact@codersee.com", "street": "Another Street", "city": "Another City", "code": 2 }' # Response: { "id": "633c6fb788e9024f42951f18", "firstName": "John", "lastName": "Doe", "email": "contact@codersee.com", "address": { "street": "Another Street", "city": "Another City", "code": 2 } } # To verify, we can perform a GET request once again.
Before we check the DELETE endpoint, let’s make an example search query:
curl --location --request POST 'localhost:8080/users/search' \ --header 'Content-Type: application/json' \ --data-raw '{ "name": "Joh" } ' # Result: [ { "id": "633c6fb788e9024f42951f18", "firstName": "John", "lastName": "Doe", "email": "contact@codersee.com", "address": { "street": "Another Street", "city": "Another City", "code": 2 } } ]
Finally, let’s delete the user:
curl --location --request DELETE 'localhost:8080/users/633c6fb788e9024f42951f18' # Response: # Status 204 No Content # Response Body: empty # If we repeat the request we should get: { "message": "Not Found", "_embedded": { "errors": [ { "message": "User with id: 633c6fb788e9024f42951f18 was not found." } ] }, "_links": { "self": { "href": "/users/633c6fb788e9024f42951f18", "templated": false } } }
As we can clearly see, everything is working, as expected.
10. Micronaut With MongoDB and MongoClient
Ok, so for now we’ve learned how to use Micronaut with MongoDB and Kotlin and data repositories for MongoDB.
Before we end this article, I would like to show you one more way to interact with a Mongo database- the MongoClient. According to the documentation, MongoClient is:
A client-side representation of a MongoDB cluster. Instances can represent either a standalone MongoDB instance, a replica set, or a sharded cluster. Instance of this class are responsible for maintaining an up-to-date state of the cluster, and possibly cache resources related to this, including background threads for monitoring, and connection pools.
Simply put- this class lets us operate on a MongoDB cluster. In our case, we will use it to perform operations on the example database and app_user collection.
11. Add AppUserWithMongoClientService
Although you probably don’t want to name classes in your project this way, I’d like to distinguish somehow between these two approaches 😀
With that being said, let’s implement the AppUserWithMongoClientService:
@Singleton class AppUserWithMongoClientService( private val mongoClient: MongoClient ) { fun create(userRequest: AppUserRequest): BsonValue { val createResult = getCollection() .insertOne(userRequest.toAppUserEntity()) return createResult.insertedId ?: throw HttpStatusException(HttpStatus.BAD_REQUEST, "User was not created.") } fun findAll(): List<AppUser> = getCollection() .find() .toList() fun findById(id: String): AppUser = getCollection() .find( Filters.eq("_id", ObjectId(id)) ) .toList() .firstOrNull() ?: throw HttpStatusException(HttpStatus.NOT_FOUND, "User with id: $id was not found.") fun update(id: String, updateRequest: AppUserRequest): AppUser { val updateResult = getCollection() .replaceOne( Filters.eq("_id", ObjectId(id)), updateRequest.toAppUserEntity() ) if (updateResult.modifiedCount == 0L) throw HttpStatusException(HttpStatus.BAD_REQUEST, "User with id: $id was not updated.") return findById(id) } fun deleteById(id: String) { val deleteResult = getCollection() .deleteOne( Filters.eq("_id", ObjectId(id)) ) if (deleteResult.deletedCount == 0L) throw HttpStatusException(HttpStatus.BAD_REQUEST, "User with id: $id was not deleted.") } fun findByNameLike(name: String): List<AppUser> = getCollection() .find( Filters.regex("firstName", name) ) .toList() private fun getCollection(): MongoCollection = mongoClient .getDatabase("example") .getCollection("app_user", AppUser::class.java) private fun AppUserRequest.toAppUserEntity(): AppUser { val address = Address( street = this.street, city = this.city, code = this.code ) return AppUser( id = null, firstName = this.firstName, lastName = this.lastName, email = this.email, address = address ) } }
Just like previously, we can see that we annotated the class as a @Singleton and reuse the toAppUserEntity()
extension function.
As you might have noticed, this time we added the getCollection()
function responsible for getting a MongoCollection<AppUser> instances. This class lets us utilize methods, like find()
, insertOne()
, or deleteOne()
.
Moreover, in every function except the create()
we make use of the Filters class, which is basically a factor for query filters.
12. Implement AppUserWithMongoClientService
And the last thing we will have to do in our Micronaut with MongoDB and Kotlin application is to add the AppUserWithMongoClientController:
@Controller("/users-client") @ExecuteOn(TaskExecutors.IO) class AppUserWithMongoClientController( private val appUserWithMongoClientService: AppUserWithMongoClientService ) { @Get fun findAllUsers(): List<AppUser> = appUserWithMongoClientService.findAll() @Get("/{id}") fun findById(@PathVariable id: String): AppUser = appUserWithMongoClientService.findById(id) @Post @Status(CREATED) fun createUser(@Body request: AppUserRequest): HttpResponse<Void> { val createdId = appUserWithMongoClientService.create(request) return HttpResponse.created( URI.create( createdId.asObjectId().value.toHexString() ) ) } @Post("/search") @Status(CREATED) fun searchUser(@Body searchRequest: SearchRequest): List<AppUser> = appUserWithMongoClientService.findByNameLike( name = searchRequest.name ) @Put("/{id}") fun updateById( @PathVariable id: String, @Body request: AppUserRequest ): AppUser = appUserWithMongoClientService.update(id, request) @Delete("/{id}") @Status(NO_CONTENT) fun deleteById(@PathVariable id: String) = appUserWithMongoClientService.deleteById(id) }
As we can see, this implementation is almost identical compared to the previous controller. Worth noting are two things:
- to avoid URI conflicts, this one responds to the
/users-client
, - the createUser functionality works a bit differently- instead of returning the created entity, we make use of the 201 Created status code and pass the id of the created object as the
location
header. That wasn’t necessary, but I wanted to show you another approach, as well 😀
13. Test Additional Endpoints
Finally, we can test our new endpoints and make sure everything runs smoothly.
Nevertheless, I will not duplicate paragraph 10 right here, so this one will be your homework. There are two differences you have to remember about when testing this new controller, but if you’ve read the article thoroughly, I am pretty sure you will know exactly what I’m talking about.
14. Micronaut with MongoDB and Kotlin Revisited Summary
And that would be all for this article about Micronaut with MongoDB and Kotlin. If you’d like to see the whole source code, then please visit this GitHub repository.
Let me know if this article was useful for you in the comment section (or through the contact form– if that suits you best). Of course, if you feel this post is a total crap and do not recommend it- please leave a comment, as well- always happy to chat and open for criticism 🙂
Have a great day!