How To Use kotlinx.serialization with Ktor and Kotlin?

Image is a featured image for article about kotlinx.serialization in Ktor and consist of Ktor logo in the foreground in a blurred photo of a winter forest in the background.

If you would like to learn how to use the kotlinx.serialization library with Ktor and Kotlin, then you just came to the right place! In this article, I will show you how to configure it to work with:

  • Ktor Server
  • Ktor Client
  • WebSockets in Ktor

Before we start, I just wanted to note that we are going to use a modified project from the lesson about Ktor with Ktorm and PostgreSQL. So if you don’t know how to set up and use a Ktor server with PostgreSQL you should definitely check it up, because I will skip the basics. Additionally, if you’d like to explore kotlinx.serialization in details, then check out my detailed guide.

Lastly, please note that there is a GitHub repository for all of this in the end of this lesson!

Setup

A few quick words about setting up. Note that for the main thing we are going to use, such as ContentNegotiation, Ktor has different implementations for Client and Server.

Do not mess them up, it causes a lot of trouble and a lot of Stack Overflow questions 🙂

//server
implementation("io.ktor:ktor-server-content-negotiation-jvm")
//client
implementation("io.ktor:ktor-client-content-negotiation-jvm")

kotlinx.serialization With Ktor Server

Now, let’s start with the kotlinx.serialization for the Ktor server part.

I am going to use Postman for making HTTP requests and testing the server. You can use any tool for this purpose even the client we will write about in the next part 🙂

First of all, let’s check our classes for the User from previous parts:

interface User : Entity<User> {  
  companion object : Entity.Factory<User>()  
  
  val userId: Long?  
  var userName: String  
}

 Now we are going to need classes for our responses: 

@Serializable  
data class UserResponse(  
  val userId: Long,  
  val userName: String  
)  
  
@Serializable  
data class UserRequest(  
  val userName: String  
)

@Serializable  
data class UserErrorResponse(val message: String)

Basically, the central part of all serialization here is ContentNegotiation which could be installed for our server(not the client).

As we are using it for Json serialization, we should specify it as json() like this:

fun Application.configureSerialization() {  
  install(ContentNegotiation) {  
    json(Json {  
      prettyPrint = true  
      isLenient = true  
    })  
  }  
}

And here’s our loved Json object! Now we can do all kinds of trickery that we know and learned before.

For the code above, I’ve used a simple style specification, but you can use it as you like. As an example, we can add a custom or polymorphic serializer:

private val contextualSerializerModule = SerializersModule {
  contextual<Date>(DateAsStringSerializer)
}

private val polymorphicSerializationModule = SerializersModule {
  polymorphic(User::class) {
    subclass(Admin::class, Admin.serializer())
    subclass(Guest::class, Guest.serializer())
  }
}

object DateAsStringSerializer : KSerializer<Date> {
  private val dateFormat = SimpleDateFormat("yyyy-MM-dd 'T' HH:mm:ss.SSSZ")

  override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(
    "Date", 
    PrimitiveKind.STRING,
  )

  override fun serialize(encoder: Encoder, value: Date) {
    encoder.encodeString(dateFormat.format(value))
  }

  override fun deserialize(decoder: Decoder): Date {
    return dateFormat.parse(decoder.decodeString())
  }
}

And our server is going to look like this: 

fun Application.configureSerialization() {
  install(ContentNegotiation) {
    json(Json {
      prettyPrint = true
      isLenient = true
      serializersModule = contextualSerializerModule
      serializersModule = polymorphicSerializationModule
    })
  }
}

We can remove this for now, we’ll use actual polymorphic serialization in the Web Sockets section.

Now, what about routes? Let’s check out a simple POST request:

fun Route.createUser(userService: UserService) {
  post {

    val request = call.receive<UserRequest>()

    val success = userService.createUser(userRequest = request)

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

As we can see, we don’t serialize/deserialize anything manually.

The whole process happens due to this line:

val request = call.receive<UserRequest>()

We can witness that our request deserializes into the UserRequest as such is marked with @Serializable annotation.

The same happens here:

fun Route.updateUserByIdRoute(userService: UserService) {
  patch("/{userId}") {
    val userId: Long = call.parameters["userId"]?.toLongOrNull()
      ?: return@patch call.respond(
          HttpStatusCode.BadRequest, 
          UserErrorResponse("Invalid id")
        )

    val request = call.receive<UserRequest>()
    val success = userService.updateUserById(userId, request)

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

Our request is deserialized and we can update a user.

Now let’s test it and get our users:

private fun User?.toUserResponse(): UserResponse? =  
    this?.let { UserResponse(it.userId!!, it.userName) }

fun Route.getAllUsersRoute(userService: UserService) {  
    get {  
        val users = userService.findAllUsers()  
            .map(User::toUserResponse)  
  
        call.respond(message = users)  
    }  
}

As I said before, I’m going to use Postman.

So how about checking our server:

Image shows a GET request URL in Postman.
Image shows a response from the GET request serialized with kotlinx serialization in our Ktor server

Post:

Image shows the URL for POST request in Postman
Screenshot presents the content type header set to application/json
Screenshot shows the response we get from the POST endpoint in our Ktor server serialized using the kotlinx serialization library
Image presents 201 Created response from the POST endpoint

Delete:

Image shows the url path for the DELETE endpoint.

kotlinx.serialization With Ktor Client

Now the kotlinx.serialization for the Ktor client. Here’s the same thing, but make sure you don’t miss anything and use ContentNegotiation FOR CLIENT.

Check the import:

import io.ktor.client.plugins.contentnegotiation.*

We can install it just like before:

private val client = HttpClient(CIO) {
  install(ContentNegotiation) {
    json(Json {
      prettyPrint = true
      isLenient = true
    })
  }
  defaultRequest {
    url {
      host = "0.0.0.0"
      path("/")
      port = 8080
    }
  }}

All things apply here as well, so let’s check how to use it for our requests. There are lots of possible situations, we are going to cover basic data retrieving and how to place something inside a body of a request.

We want to get all of the users, what should we do? We can use something like this:

suspend fun getAllUsers(): List<UserResponse> {
  return try {
    val response: HttpResponse = client.get("/users")

    if (response.status == HttpStatusCode.OK) {
      response.body<List<UserResponse>>()
    } else {
      println("Failed to retrieve users. Status: ${response.status}")
      emptyList()
    }
  } catch (e: Exception) {
    println("Error retrieving users: ${e.message}")
    emptyList()
  }
}

The main part here as well is this:

response.body<List<UserResponse>>()

The UserResponse class can be serialized, and we don’t need to do anything at all. All hail the ContentNegotiation 🙂

How about we create some user? We can do it as well:

suspend fun createUser(user: UserRequest) {
  try {
    val response: HttpResponse = client.post("/users") {
      contentType(ContentType.Application.Json)
      setBody(user)
    }

    if (response.status == HttpStatusCode.Created) {
      println("User created successfully")
    } else {
      println("Failed to create user. Status: ${response.status}")
    }
  } catch (e: Exception) {
    println("Error creating user: ${e.message}")
  }
}

This time, we specify the content type for our body, such as ContentType.Application.Json and just set our user in here. All happens as it is a miracle.

Now let’s test it. I’m going to use this simply to check each method:

runBlocking {

  val allUsersOld = UserRequests.getAllUsers()
  println("All Users: $allUsersOld")

  val newUser = UserRequest(
    userName = "Mark"
  )

  UserRequests.createUser(newUser)

  val allUsersNew = UserRequests.getAllUsers()
  println("All Users: $allUsersNew")

  val userIdToRetrieve = 2L
  val retrievedUser = UserRequests.getUserById(userIdToRetrieve)
  println("User with ID $userIdToRetrieve: $retrievedUser")

  val userIdToUpdate = 2L
  val updatedUser = UserRequest(
    userName = "Bob"
  )
  UserRequests.updateUserById(userIdToUpdate, updatedUser)

  val userIdToDelete = 2L
  UserRequests.deleteUserById(userIdToDelete)
}

And the corresponding result would be something like this:

...
All Users: [UserResponse(userId=2, userName=User #2)]
...
User created successfully
...
All Users: [UserResponse(userId=2, userName=User #2), UserResponse(userId=7, userName=Mark)]
...
User with ID 2: UserResponse(userId=2, userName=User #2)
...
User updated successfully
...
User deleted successfully

I’ve skipped the logs because they are not important for this. 

Web Sockets

So, here’s the fun part. There is no Content Negotiation for the Web Sockets 🙁

Nonetheless, there is such thing as contentConverter for WebSockets:

private val client = HttpClient(CIO).config {
  install(WebSockets) {
    contentConverter = KotlinxWebsocketSerializationConverter(
      Json
    )
  }
}

Basically, that’s it, now we can use sendSerialized() and receiveDeserialized() to send and receive data.

However, let’s focus more on manual implementation for the sake of understanding. Here we have classes to represent our messages:

@Serializable
abstract class Message {
  abstract val content: String
}

@Serializable
@SerialName("text")
class TextMessage(override val content: String) : Message()

@Serializable
@SerialName("system")
class SystemMessage(override val content: String, val systemInfo: String) : Message()

private val module = SerializersModule {
  polymorphic(Message::class) {
    subclass(TextMessage::class, TextMessage.serializer())
    subclass(SystemMessage::class, SystemMessage.serializer())
  }
}

val messagesFormat = Json {
  serializersModule = module
}

We are going to use TextMessage to send to the server and SystemMessage to send back from the server.

To send a message we must serialize it to string. And to receive vice versa. Nothing difficult: 

@OptIn(ExperimentalSerializationApi::class)
private suspend fun DefaultClientWebSocketSession.receiveMessage() {
  try {
    for (message in incoming) {
      message as? Frame.Text ?: continue
      val deserializedMessage: Message =
        messagesFormat.decodeFromStream(message.data.inputStream())
      println("${deserializedMessage.content} // ${(deserializedMessage as? SystemMessage)?.systemInfo}")
    }
  } catch (e: Exception) {
    println("Error while receiving: " + e.localizedMessage)
  }
}

private suspend fun DefaultClientWebSocketSession.sendMessage(message: Message) {
  val serializedMessage = messagesFormat.encodeToString(message)
  try {
    send(serializedMessage)
  } catch (e: Exception) {
    println("Some error occur: " + e.localizedMessage)
    return
  }
}

Here’s our simple server that going to get our message and send it back with some changes:

routing {
  webSocket("/hello") {
    try {
      for (frame in incoming) {
        frame as? Frame.Text ?: continue
        val deserializedMessage: WebSocketSession.Message =
          WebSocketSession.messagesFormat.decodeFromStream(frame.data.inputStream())
        val newMessageText = deserializedMessage.content + " - from Client"
        val serializedMessage = WebSocketSession.messagesFormat.encodeToString<WebSocketSession.Message>(
          WebSocketSession.SystemMessage(
            content = newMessageText,
            systemInfo = "Important"
          )
        )
        Connection(this).session.send(serializedMessage)
      }
    } catch (e: Exception) {
      println(e.localizedMessage)
    }
  }
}

The final part is to test it. We simply connect it to our already existing server:

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

And now run the client, for example, I’ve created this one:

suspend fun connectWebSocket() {
  client.webSocket(
    host = "0.0.0.0",
    port = 8080,
    path = "/hello"
  ) {
    launch { sendMessage(TextMessage("Good morning!")) }
    launch { receiveMessage() }
    delay(2000)
    launch { sendMessage(TextMessage("Hello!")) }
    launch { receiveMessage() }
    delay(2000)
    println("Connection closed. Goodbye!")
    client.close()
  }
}

runBlocking {
  WebSocketSession.connectWebSocket()
}

Here’s the results:

2023-12-01 15:46:26.104 [DefaultDispatcher-worker-4] TRACE io.ktor.websocket.WebSocket - Sending Frame TEXT (fin=true, buffer len = 41) from session io.ktor.websocket.DefaultWebSocketSessionImpl@2e8ab815
2023-12-01 15:46:26.110 [DefaultDispatcher-worker-6] TRACE io.ktor.websocket.WebSocket - WebSocketSession(StandaloneCoroutine{Active}@4ecfdc65) receiving frame Frame TEXT (fin=true, buffer len = 82)
Good morning! - from Client // Important
2023-12-01 15:46:28.094 [DefaultDispatcher-worker-6] TRACE io.ktor.websocket.WebSocket - Sending Frame TEXT (fin=true, buffer len = 34) from session io.ktor.websocket.DefaultWebSocketSessionImpl@2e8ab815
2023-12-01 15:46:28.097 [DefaultDispatcher-worker-3] TRACE io.ktor.websocket.WebSocket - WebSocketSession(StandaloneCoroutine{Active}@4ecfdc65) receiving frame Frame TEXT (fin=true, buffer len = 75)
Hello! - from Client // Important
Connection closed. Goodbye!

Summary

And that’s all for this article about kotlinx.serialization with Ktor.

If you would like to download the full source code, then you can find it in this GitHub repository.

Lastly, thank you for being here, and happy to hear your feedback in the comments section below!

Share this:

Hi there! 👋

Hi there! 👋

My name is Piotr and I've created Codersee to share my knowledge about Kotlin, Spring Framework, and other related topics through practical, step-by-step guides. Always eager to chat and exchange knowledge.

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

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