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.brand
sets 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
=
p
roduct.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
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.