Description: Set up a Ktor project, implement CRUD operations, define API route endpoints, and run the application. Understand Kotlin's capabilities in API development.
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.
Demonstration
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:
fitness {
_id: objectId,
exerciseType: String,
notes: String,
fitnessDetails: {
durationMinutes: Int,
distance: Double,
caloriesBurned: Int
}
}
Setting up the Ktor project
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.
To begin our project setup, we'll make use of the Ktor project generator.
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.
dependencies {
implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-server-swagger-jvm")
implementation("io.ktor:ktor-server-content-negotiation-jvm")
implementation("io.ktor:ktor-serialization-gson-jvm")
implementation("io.ktor:ktor-server-tomcat-jvm")
implementation("ch.qos.logback:logback-classic:$logback_version")
//MongoDB
implementation("org.mongodb:mongodb-driver-kotlin-coroutine:4.10.1")
//Koin Dependency Injection
implementation("io.insert-koin:koin-ktor:3.5.3")
implementation("io.insert-koin:koin-logger-slf4j:3.5.3")
}
Implementing CRUD operations
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
package com.mongodb.domain.entity
import com.mongodb.application.response.FitnessResponse
import org.bson.codecs.pojo.annotations.BsonId
import org.bson.types.ObjectId
data class Fitness(
@BsonId
val id: ObjectId,
val exerciseType: String,
val notes: String,
val details: FitnessDetails
){
fun toResponse() = FitnessResponse(
id = id.toString(),
exerciseType = exerciseType,
notes = notes,
details = details
)
}
data class FitnessDetails(
val durationMinutes: Int,
val distance: Double,
val caloriesBurned: Int
)
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:
package com.mongodb.application.request
import com.mongodb.domain.entity.Fitness
import com.mongodb.domain.entity.FitnessDetails
import org.bson.types.ObjectId
data class FitnessRequest(
val exerciseType: String,
val notes: String,
val details: FitnessDetails
)
fun FitnessRequest.toDomain(): Fitness {
return Fitness(
id = ObjectId(),
exerciseType = exerciseType,
notes = notes,
details = details
)
}
application/response/FitnessResponse.kt:
package com.mongodb.application.response
import com.mongodb.domain.entity.FitnessDetails
data class FitnessResponse(
val id: String,
val exerciseType: String,
val notes: String,
val details: FitnessDetails
)
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:
package com.mongodb.domain.ports
import com.mongodb.domain.entity.Fitness
import org.bson.BsonValue
import org.bson.types.ObjectId
interface FitnessRepository {
suspend fun insertOne(fitness: Fitness): BsonValue?
suspend fun deleteById(objectId: ObjectId): Long
suspend fun findById(objectId: ObjectId): Fitness?
suspend fun updateOne(objectId: ObjectId, fitness: Fitness): Long
}
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
package com.mongodb.infrastructure.repository
import com.mongodb.MongoException
import com.mongodb.client.model.Filters
import com.mongodb.client.model.UpdateOptions
import com.mongodb.client.model.Updates
import com.mongodb.domain.entity.Fitness
import com.mongodb.domain.ports.FitnessRepository
import com.mongodb.kotlin.client.coroutine.MongoDatabase
import kotlinx.coroutines.flow.firstOrNull
import org.bson.BsonValue
import org.bson.types.ObjectId
class FitnessRepositoryImpl(
private val mongoDatabase: MongoDatabase
) : FitnessRepository {
companion object {
const val FITNESS_COLLECTION = "fitness"
}
override suspend fun insertOne(fitness: Fitness): BsonValue? {
try {
val result = mongoDatabase.getCollection<Fitness>(FITNESS_COLLECTION).insertOne(
fitness
)
return result.insertedId
} catch (e: MongoException) {
System.err.println("Unable to insert due to an error: $e")
}
return null
}
override suspend fun deleteById(objectId: ObjectId): Long {
try {
val result = mongoDatabase.getCollection<Fitness>(FITNESS_COLLECTION).deleteOne(Filters.eq("_id", objectId))
return result.deletedCount
} catch (e: MongoException) {
System.err.println("Unable to delete due to an error: $e")
}
return 0
}
override suspend fun findById(objectId: ObjectId): Fitness? =
mongoDatabase.getCollection<Fitness>(FITNESS_COLLECTION).withDocumentClass<Fitness>()
.find(Filters.eq("_id", objectId))
.firstOrNull()
override suspend fun updateOne(objectId: ObjectId, fitness: Fitness): Long {
try {
val query = Filters.eq("_id", objectId)
val updates = Updates.combine(
Updates.set(Fitness::exerciseType.name, fitness.exerciseType),
Updates.set(Fitness::notes.name, fitness.notes),
Updates.set(Fitness::details.name, fitness.details)
)
val options = UpdateOptions().upsert(true)
val result =
mongoDatabase.getCollection<Fitness>(FITNESS_COLLECTION)
.updateOne(query, updates, options)
return result.modifiedCount
} catch (e: MongoException) {
System.err.println("Unable to update due to an error: $e")
}
return 0
}
}
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:
Developing API endpoints
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
package com.mongodb.application.routes
import com.mongodb.application.request.FitnessRequest
import com.mongodb.application.request.toDomain
import com.mongodb.domain.ports.FitnessRepository
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.route
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.patch
import org.bson.types.ObjectId
import org.koin.ktor.ext.inject
fun Route.fitnessRoutes() {
val repository by inject<FitnessRepository>()
route("/fitness") {
post {
val fitness = call.receive<FitnessRequest>()
val insertedId = repository.insertOne(fitness.toDomain())
call.respond(HttpStatusCode.Created, "Created fitness with id $insertedId")
}
delete("/{id?}") {
val id = call.parameters["id"] ?: return@delete call.respondText(
text = "Missing fitness id",
status = HttpStatusCode.BadRequest
)
val delete: Long = repository.deleteById(ObjectId(id))
if (delete == 1L) {
return@delete call.respondText("Fitness Deleted successfully", status = HttpStatusCode.OK)
}
return@delete call.respondText("Fitness not found", status = HttpStatusCode.NotFound)
}
get("/{id?}") {
val id = call.parameters["id"]
if (id.isNullOrEmpty()) {
return@get call.respondText(
text = "Missing id",
status = HttpStatusCode.BadRequest
)
}
repository.findById(ObjectId(id))?.let {
call.respond(it.toResponse())
} ?: call.respondText("No records found for id $id")
}
patch("/{id?}") {
val id = call.parameters["id"] ?: return@patch call.respondText(
text = "Missing fitness id",
status = HttpStatusCode.BadRequest
)
val updated = repository.updateOne(ObjectId(id), call.receive())
call.respondText(
text = if (updated == 1L) "Fitness updated successfully" else "Fitness not found",
status = if (updated == 1L) HttpStatusCode.OK else HttpStatusCode.NotFound
)
}
}
}
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
package com.mongodb
import com.mongodb.application.routes.fitnessRoutes
import com.mongodb.domain.ports.FitnessRepository
import com.mongodb.infrastructure.repository.FitnessRepositoryImpl
import com.mongodb.kotlin.client.coroutine.MongoClient
import io.ktor.serialization.gson.gson
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.swagger.swaggerUI
import io.ktor.server.routing.routing
import io.ktor.server.tomcat.EngineMain
import org.koin.dsl.module
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger
fun main(args: Array<String>): Unit = EngineMain.main(args)
fun Application.module() {
install(ContentNegotiation) {
gson {
}
}
install(Koin) {
slf4jLogger()
modules(module {
single { MongoClient.create(
environment.config.propertyOrNull("ktor.mongo.uri")?.getString() ?: throw RuntimeException("Failed to access MongoDB URI.")
) }
single { get<MongoClient>().getDatabase(environment.config.property("ktor.mongo.database").getString()) }
}, module {
single<FitnessRepository> { FitnessRepositoryImpl(get()) }
})
}
routing {
swaggerUI(path = "swagger-ui", swaggerFile = "openapi/documentation.yaml") {
version = "4.15.5"
}
fitnessRoutes()
}
}
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
ktor {
deployment {
port = 8080
}
application {
modules = [ com.mongodb.ApplicationKt.module ]
}
mongo {
uri = ${?MONGO_URI}
database = ${?MONGO_DATABASE}
}
}
Note: 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
openapi: 3.0.0
info:
title: Fitness API
version: 1.0.0
description: |
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.
paths:
/fitness:
post:
summary: Create a new fitness record
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/FitnessRequest'
responses:
'201':
description: Fitness created successfully
'400':
description: Bad request
/fitness/{id}:
get:
summary: Retrieve fitness record by ID
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Successful response
content:
application/json:
example: {}
'404':
description: Fitness not found
delete:
summary: Delete fitness record by ID
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Fitness deleted successfully
'400':
description: Bad request
'404':
description: Fitness not found
patch:
summary: Update fitness record by ID
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/FitnessRequest'
responses:
'200':
description: Fitness updated successfully
'400':
description: Bad request
'404':
description: Fitness not found
components:
schemas:
Fitness:
type: object
properties:
id:
type: string
format: uuid
exerciseType:
type: string
notes:
type: string
details:
$ref: '#/components/schemas/FitnessDetails'
required:
- id
- notes
- details
FitnessDetails:
type: object
properties:
durationMinutes:
type: integer
format: int32
distance:
type: number
format: double
caloriesBurned:
type: integer
format: int32
required:
- durationMinutes
- distance
- caloriesBurned
FitnessRequest:
type: object
properties:
exerciseType:
type: string
notes:
type: string
details:
$ref: '#/components/schemas/FitnessDetails'
required:
- exerciseType
- notes
- 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. After these changes, your structure should look similar to this:
Running the application
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:
-DMONGO_URI= Add your connection string
-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:
Conclusion
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.
Any questions? Come chat with us in the MongoDB Developer Community.













Top comments (0)