Mastering Kotlin: Creating an API With Ktor and MongoDB Atlas
Rate this tutorial
Kotlin's simplicity, Java interoperability, and Ktor's user-friendly framework combined with MongoDB Atlas' flexible cloud database provide a robust stack for modern software development.
Together, we'll demonstrate and set up the Ktor project, implement CRUD operations, define API route endpoints, and run the application. By the end, you'll have a solid understanding of Kotlin's capabilities in API development and the tools needed to succeed in modern software development.
As you can see above, our application will be capable of performing the operations depicted in the image. To accomplish this, we will utilize a data model structured similarly to the example provided:
1 fitness { 2 _id: objectId, 3 exerciseType: String, 4 notes: String, 5 fitnessDetails: { 6 durationMinutes: Int, 7 distance: Double, 8 caloriesBurned: Int 9 } 10 }
Ktor is a Kotlin-based, asynchronous web framework designed for building modern web applications and APIs. Developed by JetBrains, the same team behind Kotlin, Ktor simplifies the process of building web applications while offering robust support for asynchronous programming using Kotlin coroutines.
As depicted in the images, we need to configure parameters such as the project name, configuration file, etc.
In the subsequent section, we specify the following plugins:
- Content Negotiation: facilitates the negotiation of media types between the client and server
- GSON: offers serialization and deserialization capabilities
- Routing: manages incoming requests within a server application
- Swagger: simplifies API documentation and development
Once all settings are in place, simply click “Generate Project” to proceed with creating our project.
After importing the project, let’s proceed by opening the
build.gradle.kts
file to incorporate additional dependencies. Specifically, we'll be adding dependencies for the MongoDB server-side Kotlin driver and Koin for injection dependency.1 dependencies { 2 implementation("io.ktor:ktor-server-core-jvm") 3 implementation("io.ktor:ktor-server-swagger-jvm") 4 implementation("io.ktor:ktor-server-content-negotiation-jvm") 5 implementation("io.ktor:ktor-serialization-gson-jvm") 6 implementation("io.ktor:ktor-server-tomcat-jvm") 7 implementation("ch.qos.logback:logback-classic:$logback_version") 8 9 //MongoDB 10 implementation("org.mongodb:mongodb-driver-kotlin-coroutine:4.10.1") 11 12 //Koin Dependency Injection 13 implementation("io.insert-koin:koin-ktor:3.5.3") 14 implementation("io.insert-koin:koin-logger-slf4j:3.5.3") 15 }
Before creating our project, let’s organize it into several packages. To achieve this, we’ll create three packages:
application
, domain
, and infrastructure
as shown in the image below:Now, let's create a package called
entity
inside domain and include a file named Fitness.kt:domain/entity/Fitness.kt
1 package com.mongodb.domain.entity 2 import com.mongodb.application.response.FitnessResponse 3 import org.bson.codecs.pojo.annotations.BsonId 4 import org.bson.types.ObjectId 5 6 data class Fitness( 7 8 val id: ObjectId, 9 val exerciseType: String, 10 val notes: String, 11 val details: FitnessDetails 12 ){ 13 fun toResponse() = FitnessResponse( 14 id = id.toString(), 15 exerciseType = exerciseType, 16 notes = notes, 17 details = details 18 ) 19 } 20 21 data class FitnessDetails( 22 val durationMinutes: Int, 23 val distance: Double, 24 val caloriesBurned: Int 25 )
As you can observe, our class has an error in the
toResponse()
method because we haven't created the FitnessResponse
class yet. To fix this issue, we need to create the FitnessResponse
class. Let's take this opportunity to create both FitnessResponse
and FitnessRequest
. These classes will handle the data exchanged in HTTP requests related to the Fitness entity. Inside the application package, create request
and response
packages. Include the FitnessRequest
and FitnessResponse
classes in each, respectively.application/request/FitnessRequest.kt:
1 package com.mongodb.application.request 2 3 import com.mongodb.domain.entity.Fitness 4 import com.mongodb.domain.entity.FitnessDetails 5 import org.bson.types.ObjectId 6 7 data class FitnessRequest( 8 val exerciseType: String, 9 val notes: String, 10 val details: FitnessDetails 11 ) 12 fun FitnessRequest.toDomain(): Fitness { 13 return Fitness( 14 id = ObjectId(), 15 exerciseType = exerciseType, 16 notes = notes, 17 details = details 18 ) 19 }
application/response/FitnessResponse.kt:
1 package com.mongodb.application.response 2 3 4 import com.mongodb.domain.entity.FitnessDetails 5 6 7 data class FitnessResponse( 8 val id: String, 9 val exerciseType: String, 10 val notes: String, 11 val details: FitnessDetails 12 )
If everything is correct, our structure will look similar to the image below:
Now, it’s time to create our interface that will communicate with our database. To do this, within the
domain
package, we will create another package called ports
, and subsequently, the FitnessRepository
interface.domain/ports/FitnessRepository:
1 package com.mongodb.domain.ports 2 3 import com.mongodb.domain.entity.Fitness 4 import org.bson.BsonValue 5 import org.bson.types.ObjectId 6 7 interface FitnessRepository { 8 suspend fun insertOne(fitness: Fitness): BsonValue? 9 suspend fun deleteById(objectId: ObjectId): Long 10 suspend fun findById(objectId: ObjectId): Fitness? 11 suspend fun updateOne(objectId: ObjectId, fitness: Fitness): Long 12 }
Perfect! Now, we need to implement the methods of our interface. We'll access the
infrastructure
package and create a repository
package inside it. After that, create a class FitnessRepositoryImpl
, then implement the methods as shown in the code below:infrastructure/repository/FitnessRepositoryImpl
1 package com.mongodb.infrastructure.repository 2 3 import com.mongodb.MongoException 4 import com.mongodb.client.model.Filters 5 import com.mongodb.client.model.UpdateOptions 6 import com.mongodb.client.model.Updates 7 import com.mongodb.domain.entity.Fitness 8 import com.mongodb.domain.ports.FitnessRepository 9 import com.mongodb.kotlin.client.coroutine.MongoDatabase 10 import kotlinx.coroutines.flow.firstOrNull 11 import org.bson.BsonValue 12 import org.bson.types.ObjectId 13 14 class FitnessRepositoryImpl( 15 private val mongoDatabase: MongoDatabase 16 ) : FitnessRepository { 17 18 companion object { 19 const val FITNESS_COLLECTION = "fitness" 20 } 21 22 override suspend fun insertOne(fitness: Fitness): BsonValue? { 23 try { 24 val result = mongoDatabase.getCollection<Fitness>(FITNESS_COLLECTION).insertOne( 25 fitness 26 ) 27 return result.insertedId 28 } catch (e: MongoException) { 29 System.err.println("Unable to insert due to an error: $e") 30 } 31 return null 32 } 33 34 override suspend fun deleteById(objectId: ObjectId): Long { 35 try { 36 val result = mongoDatabase.getCollection<Fitness>(FITNESS_COLLECTION).deleteOne(Filters.eq("_id", objectId)) 37 return result.deletedCount 38 } catch (e: MongoException) { 39 System.err.println("Unable to delete due to an error: $e") 40 } 41 return 0 42 } 43 44 override suspend fun findById(objectId: ObjectId): Fitness? = 45 mongoDatabase.getCollection<Fitness>(FITNESS_COLLECTION).withDocumentClass<Fitness>() 46 .find(Filters.eq("_id", objectId)) 47 .firstOrNull() 48 49 override suspend fun updateOne(objectId: ObjectId, fitness: Fitness): Long { 50 try { 51 val query = Filters.eq("_id", objectId) 52 val updates = Updates.combine( 53 Updates.set(Fitness::exerciseType.name, fitness.exerciseType), 54 Updates.set(Fitness::notes.name, fitness.notes), 55 Updates.set(Fitness::details.name, fitness.details) 56 ) 57 val options = UpdateOptions().upsert(true) 58 val result = 59 mongoDatabase.getCollection<Fitness>(FITNESS_COLLECTION) 60 .updateOne(query, updates, options) 61 62 return result.modifiedCount 63 } catch (e: MongoException) { 64 System.err.println("Unable to update due to an error: $e") 65 } 66 return 0 67 } 68 }
As observed, the class implements the
FitnessRepository
interface and includes a companion object. It receives a MongoDatabase
instance as a constructor parameter. The companion object defines a constant named FITNESS_COLLECTION
, with a value of "fitness," indicating the name of the MongoDB collection where operations such as insert, find, delete, and update are performed on fitness-related data.If everything is correct, our structure will be as follows:
To create our endpoints, understanding routing is crucial. In Ktor, routing dictates how the server handles incoming requests to specific URLs, enabling developers to define actions or responses for each endpoint. Before proceeding with the creation process, let’s briefly review the endpoints we’ll be constructing:
- GET /fitness/{id}: Utilize this endpoint to fetch detailed information about a specific fitness activity based on its unique identifier (ID).
- POST /fitness: With this endpoint, you can effortlessly add new fitness activities to your tracker, ensuring all your workout data is up-to-date.
- PATCH /fitness/{id}: Need to update or modify an existing fitness activity? This endpoint allows you to make targeted changes to specific activities identified by their unique ID.
- DELETE /fitness/{id}: Finally, this endpoint empowers you to remove unwanted or outdated fitness activities from your tracker, maintaining a clean and accurate record of your fitness journey.
Now that we know the methods we will implement, let's create our class that will do this work. Inside the
application
package, let's create a new one called routes
and the file below with the name FitnessRoute.kt
with the content below:application/routes/FitnessRoutes.kt
1 package com.mongodb.application.routes 2 3 import com.mongodb.application.request.FitnessRequest 4 import com.mongodb.application.request.toDomain 5 import com.mongodb.domain.ports.FitnessRepository 6 import io.ktor.http.HttpStatusCode 7 import io.ktor.server.application.call 8 import io.ktor.server.request.receive 9 import io.ktor.server.response.respond 10 import io.ktor.server.response.respondText 11 import io.ktor.server.routing.route 12 import io.ktor.server.routing.Route 13 import io.ktor.server.routing.post 14 import io.ktor.server.routing.delete 15 import io.ktor.server.routing.get 16 import io.ktor.server.routing.patch 17 import org.bson.types.ObjectId 18 import org.koin.ktor.ext.inject 19 20 fun Route.fitnessRoutes() { 21 val repository by inject<FitnessRepository>() 22 route("/fitness") { 23 post { 24 val fitness = call.receive<FitnessRequest>() 25 val insertedId = repository.insertOne(fitness.toDomain()) 26 call.respond(HttpStatusCode.Created, "Created fitness with id $insertedId") 27 } 28 29 delete("/{id?}") { 30 val id = call.parameters["id"] ?: return@delete call.respondText( 31 text = "Missing fitness id", 32 status = HttpStatusCode.BadRequest 33 ) 34 val delete: Long = repository.deleteById(ObjectId(id)) 35 if (delete == 1L) { 36 return@delete call.respondText("Fitness Deleted successfully", status = HttpStatusCode.OK) 37 } 38 return@delete call.respondText("Fitness not found", status = HttpStatusCode.NotFound) 39 } 40 41 get("/{id?}") { 42 val id = call.parameters["id"] 43 if (id.isNullOrEmpty()) { 44 return@get call.respondText( 45 text = "Missing id", 46 status = HttpStatusCode.BadRequest 47 ) 48 } 49 repository.findById(ObjectId(id))?.let { 50 call.respond(it.toResponse()) 51 } ?: call.respondText("No records found for id $id") 52 } 53 54 patch("/{id?}") { 55 val id = call.parameters["id"] ?: return@patch call.respondText( 56 text = "Missing fitness id", 57 status = HttpStatusCode.BadRequest 58 ) 59 val updated = repository.updateOne(ObjectId(id), call.receive()) 60 call.respondText( 61 text = if (updated == 1L) "Fitness updated successfully" else "Fitness not found", 62 status = if (updated == 1L) HttpStatusCode.OK else HttpStatusCode.NotFound 63 ) 64 } 65 } 66 }
As you can observe, we’ve set up method calls and utilized
call
to define actions within route handlers. Ktor allows you to manage incoming requests and send responses directly within these handlers, providing flexibility in handling different request scenarios. Additionally, we're utilizing Koin for dependency injection in the FitnessRepository
repository.Now, before we proceed to run the application, we need to install the Koin dependency and incorporate it as a module.
Modules in Ktor are used to organize and encapsulate functionality within your application. They allow you to group related routes, dependencies, and configurations for easier management and maintenance.
To accomplish this, open the
Application.kt
class and replace all of its code with the one below:com.mongodb.Application.kt
1 package com.mongodb 2 3 import com.mongodb.application.routes.fitnessRoutes 4 import com.mongodb.domain.ports.FitnessRepository 5 import com.mongodb.infrastructure.repository.FitnessRepositoryImpl 6 import com.mongodb.kotlin.client.coroutine.MongoClient 7 import io.ktor.serialization.gson.gson 8 import io.ktor.server.application.Application 9 import io.ktor.server.application.install 10 import io.ktor.server.plugins.contentnegotiation.ContentNegotiation 11 import io.ktor.server.plugins.swagger.swaggerUI 12 import io.ktor.server.routing.routing 13 import io.ktor.server.tomcat.EngineMain 14 import org.koin.dsl.module 15 import org.koin.ktor.plugin.Koin 16 import org.koin.logger.slf4jLogger 17 18 fun main(args: Array<String>): Unit = EngineMain.main(args) 19 20 fun Application.module() { 21 install(ContentNegotiation) { 22 gson { 23 } 24 } 25 install(Koin) { 26 slf4jLogger() 27 modules(module { 28 single { MongoClient.create( 29 environment.config.propertyOrNull("ktor.mongo.uri")?.getString() ?: throw RuntimeException("Failed to access MongoDB URI.") 30 ) } 31 single { get<MongoClient>().getDatabase(environment.config.property("ktor.mongo.database").getString()) } 32 }, module { 33 single<FitnessRepository> { FitnessRepositoryImpl(get()) } 34 }) 35 } 36 routing { 37 swaggerUI(path = "swagger-ui", swaggerFile = "openapi/documentation.yaml") { 38 version = "4.15.5" 39 } 40 fitnessRoutes() 41 } 42 }
Let’s break down what this code is doing into three main sections for easier understanding:
- ContentNegotiation configuration: We’re setting up ContentNegotiation to handle JSON serialization and deserialization. Specifically, we’re using Gson as the default JSON formatter, ensuring seamless communication between our application and clients.
- Dependency injection with Koin**:** In the subsequent section, we’re integrating Koin for dependency injection management. Within the first module, we establish the connection to MongoDB, retrieving the URI and database name from the configuration.conf file. Subsequently, we define injection for the FitnessRepository.
- Routing configuration: During the routing phase, we configure the Swagger API route, accessible at
/swagger-ui
. Furthermore, we specify our route for handling Fitness-related operations.
Great. Now, we need to make the final adjustments before running our application. First, open the
application.conf
file and include the following information:resources/application.conf
1 ktor { 2 deployment { 3 port = 8080 4 } 5 application { 6 modules = [ com.mongodb.ApplicationKt.module ] 7 } 8 mongo { 9 uri = ${?MONGO_URI} 10 database = ${?MONGO_DATABASE} 11 } 12 }
Notice: These variables will be used at the end of the article when we run the application.
Very well. Now, just open the
documentation.yaml
file and replace it with the content below. In this file, we are indicating which methods our API will provide when accessed through /swagger-ui
:resources/openapi/documentation.yaml
1 openapi: 3.0.0 2 info: 3 title: Fitness API 4 version: 1.0.0 5 description: | 6 This Swagger documentation file outlines the API specifications for a Fitness Tracker application built with Ktor and MongoDB. The API allows users to manage fitness records including creating new records, updating and deleting records by ID. The API uses the Fitness and FitnessDetails data classes to structure the fitness-related information. 7 8 paths: 9 /fitness: 10 post: 11 summary: Create a new fitness record 12 requestBody: 13 required: true 14 content: 15 application/json: 16 schema: 17 $ref: '#/components/schemas/FitnessRequest' 18 responses: 19 '201': 20 description: Fitness created successfully 21 '400': 22 description: Bad request 23 /fitness/{id}: 24 get: 25 summary: Retrieve fitness record by ID 26 parameters: 27 - name: id 28 in: path 29 required: true 30 schema: 31 type: string 32 responses: 33 '200': 34 description: Successful response 35 content: 36 application/json: 37 example: {} 38 '404': 39 description: Fitness not found 40 delete: 41 summary: Delete fitness record by ID 42 parameters: 43 - name: id 44 in: path 45 required: true 46 schema: 47 type: string 48 responses: 49 '200': 50 description: Fitness deleted successfully 51 '400': 52 description: Bad request 53 '404': 54 description: Fitness not found 55 patch: 56 summary: Update fitness record by ID 57 parameters: 58 - name: id 59 in: path 60 required: true 61 schema: 62 type: string 63 requestBody: 64 required: true 65 content: 66 application/json: 67 schema: 68 $ref: '#/components/schemas/FitnessRequest' 69 responses: 70 '200': 71 description: Fitness updated successfully 72 '400': 73 description: Bad request 74 '404': 75 description: Fitness not found 76 77 components: 78 schemas: 79 Fitness: 80 type: object 81 properties: 82 id: 83 type: string 84 format: uuid 85 exerciseType: 86 type: string 87 notes: 88 type: string 89 details: 90 $ref: '#/components/schemas/FitnessDetails' 91 required: 92 - id 93 - notes 94 - details 95 96 FitnessDetails: 97 type: object 98 properties: 99 durationMinutes: 100 type: integer 101 format: int32 102 distance: 103 type: number 104 format: double 105 caloriesBurned: 106 type: integer 107 format: int32 108 required: 109 - durationMinutes 110 - distance 111 - caloriesBurned 112 113 FitnessRequest: 114 type: object 115 properties: 116 exerciseType: 117 type: string 118 notes: 119 type: string 120 details: 121 $ref: '#/components/schemas/FitnessDetails' 122 required: 123 - exerciseType 124 - notes 125 - details
The final adjustment is to delete the
plugins
folder, which contains the HTTP.kt
, Routing.kt
, and Serialization.kt
files, as we have already included them in the Application.kt
class. Additionally, delete the ApplicationTest
class since our focus here is not on tests. We can discuss testing another time. After these changes, your structure should look similar to this:To run our application, we need a connection string from MongoDB Atlas. If you don't have access yet, create your account.
Once your account is created, access the Overview menu, then Connect, and select Kotlin. After that, our connection string will be available as shown in the image below:
With the connection string in hand, let's return to IntelliJ, open the
Application.kt
class, and click on the run button, as shown in the image:At this stage, you will notice that our application encountered an error connecting to MongoDB Atlas. This is where we need to provide the
MONGO_URI
and MONGO_DATABASE
defined in the application.conf
file:To do this, simply edit the configuration and include the values as shown in the image below:
1 -DMONGO_URI= Add your connection string 2 -DMONGO_DATABASE= Add your database name
Click “Apply” and run the application again.
Attention: Remember to change the connection string to your username, password, and cluster in MongoDB Atlas.
Now, simply access
localhost:8080/swagger-ui
and perform the operations.Open the post method and insert an object for testing:
We can see the data recorded in our MongoDB Atlas cluster as shown in the image below:
MongoDB Atlas, Ktor, and Kotlin API service together form a powerful combination for building robust and scalable applications. MongoDB Atlas provides a cloud-based database solution that offers flexibility, scalability, and reliability, making it ideal for modern applications. Ktor, with its lightweight and asynchronous nature, enables rapid development of web services and APIs in Kotlin, leveraging the language’s concise syntax and powerful features. When combined, MongoDB Atlas and Ktor in Kotlin allow developers to build high-performance APIs with ease, providing seamless integration with MongoDB databases while maintaining simplicity and flexibility in application development. This combination empowers developers to create modern, data-driven applications that can easily scale to meet evolving business needs.
The example source code used in this series is available in Github
Top Comments in Forums
Lucas_Carrijo_FerrariLucas Carrijo Ferrarilast quarter
Muito obrigado pelo artigo. Achei muito mais claro sua explicação de como conectar o Mongo, mais claro que a própria documentação do Ktor explicando como se conecta no Postgresql.
Só um comentário, o Ktor recomenda, além do GSON que você utilizou, o KotlinX para serialização, a implementação é da mesma forma?
Pelo nome já percebi que era brasileiro (tambem fui no Linkedin pra ter certeza haha).