DEV Community

Ricardo Mello
Ricardo Mello

Posted on

Mastering Kotlin: Creating an API With Ktor and MongoDB Atlas

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

Demo of Kotlin and Ktor Project with MongoDB

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Ktor Project Generator - Adjusting project settings

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

Ktor Project Generator - Adding plugins

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")
}
Enter fullscreen mode Exit fullscreen mode

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:

Project structure

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
)
Enter fullscreen mode Exit fullscreen mode

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
    )
}
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

If everything is correct, our structure will look similar to the image below:

Project structure

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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

Project structure

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:

  1. GET /fitness/{id}: Utilize this endpoint to fetch detailed information about a specific fitness activity based on its unique identifier (ID).
  2. POST /fitness: With this endpoint, you can effortlessly add new fitness activities to your tracker, ensuring all your workout data is up-to-date.
  3. 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.
  4. 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
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's break down what this code is doing into three main sections for easier understanding:

  1. 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.
  2. 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.
  3. 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}
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Project structure

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:

MongoDB Atlas - Cluster connection string

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:

Running the application

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
Enter fullscreen mode Exit fullscreen mode

Click "Apply" and run the application again.

IntelliJ Run Settings

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.

Swagger Dashboard

Open the post method and insert an object for testing:

Swagger Responses

We can see the data recorded in our MongoDB Atlas cluster as shown in the image below:

MongoDB Atlas Document Explorer

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)