Codersee

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.

Ktor is a free, open-source framework, which allows us to easily build connected applications. When it comes to the server-side applications, we can treat it as a lightweight and powerful alternative for a 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. Futhermore, 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 projectPlease 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 has been added automatically: ContentNegotiation and Routing. Please do not remove them, we will need them later.

2. Add Ktorm and PostgreSQL To Ktor

Nextly, let’s open up our project with IntelliJ and go to the gradle.properties file. Unfortunately, the Ktor 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")

}

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 Serializaiton.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 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 add the docker-compose.yaml file to the root of the project:

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 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 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, with these three steps we can quickly set up the development environment.

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 to understand it better.

The first one, Book, is a representation of 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, object called Books is responsible for binding our entity with database table. It allows us to specify columns types, names and wire them with our entity through the .bindTo() method. Whatsoever, we will use this object later, when working with our 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 connections 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 to 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 functions 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 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 the fact, that in second case the .find{} function will 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 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 different action, the mechanism behind it remains the same.

9. Implement Book Routes

With all of that being done, we can finally add 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 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, routing block installs a Routing feature in our application. On the other hand, the route builds a route to match specified path. This way, we can keep relevant functions together and avoid duplicating /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 conent 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 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 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 details.

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 ednpoint 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.

Whatsoever, I can quarantee that more materials about this framework will be published on my blog.

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:

Leave a Reply

Your email address will not be published.

Categories

Author

Piotr Wolak

Piotr Wolak

Founder Of Codersee

Join Newsletter And Get 2 FREE EBOOKS

Image shows the covers of free ebooks accessible for newsletter subscribers.

Join the FREE weekly newsletter and get two free eBooks:

Image shows the covers of free ebooks accessible for newsletter subscribers.

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

To make Codersee work, we log user data. By using our site, you agree to our Privacy Policy and Terms of Use.