logo
icon_menu
  1. Home
  2. Categories
  3. Kotlin

Tutorial: Simple Kotlin REST API with Ktor, Exposed and Kodein

When building a REST API, usually Spring (Boot) is one of the first frameworks that comes to my mind. It has proven to be a reliable framework, but it also has a steep learning path and a lot of overhead for smaller projects.

Also, when using Kotlin instead of Java, you feel that Spring does not make use of many cool language features that are available.

This is why I started searching for alternatives and I found Ktor . It makes extensive use of Kotlin features and allows to quickly set up a REST API without having too much configuration to do before you can start.

In this tutorial, I show you how to set up a REST API with Ktor, but also create a scalable project structure that can be extended easily. Beside Ktor, we will be using Exposed as ORM (Object-relational mapping) framework, and Kodein for DI (Dependency Injection).

The code for this repository is also available on GitHub: https://github.com/stefangaller/Ktor-Api .

Prerequesites

As a first step, we need to create a new Ktor project. I suggest that you follow the official “Quick Start” documentation. This should get you a basic Ktor application, from which we will start building our API.

Database

For our database, I use Docker to quickly create a PostgreSQL instance. My docker compose file looks like this:

version: "3.7" services: books_db: image: postgres:13-alpine environment: POSTGRES_DB: "bookdb" POSTGRES_USER: "user" POSTGRES_PASSWORD: "password" POSTGRES_ROOT_PASSWORD: "rootpwd" ports: - "5432:5432"
Code language: YAML (yaml)

We will use Hikari as JDBC connection pool and therefore create a configuration file called dbconfig.properties under the resources folder. The dbconfig.properties file should look like this:

dataSourceClassName=org.postgresql.ds.PGSimpleDataSource dataSource.user=user dataSource.password=password dataSource.databaseName=bookdb dataSource.portNumber=5432 dataSource.serverName=localhost
Code language: Properties (properties)

Just remember to adapt this file when using a different database.

Next, we add the path of the dbconfig.properties file to our application.conf file, which is also located under resources. This step is not necessary, but it allows us to potentially have a separate database configuration for each application configuration (e.g. for development, staging and production environments).

ktor { ... hikariconfig = "resources/dbconfig.properties" }
Code language: JavaScript (javascript)

Dependencies

Let’s have a look at the dependencies in our build.gradle file. My dependencies block looks like this:

dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" // kotlin implementation "io.ktor:ktor-server-netty:$ktor_version" // ktor netty server implementation "ch.qos.logback:logback-classic:$logback_version" //logging implementation "io.ktor:ktor-server-core:$ktor_version" // ktor server implementation "io.ktor:ktor-gson:$ktor_version" // gson for ktor implementation "org.kodein.di:kodein-di-framework-ktor-server-jvm:7.0.0" // kodein for ktor // Exposed ORM library implementation "org.jetbrains.exposed:exposed-core:$exposed_version" implementation "org.jetbrains.exposed:exposed-dao:$exposed_version" implementation "org.jetbrains.exposed:exposed-jdbc:$exposed_version" implementation "com.zaxxer:HikariCP:3.4.5" // JDBC Connection Pool implementation "org.postgresql:postgresql:42.2.1" // JDBC Connector for PostgreSQL testImplementation "io.ktor:ktor-server-tests:$ktor_version" // test framework }
Code language: Gradle (gradle)

I do not cover testing in this tutorial, so feel free to remove the last dependency. Also remember to add a different JDBC Connector if you are using another database like MySQL.

To make the example work, I also had to add the following block to my build.gradle to make sure the project is compiled for JVM 1.8:

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { kotlinOptions { jvmTarget = "1.8" } }
Code language: JavaScript (javascript)

Data Persistence with Exposed

For this tutorial, we will implement a simple API to add, delete and retrieve books. So let’s have a look how we can use Exposed for our database transactions.

In our src directory, create a new package called data. Here we add a new Kotlin file Book.kt that will contain all our logic for adding, deleting and retrieving books from our database.

Book.kt

package at.stefangaller.data 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 Books : IntIdTable() { val title = varchar("title", 255) val author = varchar("author", 255) } class BookEntity(id: EntityID<Int>) : IntEntity(id) { companion object : IntEntityClass<BookEntity>(Books) var title by Books.title var author by Books.author override fun toString(): String = "Book($title, $author)" fun toBook() = Book(id.value, title, author) } data class Book( val id: Int, val title: String, val author: String )
Code language: Kotlin (kotlin)
  • Lines 8 – 11: Here we define an object that represents our Books table in the database. We add two columns: title and author. By deriving from IntIdTable, we automatically have an id column added for us.
  • Lines 13 – 22: The BookEntity represents a row in our database. Using this class, we can perform our database operations. We do not need to write any extra code for this, all we need to do is to create the companion object in line 14.
  • In lines 16 and 17 we use Kotlin delegates to map the values of the row to their corresponding columns of the database table.
  • For better readability of the logs, I suggest overriding the toString method (line 19).
  • In line 21 we have a conversion function to transform our Entity to a simple Kotlin data class (defined in lines 24 – 28).

Service Layer using Exposed

Now that we have defined our data model and configured Exposed to correctly map the Book class to the database, we create a BookService containing functions for manipulating the data.

In the src directory, create a services package and add a new BookService.kt file to it.

BookService.kt

package at.stefangaller.services import at.stefangaller.data.Book import at.stefangaller.data.BookEntity import org.jetbrains.exposed.sql.transactions.transaction class BookService { fun getAllBooks(): Iterable<Book> = transaction { BookEntity.all().map(BookEntity::toBook) } fun addBook(book: Book) = transaction { BookEntity.new { this.title = book.title this.author = book.author } } fun deleteBook(bookId: Int) = transaction { BookEntity[bookId].delete() } }
Code language: Kotlin (kotlin)
  • Lines 9 -11: This is all that is needed to retrieve all books from the database. We use the all method provided by BookEntity and then map the result to our Book data class.
  • Lines 13 – 18: For adding a new book, we simply call BookEntity.new with the data we want to add.
  • Lines 20 – 22: To delete a Book by its id, we utilize the get functionality using [] and simply call the delete function.

Note that all function code is wrapped within a transaction. All database operations using Exposed are required to be executed in a transaction block.

Provide BookService using Kodein

To make the BookService available throughout the app, we are using the Kodein library. Within the services package, create a new file called ServicesDI.kt.

package at.stefangaller.services import org.kodein.di.DI import org.kodein.di.bind import org.kodein.di.singleton fun DI.MainBuilder.bindServices(){ bind<BookService>() with singleton { BookService() } }
Code language: Kotlin (kotlin)

All we do here is to create an extension function for the DI.MainBuilder class named bindServices and bind our BookService as a singleton.

Routing: Defining the REST API

Now we need to define how our REST API will look like. Therefore, we create a new package called routes in our src directory.

We are going to split up our route definition in two files: ApiRoute and BookRoute. ApiRoute will be our base route defining our API (represented by the path /api/v1). The BookRoute is responsible for all API endpoints regarding books.

Since in the future we might also have an AuthorRoute or a PublisherRoute, it makes sense to use that structure.

Let’s have a look at the ApiRoute first:

ApiRoute.kt

package at.stefangaller.routes import io.ktor.routing.Routing import io.ktor.routing.route fun Routing.apiRoute() { route("/api/v1") { books() } }
Code language: Kotlin (kotlin)

All it does is to create a /api/v1 route and registers the books route beneath it. The books function is defined in BookRoute.kt:

BookRoute.kt

package at.stefangaller.routes import at.stefangaller.data.Book import at.stefangaller.services.BookService import io.ktor.application.call import io.ktor.features.NotFoundException import io.ktor.http.HttpStatusCode import io.ktor.request.receive import io.ktor.response.respond import io.ktor.routing.Route import io.ktor.routing.delete import io.ktor.routing.get import io.ktor.routing.post import org.kodein.di.instance import org.kodein.di.ktor.di fun Route.books() { val bookService by di().instance<BookService>() get("books") { val allBooks = bookService.getAllBooks() call.respond(allBooks) } post("book") { val bookRequest = call.receive<Book>() bookService.addBook(bookRequest) call.respond(HttpStatusCode.Accepted) } delete("book/{id}") { val bookId = call.parameters["id"]?.toIntOrNull() ?: throw NotFoundException() bookService.deleteBook(bookId) call.respond(HttpStatusCode.OK) } }
Code language: Kotlin (kotlin)
  • Line 17: To make books() available in ApiRoute.kt, we create an extension function of the Route class.
  • Line 19: Here we use Kodein to inject our BookService.
  • Lines 21 – 24: We use the get function to define a get endpoint called books. First, we retrieve all books from our BookService and use call.respond to create an HTTP response containing all books. We later configure our server to automatically convert our list of Books to JSON.
  • Lines 26 – 30: To add a Book to our server, we use a post endpoint named book. As we know that we want to receive a request defined like our Book class, we can simply use the call.receive<Book>() function. Next, we add the book using our BookService and respond with status Accepted.
  • Lines 32 – 36: Here we see how we can handle URL parameters. They are available using call.parameters. Similar to the post function, we use the BookService to perform the action and create a response afterwards.

Connecting to the Database

Before we are starting to put everything together, what is still missing is some code to connect to the database when the server is starting. We will do this in a file called DBConfig.kt.

package at.stefangaller import at.stefangaller.data.Books import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariDataSource import io.ktor.application.Application import io.ktor.util.KtorExperimentalAPI import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.transactions.transaction import org.slf4j.LoggerFactory const val HIKARI_CONFIG_KEY = "ktor.hikariconfig" @KtorExperimentalAPI fun Application.initDB() { val configPath = environment.config.property(HIKARI_CONFIG_KEY).getString() val dbConfig = HikariConfig(configPath) val dataSource = HikariDataSource(dbConfig) Database.connect(dataSource) createTables() LoggerFactory.getLogger(Application::class.simpleName).info("Initialized Database") } private fun createTables() = transaction { SchemaUtils.create( Books ) }
Code language: Kotlin (kotlin)
  • Line 16: First we create an extension function for Application called initDB.
  • Line 17: We retrieve the path of the Hikari configuration file we earlier put into our application.conf file.
  • Lines 18 – 20: we load the Hikari config from the given path, create a data source and tell the Exposed Database to connect to it.
  • Lines 21 & 25 – 29: To tell Exposed to create the missing tables, we need to call SchemaUtils.create listing all required tables.

In larger applications, you probably wouldn’t use the SchemaUtils.create() function to manage your database tables. Instead, you would use a database migration tool like Flyway. Have a look at this tutorial to see how Flyway can be added to Exposed.

Application.kt: Putting everything together

Finally, all that is left is to connect all the parts in the Application.kt file.

package at.stefangaller import at.stefangaller.routes.apiRoute import at.stefangaller.services.bindServies import io.ktor.application.Application import io.ktor.application.call import io.ktor.application.install import io.ktor.features.CallLogging import io.ktor.features.ContentNegotiation import io.ktor.features.StatusPages import io.ktor.gson.gson import io.ktor.http.HttpStatusCode import io.ktor.response.respond import io.ktor.routing.routing import io.ktor.util.KtorExperimentalAPI import org.jetbrains.exposed.dao.exceptions.EntityNotFoundException import org.kodein.di.ktor.di fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args) @KtorExperimentalAPI @Suppress("unused") // Referenced in application.conf @kotlin.jvm.JvmOverloads fun Application.module(testing: Boolean = false) { initDB() install(ContentNegotiation) { gson { } } install(CallLogging) install(StatusPages) { exception<EntityNotFoundException> { call.respond(HttpStatusCode.NotFound) } } di { bindServies() } routing { apiRoute() } }
Code language: Kotlin (kotlin)
  • Line 19: The entry point of our application. Here our web server is being started.
  • Line 26: Initialize the database using the initDB() function from DBConfig.kt.
  • Line 28: Installing the ContentNegotiation feature with GSON to transform the requests and responses from/to JSON.
  • Line 29: The CallLoging feature logs all calls, which is nice for debugging.
  • Line 30: The StatusPages feature allows us to specify what happens when certain exceptions occur. In this case, we catch a EntityNotFoundException and convert it to an HTTP 404 Not Found response.
  • Lines 36 – 38: Here, we initialize the Kodein dependency injection by calling bindServices from ServicesDI.kt.
  • Lines 40 – 42: Finally, we register our apiRoute to our Application.

Running the Server

Now we can start up our server. The easiest way is to simply click the green arrow in IntelliJ, but you can also use ./gradlew run from the command line.

For testing our API I use IntelliJ’s http client feature with following configurations:

// get all books GET http://localhost:8080/api/v1/books Accept: application/json ### // add a book POST http://localhost:8080/api/v1/book Content-Type: application/json { "title": "my new book", "author": "new author" } ### //delete a book DELETE http://localhost:8080/api/v1/book/1 Content-Type: application/json
Code language: PHP (php)

Anyway, these requests can easily be translated to many other tools like cURL or Postman .

Conclusion

Compared to using Spring Boot, using Ktor, Exposed and Kodein is simple to set up. Although Spring Boot might have advantages for larger scale backends, for smaller applications Ktor might save you a lot of time. Especially, for prototyping it is a real (JVM-based) alternative to using python with Django.

I hope this tutorial helps you to understand how Ktor, Exposed and Kodein can be used and how an extendable architecture can look like. I’m always happy to receive feedback on my tutorials, so let me know what you think in the comments below or by writing me an email.

If you are interested in how to implement database migrations with Ktor and Exposed checkout my article Tutorial: How to set up Exposed ORM with Flyway Database Migration.

Comments

juliusham says:

thank u for the tut,
in your conclusion u say “Although Spring Boot might have advantages for larger scale backends” whay In your opinion do u think limits ktor to handle larger backends? am considering using ktor for a large highly scalable app?

Stefan Galler says:

I don’t think that performance will be a problem when using Ktor. But I have the feeling, that it might get difficult to structure the project right by extensively using Kotlin extension functions.

@tinaciousdesign says:

Great tutorial, thank you! Ktor, and Kotlin in general, seem so elegant, even on the server. Thanks for covering API versioning too.

Re: targeting JVM 1.8, while looking at Ktor examples, I saw someone else added this instead:

“`
sourceCompatibility = 1.8
compileKotlin { kotlinOptions.jvmTarget = “1.8” }
compileTestKotlin { kotlinOptions.jvmTarget = “1.8” }
“`

It looks like it equates to the same thing that you have, it’s just more concise.

Any thoughts on deployments, specifically deploying to Heroku?

Thanks again!

Leave a Reply

Your email address will not be published. Required fields are marked *