REST API With Ktor, Ktorm and PostgreSQL

In this step by step tutorial I will show you how to implement a simple REST API with Ktor, Ktorm and PostgreSQL.
A featured image for category: Ktor

1. Introduction

In this step-by-step tutorial, I would like to show you how to create a simple REST API using Ktor, Ktorm, and PostgreSQL (aka Postgres).

Ktor is a free, open-source framework, which allows us to easily build connected applications. When it comes to server-side applications, we can treat it as a lightweight and powerful alternative to Spring Boot. Additionally, it was written in Kotlin programming language by its’ creators- the JetBrains team.

Ktorm, on the other hand, is a lightweight and efficient ORM Framework for Kotlin directly based on pure JDBC. Furthermore, combined with PostgreSQL makes an interesting combination for a REST API tech stack.

2. Generate Ktor Project

With all of that being said, let’s create the Ktor project (and in the next chapter, I will show you how to add Ktorm and PostgreSQL dependencies).

If you are the owner of IntelliJ Ultimate Edition, then it ships with Ktor support out of the box. Nevertheless, in this tutorial, we’ll focus on the alternative: https://start.ktor.io, which is pretty similar to the Spring Initializr page.

As the first step, let’s specify the project name along with other, necessary data:

Image shows project generator page used to generate our ktor with ktorm and postgresql project

Please remember about selecting the same Ktor version, so that the code from this tutorial will work for sure. Don’t worry about a sample code generated, we will take care of it later.

As the next step, let’s click the Add plugins button and add the kotlinx.serialization– we’ll use it to serialize our objects into JSON format:

Image shows ktor project generator page

As we can see, two other plugins have been added automatically: ContentNegotiation and Routing. Please do not remove them, we will need them later.

Make a real progress thanks to practical examples, exercises, and quizzes.

Image presents a Kotlin Course box mockup for "Kotlin Handbook. Learn Through Practice"

2. Add Ktorm and PostgreSQL To Ktor

Nextly, let’s open up our Ktor Postgres project with IntelliJ and go to the gradle.properties file.

Unfortunately, the Project Generator does not allow us to add all of the necessary imports, so we will have to do it manually:

postgresql_driver_version=42.3.1
ktorm_version=3.4.1

As the next step, let’s add a few lines within the build.gradle.kts file and reload the Gradle project:

val ktorm_version: String by project
val postgresql_driver_version: String by project

dependencies {

// other dependencies

implementation("org.ktorm:ktorm-core:$ktorm_version")
implementation("org.ktorm:ktorm-support-postgresql:$ktorm_version")
implementation("org.postgresql:postgresql:$postgresql_driver_version")

}

As we can see, these dependencies are necessary in order to connect our Ktor application with the Postgres database.

3. Clean Up The Code

After that, let’s clean up the project a bit and delete the Routing.kt along with its invocation within the Application.kt.

The second file should now be looking, as follows:

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

Then, let’s go to the Serialization.kt file and remove unnecessary code, as well:

fun Application.configureSerialization() {
  install(ContentNegotiation) {
    json()
  }
}

The above code will be necessary for JSON serialization.

4. Prepare PostgreSQL With Docker Compose

Basically, if you already have a PostgreSQL instance running on your machine, you can just skip the docker-compose part and just run the below SQL script.

Either way, let’s create the sql folder in the root directory and add the init-db.sql file:

CREATE TABLE book
(
  id   SERIAL NOT NULL,
  name varchar NOT NULL
);

INSERT INTO book(name) VALUES ('Book #1');
INSERT INTO book(name) VALUES ('Book #2');

We can clearly see, that after the script is run, a new table will be added and populated with test data.

Following, let’s go to the root of the Ktor project and add the docker-compose.yaml file with Postgres definition:

version: "3.9"
services:
  postgres-sandbox:
    image: postgres:14
    ports:
      - '5438:5432'
    volumes:
      - ./sandbox-db:/var/lib/postgresql/data
      - ./sql/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
    environment:
      - POSTGRES_NAME=postgres
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres

The above code is responsible for a few things:

  • pulling the Docker image for PostgreSQL
  • publishing the container’s 5432 port to the host’s 5438 (to put it simply- we will connect from our application using port 5438)
  • running initialization script prepared above
  • and finally, setting credentials through environment variables

To start our PostgreSQL instance, let’s run the below command:

docker-compose up

# To validate:
docker ps
CONTAINER ID IMAGE       COMMAND                CREATED       STATUS       PORTS                  NAMES
2f5716235039 postgres:14 "docker-entrypoint.sโ€ฆ" 6 minutes ago Up 6 minutes 0.0.0.0:5438->5432/tcp ktor-ktorm-postgresql_postgres-sandbox_1

As we can see, everything is working fine and now we will be able to connect our Ktor app with Postgres.

5. Implement DTOs

Following, let’s prepare request and response classes, which will be used in our REST API. Technically, we don’t have to do that, but it’s a good practice to keep them separate.

Let’s start with the BookRequest:

@Serializable
data class BookRequest(
  val name: String
)

Similarly, let’s implement the BookResponse and ErrorResponse:

@Serializable
data class BookResponse(
  val id: Long,
  val name: String
)

@Serializable
data class ErrorResponse(val message: String)

You might have noticed, that both classes are annotated with @Serializable (from kotlinx.serialization library). According to the documentation:

Applying Serializable to the Kotlin class instructs the serialization plugin to automatically generate implementation of KSerializer for the current class, that can be used to serialize and deserialize the class.

To put it simply, this annotation is necessary if we want to operate on these two classes in our REST API.

6. Add Entity And Columns Bindings

With all of that being done, let’s add the code responsible for object-relational mapping:

interface Book : Entity<Book> {
  companion object : Entity.Factory<Book>()

  val id: Long?
  var name: String
}

object Books : Table<Book>("book") {
  val id = long("id").primaryKey().bindTo(Book::id)
  val name = varchar("name").bindTo(Book::name)
}

It may seem a bit counterintuitive in the beginning, so let’s spend some time understanding it better.

The first one, Book, is a representation of the entity class, which will be used to operate on it. You might have already noticed, that it’s been declared as an interface- thatโ€™s a design requirement of Ktorm. (Ktorm 2.5 introduced the possibility to declare them as classes, but this approach has some limitations, which you can read more about here).

Additionally, you might be curious, what exactly does this line do?

companion object : Entity.Factory<Book>()

Basically, interfaces cannot be instantiated, but to help us with that, Ktorm provides an abstract class Entity.Factory. It overloads the invoke operator so that we will be able to create Book instances and initialize its properties pretty easily:

val newBook = Book {
  name = bookRequest.name
}

Finally, the second part, an object called Books is responsible for binding our entity with the database table.

It allows us to specify columns types, and names and wire them with our entity through the .bindTo() method. Whatsoever, we will use this object later, when working with our Ktor Postgres database connection.

7. Connect To PostgreSQL

As the next step, let’s implement the BookService and connect to the database:

class BookService {

  private val database = Database.connect(
    url = "jdbc:postgresql://localhost:5438/postgres",
    driver = "org.postgresql.Driver",
    user = "postgres",
    password = "postgres"
  )
}

As we can see, the connect function allows us to specify Ktor Postgres connection details. Please make sure that these values match the actual state in your case. As a result, we receive a new database object, which we will use to perform DB operations.

8. Implement Service Methods

With all of that being done, we can finally add logic responsible for books management.

Let’s go to the BookService and add 4 methods:

fun createBook(bookRequest: BookRequest): Boolean {
  val newBook = Book {
    name = bookRequest.name
  }

  val affectedRecordsNumber = 
    database.sequenceOf(Books)
      .add(newBook)

  return affectedRecordsNumber == 1
}

fun findAllBooks(): Set<Book> =
  database.sequenceOf(Books).toSet()

fun findBookById(bookId: Long): Book? =
  database.sequenceOf(Books)
    .find { book -> book.id eq bookId }

fun updateBookById(bookId: Long, bookRequest: BookRequest): Boolean {
  val foundBook = findBookById(bookId)
    foundBook?.name = bookRequest.name

  val affectedRecordsNumber = foundBook?.flushChanges()

  return affectedRecordsNumber == 1
}
fun deleteBookById(bookId: Long): Boolean {
  val foundBook = findBookById(bookId)

  val affectedRecordsNumber = foundBook?.delete()

  return affectedRecordsNumber == 1
}

8.1. Create Book

Let’s take a while to analyze each method:

fun createBook(bookRequest: BookRequest): Boolean {
  val newBook = Book {
    name = bookRequest.name
  }

  val affectedRecordsNumber = 
    database.sequenceOf(Books)
      .add(newBook)

  return affectedRecordsNumber == 1
}

As we can see, the createBook() creates a new Book instance (thanks to the companion object) and inserts it into the table using the database object.

The sequenceOf(), instantiates a new EntitySequence object. It exposes a sequence API, which we can use to perform DB operations, like querying or inserting data.

Finally, the .add() method inserts our entity into the database and returns the number of affected records.

The last line of this function simply validates, whether the number of affected records is equal to one (any other number indicates that something went wrong).

8.2. Fetching Books

Nextly, let’s check the querying functions:

fun findAllBooks(): Set<Book> =
  database.sequenceOf(Books).toSet()

fun findBookById(bookId: Long): Book? =
  database.sequenceOf(Books)
    .find { book -> book.id eq bookId }

They are pretty much the same, except for the fact, that in the second case the .find{} function will be translated to the WHERE clause in the generated SQL.

8.3. Update Book

As the next one, let’s check out the updateBookById():

fun updateBookById(bookId: Long, bookRequest: BookRequest): Boolean {
  val foundBook = findBookById(bookId)
  foundBook?.name = bookRequest.name

  val affectedRecordsNumber = foundBook?.flushChanges()

  return affectedRecordsNumber == 1
}

We can clearly see, that we can operate on the fetched entity, just like on any object in Kotlin. Nevertheless, for the changes to take effect, we must invoke the .flushChanges() function, which returns the affected records number.

8.4. Delete Book

Finally, the deleteBookById():

fun deleteBookById(bookId: Long): Boolean {
  val foundBook = findBookById(bookId)

  val affectedRecordsNumber = foundBook?.delete()

  return affectedRecordsNumber == 1
}

Although it’s performing a different action, the mechanism behind it remains the same.

9. Implement Book Routes

With all of that being done, we can finally add the necessary routes.

In Ktor, Routing is a core plugin responsible for handling incoming requests. If you’ve ever been working with Spring Boot before, then you may find it similar to the Controller concept.

9.1. Edit Application.kt

As the first step, let’s get back to the Application.kt and add configureBookRoutes() invocation:

fun main() {
  embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
    configureBookRoutes()
    configureSerialization()
  }.start(wait = true)
}

9.2. Create BookRoutes.kt

Secondly, let’s add the BookRoutes.kt file with the necessary function:

fun Application.configureBookRoutes() {
  routing {
    route("/book") {
      val bookService = BookService()
      createBook(bookService)
      getAllBooksRoute(bookService)
      getBookByIdRoute(bookService)
      updateBookByIdRoute(bookService)
      deleteBookByIdRoute(bookService)
    }
  }
}

private fun Book?.toBookResponse(): BookResponse? =
  this?.let { BookResponse(it.id!!, it.name) }

To put it simply, the routing block installs a Routing feature in our application. On the other hand, the route builds a route to match the specified path. This way, we can keep relevant functions together and avoid duplicating the /book path for each of them.

When it comes to the toBookResponse(), this extension function will help us to convert Book entities into BookResponses.

9.3. Add Create Book Route

With this in mind, let’s implement the rest of the code step by step. As the first one, let’s add the createBook():

fun Route.createBook(bookService: BookService) {
  post {
    val request = call.receive<BookRequest>()

    val success = bookService.createBook(bookRequest = request)

    if (success)
      call.respond(HttpStatusCode.Created)
    else
      call.respond(HttpStatusCode.BadRequest, ErrorResponse("Cannot create book"))
  }
}

The above code will be responsible for handling POST /book requests. As a word of explanation, the call.receive<BookRequest>() deserializes the content of the request (JSON) into the BookRequest instance. When the content cannot be translated, it throws ContentTransformationException (it’s worth to remember in real-life scenarios).

On the contrary, the call.respond() is responsible for sending responses to the client. We can clearly see that if creation is successful, the 201 Created is returned. However, if this is not the case, then the 400 Bad Request with an appropriate message is returned.

9.4. Implement Get All Books Route

As the next step, let’s add the getAllBooksRounte():

fun Route.getAllBooksRoute(bookService: BookService) {
  get {
    val books = bookService.findAllBooks()
      .map(Book::toBookResponse)

    call.respond(message = books)
  }
}

This one will be triggered on GET /book requests returning the list of books.

9.5. Add Get Book By Id Route

With that being done, let’s add the getBookByIdRoute() responsible for handling GET /book/{id} requests:

fun Route.getBookByIdRoute(bookService: BookService) {
  get("/{id}") {
    val id: Long = call.parameters["id"]?.toLongOrNull()
      ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid id"))

    bookService.findBookById(id)
      ?.let { foundBook -> foundBook.toBookResponse() }
      ?.let { response -> call.respond(response) }
      ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Book with id [$id] not found"))
  }
}

This time, let’s focus on the call.parameters[“id”]. The Parameters property of the call is simply a map of case-insensitive names to a collection of String values. That’s why we have to use a safe ?.toLongOrNull() to obtain the value correctly. If the id is invalid, then the 400 Bad Request is returned.

The rest of the code is pretty straightforward- either the found book is mapped to the BookResponse instance and returned to the client, or a 400 Bad Request informing that the book with a given id was not found.

9.6. Insert Update and Delete Routes

Finally, let’s add the two routes responsible for handling PATCH /book/{id} and DELETE /book/{id} requests:

fun Route.updateBookByIdRoute(bookService: BookService) {
  patch("/{id}") {
    val id: Long = call.parameters["id"]?.toLongOrNull()
      ?: return@patch call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid id"))

    val request = call.receive<BookRequest>()
    val success = bookService.updateBookById(id, request)

    if (success)
      call.respond(HttpStatusCode.NoContent)
    else
      call.respond(HttpStatusCode.BadRequest, ErrorResponse("Cannot update book with id [$id]"))
  }
}

fun Route.deleteBookByIdRoute(bookService: BookService) {
  delete("/{id}") {
    val id: Long = call.parameters["id"]?.toLongOrNull()
      ?: return@delete call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid id"))

    val success = bookService.deleteBookById(id)

    if (success)
      call.respond(HttpStatusCode.NoContent)
    else
      call.respond(HttpStatusCode.BadRequest, ErrorResponse("Cannot delete book with id [$id]"))
  }
}

As we can see, these functions use already described concepts, so we won’t go into much detail.

10. Test With cURL

Finally, let’s run the application and test it manually. For simplicity, we will use the cURL command. Alternatively, we could do that through Postman, or Insomnia.

10.1. GET /book

Let’s start by listing all books to validate both the endpoint and SQL init script:

curl localhost:8080/book

# Output
[
  {
    "id":1,
    "name": "Book #1"
  },
  {
    "id":2,
    "name":"Book #2"
  }
]

As we can see, everything is working correctly.

10.2. POST /book

As the second step, let’s create a new book:

curl -X POST 'localhost:8080/book' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "name": "Book #3"
  }'

# Then again: 
curl localhost:8080/book

# Output
[
  {
    "id":1,
    "name": "Book #1"
  },
  {
    "id":2,
    "name":"Book #2"
  },
  {
    "id":3,
    "name":"Book #3"
  }
]

10.3. GET /book/{id}

As the third step, let’s check whether fetching book details works:

curl localhost:8080/book/3

# Output:
{
  "id":3,
  "name":"Book #3"
}

curl localhost:8080/book/1234

# Output:
{
  "message":"Book with id [1234] not found"}
}

10.4. PATCH /book/{id}

After that, let’s validate the PATCH endpoint:

curl -X PATCH 'localhost:8080/book/3' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "name": "Book #3- edited"
  }'

# Then, let's check with GET:
curl localhost:8080/book/3

# Output:
{
  "id": 3,
  "name": "Book #3- edited"
}

10.5. DELETE /book/{id}

Finally, let’s delete created book:

curl -X DELETE 'localhost:8080/book/3'

# Then, let's check with GET:
curl localhost:8080/book/3

# Output:
{
  "message":"Book with id [3] not found"
}

11. Ktor With Ktorm And PostgreSQL Summary

And that would be all for this step-by-step guide on how to create REST API with Ktor, Ktorm, and PostgreSQL. I really hope you will find this material useful when working with Ktor. If you would like to see the source code, please refer to this GitHub repository.

If you find this one useful, then you may enjoy my newer article about Ktor and MongoDB.

As always- if you would like to ask me about anything, please do that in the comments section below, or through the contact form.

Previous articles, you might be interested in:

Share this:

Related content

Newsletter
Image presents 3 ebooks with Java, Spring and Kotlin interview questions.

Never miss any important updates from the Kotlin world and get 3 ebooks!

You may opt out any time. Terms of Use and Privacy Policy