Building a REST API with Ktor and Exposed | Kotlin

This article is about how to create a simple REST API using Ktor and Exposed.

We will develop a simple inventory App with CRUD functions. We can request a list of all products in the database, a product by its ID, add new products, and update and remove them.

First, we are going to develop an app using ktor. Then, we add Exposed. If you are here to know how to add Exposed, you can skip the first part, and go straight to the second part of this article.

Ktor

Ktor is an asynchronous framework for creating microservices, web applications, and client-side applications.

Exposed

Exposed is an ORM framework for the Kotlin language. Exposed has two flavors of database access: typesafe SQL wrapping DSL and lightweight Data Access Objects (DAO).

Requirements

  • Java SDK installed

  • Gradle installed (Download it from here, install it, and add it to the PATH environment)

  • Kotlin Installed

  • Kotlin basic knowledge

  • Postgres

Creating a project

To create a new Ktor project, we use the Ktor project generator on this web page.

Then, we click on "Add plugins", and select "Routing", "Call Logging", "Content Negotiation", "Status Pages", "Kotlinx.Serialization", "Exposed" and "Postgres". In case you want to use a frontend framework or library, add "CORS" too.

We click on "Generate project" and a .zip folder will be downloaded to our machine.

We unzip it and open the folder with a code editor. For this example, I will use VS Code.

We execute the gradle run command in the root folder.

After the building is completed, we will see this output in the command line:

If we navigate to localhost:8080, we should see the following message:

Ktor server

src/main/kotlin Folder

In the src/main/kotlin folder we have the Application.kt file and the plugins folder which contains the Routing.kt and Monitoring.kt files.

Application.kt

package com.example

import com.example.plugins.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
        .start(wait = true)
}

fun Application.module() {
    configureSerialization()
    //configureDatabases()
    configureMonitoring()
    configureHTTP()
    configureRouting()
}

The Application.kt file is the main entry point for a Ktor application.

The main() function in the Application.kt file is responsible for starting the Ktor server. The module() function is responsible for configuring the application and loading the plugins. In the code snippet above, configureMonitoring() install the plugin responsible for collecting the metrics of the app. Also, this file is where we define the port and host from where our application should start listening.

Routing.kt

package com.example.plugins

import io.ktor.server.routing.*
import io.ktor.server.response.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.http.*
import io.ktor.server.application.*

fun Application.configureRouting() {
    install(StatusPages) {
        exception<Throwable> { call, cause ->
            call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
        }
    }
    routing {
        get("/") {
            call.respondText("Hello World!")
        }
    }
}

The routing() function in the Routing.kt file is responsible for defining routes. The routing() function takes a block of code as its argument. The code in the block of code defines the routes for the application.

The routes are defined using the get(), post(), put(), delete(), and head() functions. These functions take a path as their argument and a handler as their return value.

The handler is the function that will be called when a request is made to the specified path. Inside the handler, we can get access to the ApplicationCall, which handles client requests and sends the defined response.

In the code snippet above the configureRouting() function installs the StatusPages plugin and configures it to respond to all Throwable exceptions with a 500 Internal Server Error response.

The routing() function defines a route that responds to GET requests to the / path. Then, the handler for this route simply responds with the text "Hello World!".

The StatusPages plugin allows you to customize the way that Ktor responds to errors. The exception handler allows you to handle calls that result in a Throwable exception. In the code above it will throw the 500 HTTP status code for any exception.

Installing the StatusPages plugin in the configureRouting() function allows this plugin to be applied to all routes in the application.

For more information about the Routing and StatusPages plugins, you can consult the documentation here.

First Part: Building the REST API

First, we create a new package in src/main/kotlin/com/example/ called models. In there, we create a new file called product.kt.

package com.example.models



import kotlinx.serialization.Serializable

@Serializable
data class Product (
    val name: String,
    val quantity: Int,
    val brand: String
)
val productStorage = mutableListOf<Product>()

We create a package named routes, inside the src/main/kotlin/com/example folder.

Then, inside the routes package, we create the ProductRouter.kt file.

package com.example.routes

import com.example.models.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Route.ProductRouting() {
    route("/product") {
        get {

        }
        get("{id}") {

        }
        post {

        }
        put("{id}") {

        }
        delete("{id}") {

        }
    }
}

In this file, we declare the productRouting function, in which we group all the routes for the /product endpoint.

GET route

fun Route.productRouting() {
    route("/product") {
        get {
            if (productStorage.isNotEmpty()) {
                call.respond(productStorage)
            } else {
                call.respondText ("No products found", status = HttpStatusCode.OK)
            }
        }
...

    }
}

Now, we added a handler for the get route to see if there are any products in the productStorage list. If there are no products in the list, the route will respond with a 200 OK status code and the text "No users found".

Before we start the server to try this functionality, we have to go Routing.kt file and declare the productRouting() function inside the configureRouting() function.

Routing.kt

package com.example.plugins

import com.example.routes.productRouting
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Application.configureRouting() {
    install(StatusPages) {
        exception<Throwable> { call, cause ->
            call.respondText(text = "500: $cause" , status = HttpStatusCode.InternalServerError)
        }
    }
    routing {
        get("/") {
            call.respondText("Hello World!")
        }
        productRouting()
    }
}

Now we can start the server and navigate to localhost:8080/product. We should receive the following response:

We received that message because so far there is no data in the productStorage.

Let's add a handler to create a user.

POST route

 post {
       val product = call.receive<Product>()
       productStorage.add(product)
       call.respondText("Product stored correctly", status = HttpStatusCode.Created)

        }

This route handles the POST requests received to the path “/products”.

The call.receive<Product>() function deserializes the JSON data in the request body into a Product object. Then, the productStorage.add() method adds the new data to the productStorage list.

We run the server. And use an HTTP client to make a POST request.

If we navigate to localhost:8080/product or make a GET request with an HTTP client, we should see the following response.

GET ID route

get("/{id}") {
    val id = call.parameters["id"] ?: return@get call.respondText(
        "Missing id",
        status = HttpStatusCode.BadRequest
    )
    val product =
        productStorage.find {it.id == id} ?: return@get call.respondText(
            "No product with id $id",
            status = HttpStatusCode.NotFound
        )
    call.respond(product)
}

This handler retrieves the product with the specified ID from the productStorage list and returns it to the client. If the product does not exist, the handler returns a 404 Not Found error.

The handler first uses the call.parameters function to get the value of the id parameter from the request. If the id parameter is not present, the handler returns a 400 Bad Request error.

Next, the handler uses the userStorage.find function to find the product with the specified ID in the productStorage list. If the product is not found, the handler returns a 404 Not Found error.

If the product is found, the handler returns the product to the client. The respond function sends a response to the client.

PUT route

put("/{id}") {
            val id = call.parameters["id"] ?: return@put call.respondText(
                "No id provided",
                status = HttpStatusCode.BadRequest
            )

            val product =
                productStorage.find {it.id == id} ?: return@put call.respondText(
                    "No product with this id: $id",
                    status = HttpStatusCode.NotFound
                )
            val newData = call.receive<Product>()
            val indexProduct = productStorage.indexOf(product)
            productStorage[indexProduct] = newData
            call.respondText("Product updated", status = HttpStatusCode.OK)
        }

Now, we implement the option that allows the clients to update an element in the productStorage. This handler updates the user with the specified ID with the data from the request body. If the product does not exist, the handler returns a 404 Not Found error.

The handler first uses the call.parameters function to get the value of the id parameter from the request. If the id parameter is not present, the handler returns a 400 Bad Request error.

Next, the handler uses the productStorage.find function to find the product with the specified ID in the productStorage list. If the product is not found, the handler returns a 404 Not Found error.

If the product is found, the handler uses the call.receive<Product> function to read the data from the request body and create a new Product object. The new Product object is then used to update the existing product in the productStorage list. The index of the existing product in the list is used to ensure that the correct product is updated.

Finally, the handler returns a 200 OK status code and the text "Product updated".

DELETE route

delete("{id}") {
            val id = call.parameters["id"] ?: return@delete call.respond(HttpStatusCode.BadRequest)
            if (productStorage.removeIf{it.id == id}) {
                call.respondText("Product removed correctly", status = HttpStatusCode.Accepted)
            } else {
                call.respondText("Product Not found", status = HttpStatusCode.NotFound)
            }
        }

In the DELETE route, with a specified ID, this handler deletes a product from the productStorage list. If the product does not exist, the handler returns a 404 Not Found error.

Similar to the GET and PUT handlers, the delete handler first uses the call.parameters function to get the value of the id parameter from the request. If the id parameter is not present, the handler returns a 400 Bad Request error.

Next, the handler uses the productStorage.removeIf function to remove the product with the specified ID from the productStorage list. If the product is not found, the handler returns a 404 Not Found error.

Finally, the handler returns a 202 Accepted status code if the product was deleted successfully, or a 404 Not Found status code if the product was not found.

After we have created all the routes, we go to the file kotlin/com/example/plugins/Routing.kt and add the productRouting() function to the Application.configureRouting()

Second Part: Setting Exposed

First, we have to create a database. In this case, we are going to make an inventory app. So, we open our terminal and use Pqsl to create the database.

CREATE DATABASE inventory;

Then, we create a table.

CREATE TABLE product (
id SERIAL PRIMARY KEY,
name VARCHAR(50),
quantity INTEGER,
brand VARCHAR(50)
);

Next, we insert a row into the table.

INSERT INTO product (name, quantity, brand) VALUES ('A55', '10', 'Samsung');

We navigate to the src/main/kotlin/com/example/models/ package and open the product.kt file. Delete the line val productStorage = mutableListOf<Product>(). We don't need it anymore, we are going to use a database

product.kt

import kotlinx.serialization.Serializable

@Serializable
data class Product (
    val id: Int,
    val name: String,
    val quantity: Int,
    val brand: String
)

@Serializable
data class NewProduct(
    val name: String,
    val quantity: Int,
    val brand: String
)

We create a new file in this package, productRepository.kt.

package com.example.models
interface ProductRepository {
    suspend fun allProducts(): List<Product>
    suspend fun productById(id: Int): Product?
    suspend fun productByName(name: String): Product?
    suspend fun addProduct(product: NewProduct)
    suspend fun updateProduct(id: Int, product: NewProduct)
    suspend fun removeProduct(id: Int)
}

This will allow implementations of the interface methods to start jobs of work on a different Coroutine Dispatcher.

Next, we open the Databases.kt file in src/main/kotlin/com/example/plugins.

Use the Database.connect() function to connect to your database, adjusting the values of the settings to match your environment:

fun Application.configureDatabases() {
    Database.connect(
        "jdbc:postgresql://localhost:port/<datbase name>",
        user = "user",
        password = "password"
    )
}

The url includes the following components:

localhost:port is the host and port on which the PostgreSQL database is running.

“database name” is the name of the database created when running services.

We create a new package in scr/main/kotlin/com/example called db. And create a new file in db, called mapping.kt.

mapping.kt

package com.example.db



import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable

object ProductTable : IntIdTable("product") {
    val name = varchar("name", 50)
    val quantity = integer( "quantity")
    val brand = varchar("brand", 50)

}

class ProductDAO(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<ProductDAO>(ProductTable)

    var name by ProductTable.name
    var quantity by ProductTable.quantity
    var brand by ProductTable.brand
}

Open the gradle.build.kts file and add the following dependency:

//...
val exposed_version: String by project

//..

dependencies {
    //...
    implementation("org.jetbrains.exposed:exposed-dao:$exposed_version")
}

Navigate back to the mapping.kt file and add the following two helper functions:

suspend fun <T> suspendTransaction(block: Transaction.() -> T): T =
    newSuspendedTransaction(Dispatchers.IO, statement = block)


fun daoToModel(dao: ProductDAO) = Product(
    dao.id.value,
    dao.name,
    dao.quantity,
    dao.brand,
)

suspendTransaction() takes a block of code and runs it within a database transaction, through the IO Dispatcher. This is designed to offload blocking jobs of work onto a thread pool.

daoToModel() transforms an instance of the ProductDAO type to the Product object.

We add the following imports to the mapping.kt

import com.example.models.Product
import kotlinx.coroutines.Dispatchers
import org.jetbrains.exposed.sql.Transaction
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction

Next, we create a new file, src/main/kotlin/com/example/models/PostgresProductRepository. Here, we are going to implement the methods from the ProductRepository interface.

PostgresProductRepository.kt

package com.example.models


import com.example.models.Product
import com.example.db.ProductDAO
import com.example.db.ProductTable
import com.example.db.daoToModel
import com.example.db.suspendTransaction
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.deleteWhere

class PostgresProductRepository : ProductRepository {
     override suspend fun allProducts(): List<Product> = suspendTransaction {
        ProductDAO.all().map(::daoToModel)
    }

The override suspend fun allProducts(): List<Product> method returns a list of Product objects.

If you are new to Kotlin like me, let me explain what override and suspend keywords means.

The override keyword indicates that this method redefines a method from the parent interface (ProductRepository). The suspend keyword indicates that a function is asynchronous and can be paused and resumed later without blocking the thread.

ProductDAO.all()retrieves all products from the database. And .map(::daoToModel) uses the map function to transform each retrieved product DAO object to a Product object (model representation) using the daoToModel function.

override suspend fun productById(id: Int): Product? = suspendTransaction {
        ProductDAO
            .find({ ProductTable.id eq id})
            .limit(1)
            .map(::daoToModel)
            .firstOrNull()
    }

The override suspend fun productById(id: Int): Product? method takes an integer id as a parameter, representing the product’s ID.

Product? means if a product with the given ID is found, it returns the product. Otherwise, it returns null.

.find({ ProductTable.id eq id }): This uses Exposed to find products where the id column in the ProductTable matches the provided id, .limit(1) limits the search results to only the first matching product, .map(::daoToModel) transforms the retrieved product DAO object to a Product object using the daoToModel function, .firstOrNull() retrieves the first element from the transformed list, if the list is empty, it returns null.

override suspend fun productByName(name: String): Product? = suspendTransaction {
        ProductDAO
            .find { (ProductTable.name eq name) }
            .limit(1)
            .map(::daoToModel)
            .firstOrNull()
    }

This method allows you to search for a product by its name using a similar approach to productById.

 override suspend fun addProduct(product: NewProduct ): Unit = suspendTransaction {
        ProductDAO.new {
            name = product.name
            quantity = product.quantity
            brand = product.brand
        }
    }

The override suspend fun addProduct(product: NewProduct): Unit method takes a NewProduct object as a parameter and returns a Unit, meaning it doesn´t return a specific value, like void in other programming languages.

ProductDAO.new creates a new record in the ProductDAO table. The code block inside the curly braces defines the new product's properties. name = product.name sets the name column in the new record to the name property of the product object, quantity = product.quantity sets the quantity column to the quantity property of the product object, brand = product.brandsets the brand column to the brand property of the product object.


    override suspend fun updateProduct(id: Int, product: NewProduct): Unit = suspendTransaction {
        ProductDAO.findByIdAndUpdate(id) {
            it.name = product.name
            it.quantity = product.quantity
            it.brand = product.brand
        }

    }

The override suspend fun updateProduct(id: Int, product: NewProduct): Unit takes an id and a NewProduct object as a parameter, and returns Unit.

ProductDAO.findByIdAndUpdate(id) { ... }: This uses Exposed to find a product by its id and then update it within the code block. The .it Inside the code block refers to the retrieved product object. it.name = product.name sets the name property of the retrieved product to the name property of the product object, it.quantity = product.quantity sets the quantity property of the retrieved product to the quantity property of the product object, it.brand = product.brand sets the brand property of the retrieved product to the brand property of the product object.


     override suspend fun removeProduct(id: Int): Unit = suspendTransaction {
         ProductTable.deleteWhere { ProductTable.id.eq(id) }

    }

The override suspend fun removeProduct(id: Int): Unit takes an id as a parameter, the id of the product we want to delete.

ProductTable.deleteWhere { ProductTable.id.eq(id) }: This uses Exposed to delete products from the ProductTable where the id column matches the provided id.

.deleteWhere: This function is likely used by Exposed to delete rows from a table based on a specific condition.

{ ProductTable.id.eq(id) } checks if the id column in the ProductTable is equal to the provided id.

Then we go to the Serialization.kt file in src/main/kotlin/com/example/plugins. And add the PostgresProductRepository class as a parameter to the Application.configureSerialization() function.

package com.example.plugins

import com.example.models.PostgresProductRepository
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Application.configureSerialization(repository: PostgresProductRepository) {
    install(ContentNegotiation) {
        json()
    }
    routing {
        get("/json/kotlinx-serialization") {
                call.respond(mapOf("hello" to "world"))
            }
    }
}

Finally, we go to the src/main/kotlin/com/example/application.kt file. And create an instance of the postgresProductRepository class in the Application.module() function. Then we pass the new instance as an argument to the configureSerializatio() function.

package com.example

import com.example.models.PostgresProductRepository
import com.example.plugins.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
        .start(wait = true)
}

fun Application.module() {
    val repository = PostgresProductRepository()

    configureSerialization(repository)
    configureDatabases()
    configureMonitoring()
    configureHTTP()
    configureRouting()
}

productRoutes.kt

 package com.example.routes


import com.example.models.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Route.productRouting() {
    val repository = PostgresProductRepository()
    route("/product") {
        get {
            val product = repository.allProducts()
            call.respond(product)
        }


    }
}

This route calls the allProducts() method and shows all the products in the database.

Now we can start the server and navigate to localhost:8080/product. We should receive the following response:

GET By ID Route

get("/{id}") {
            val id = call.parameters["id"] ?: return@get call.respondText(
                "Missing id",
                status = HttpStatusCode.BadRequest
            )

            val product =
                repository.productById(id.toInt()) ?: return@get call.respondText(
                    "No product with id $id",
                    status = HttpStatusCode.NotFound
                )
            call.respond(product)

        }

The "/{id}" route will handle GET requests for a specific product by its ID.

val id = call.parameters["id"] ?: return@get call.respondText("Missing id", 
status = HttpStatusCode.BadRequest)

The line of code above extracts the id parameter from the request path. If the id parameter is missing, it returns a BadRequest response indicating that the ID is required.

val product = repository.productById(id.toInt()) ?: 
return@get call.respondText("No product with id $id",
 status = HttpStatusCode.NotFound)

The productById method retrieves the product with the specified ID. If no product is found, it returns the status code “404” indicating that the product doesn't exist.

if the product is found, call.respond(product) sends the product object as a response.

POST Route

post {
       val product = call.receive<Product>()

        repository.addProduct(product)
        call.respondText("Product stored correctly", 
        status = HttpStatusCode.Created)

        }

This route handles the POST requests received to the path “/products”.

The call.receive<Product>() function deserializes the JSON data in the request body into a Product object. The addProduct method adds the new product to the database.

The call.respondText("Product stored correctly", status = HttpStatusCode.Created) method sends a response to the client with the message “Product stored correctly” and the status code “201”.

PUT Route

put("/{id}") {
            val id = call.parameters["id"] ?: return@put call.respondText(
                "Missing id",
                status = HttpStatusCode.BadRequest
            )

            val product =
                repository.productById(id.toInt()) ?: return@put call.respondText(
                    "No product with id $id",
                    status = HttpStatusCode.NotFound
                )
            val newProduct = call.receive<NewProduct>()
            repository.updateProduct(id.toInt(),newProduct)
            call.respondText("Product updated correctly", status = HttpStatusCode.Ok)

        }

This option allows the clients to update an element in the database. This handler updates a product with the specified ID with the data from the request body. If the product does not exist, the handler returns a 404 Not Found error.

val id = call.parameters["id"] ?: return@put call.respondText("Missing id", 
status = HttpStatusCode.BadRequest)

The line of code above extracts the id parameter from the request path. If the id parameter is missing, it returns a BadRequest response indicating that the ID is required.

val product = repository.productById(id.toInt()) ?: 
return@put call.respondText("No product with id $id", 
status = HttpStatusCode.NotFound):

Next, the line above calls the productById method of the repository object to retrieve the product with the specified ID. If no product is found, it returns a status code “404” with a “No product with id $id” message.

If the product exists, the following line of code receives the request body and deserializes the JSON data into a newProduct object. Then, the updateProduct method replaces the existing data with the newProduct data. Next, it sends a “Product update correctly” message with the status code “200”.

val newProduct = call.receive<NewProduct>()
            repository.updateProduct(id.toInt(),newProduct)
            call.respondText("Product updated correctly",
 status = HttpStatusCode.Ok)

DELETE Route

delete("{id}") {
            val id = call.parameters["id"] ?: return@delete call.respondText(
                "Missing id",
                status = HttpStatusCode.BadRequest
            )

            val product =
                repository.productById(id.toInt()) ?: return@delete call.respondText(
                    "No product with id $id",
                    status = HttpStatusCode.NotFound
                )
            repository.removeProduct(id.toInt())
            call.respondText("Product deleted correctly", status = HttpStatusCode.OK)
        }

Similar to the GET and PUT handlers, the delete handler first uses the call.parameters function to get the value of the id parameter from the request. If the id parameter is not present, the handler returns the status code 400 Bad Request.

In the DELETE route, with a specified ID, this handler deletes a product from the database. If the product does not exist, the handler returns the status code 404 Not Found.

This handler retrieves the specified product and calls the removeProduct method to delete it from the database and sends the message, “Product deleted correctly” with the status code 200 Ok.

Conclusion

I am amazed about how easy is to add Exposed to a Ktor API. I think the JetBrains developers did a good job writing the Ktor and Exposed tutorials and developing these frameworks that work well together. I have always struggled with adding an ORM to API when developing in other languages. Except for Django in Python and Rails in Ruby, they have an ORM integrated. Probably there are more examples out there.

I wonder if integrating Exposed with other frameworks in Kotlin or Java will be as easy as with Ktor. Also, this example is not a complex one, so I don't know if it will work well with a complex API with complex queries.

I am rusty in Kotlin. It has been a long time since the last time I wrote Kotlin code. Sorry for that. I will continue working on my code and my writing.

Source code here.

Thank you for taking the time to read this article.

If you have any recommendations about other packages, architectures, how to improve my code, my English, or anything; please leave a comment or contact me through Twitter, or LinkedIn.

References

Ktor documentation

Exposed

0
Subscribe to my newsletter

Read articles from Carlos Armando Marcano Vargas directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Carlos Armando Marcano Vargas
Carlos Armando Marcano Vargas

I am a backend developer from Venezuela. I enjoy writing tutorials for open source projects I using and find interesting. Mostly I write tutorials about Python, Go, and Rust.