1. Introduction
If you’ve just started programming, bodyAsText() seems perfect: request, get a string, print it to the console — beautiful. The problem appears exactly at the moment when you want to do anything useful: take the id field, show title, sum numbers, check completed. At that moment the string turns into a “blob of letters” that’s hard to work with without mistakes.
Imagine you received a package (JSON), but you didn’t unpack it and are trying to guess what’s inside by the noise when you shake the box. You can, but your head starts hurting very quickly. “Unpacking” is exactly serialization/deserialization.
In the Kotlin world we already have a familiar tool: kotlinx.serialization, and Ktor can plug into it via the ContentNegotiation plugin to automate the “unpacking” and “packing.”
A small diagram of where we’re going:
flowchart LR
A[HttpResponse] --> B["bodyAsText()"]
B --> C["Json.decodeFromString⟨T⟩() manually"]
A --> D["response.body⟨T⟩()"]
D --> E[Kotlin object]
The left branch is “manual,” the right one is “the grown-up way via Ktor.”
Two approaches to JSON in Ktor
Here is an important point: response.body<T>() is not magic. It’s just a convenient layer that Ktor wires up via plugins. In a “bare” Ktor client, Ktor is not required to understand JSON by itself.
Let’s compare the approaches in a single table — sometimes it’s easier for the brain when everything is side by side.
| Approach | What we write | What we control | Typical feeling |
|---|---|---|---|
| Manually | val text = response.bodyAsText() + Json.decodeFromString<T>(text) | Everything: when we read the string, which Json instance we parse with | “I understand everything, but there’s quite a bit of code” |
| Via ContentNegotiation | val obj: T = response.body() | Configuration at the client level; after that we read as types | “Wow, it got short. Now I just need to remember the settings” |
Today we’re aiming for the second option, but we also keep the first one in mind because sometimes it’s useful for diagnostics (for example, to look at the server’s “raw” response as text).
Data models: data class and @Serializable
When the server responds with JSON, it usually looks like an object with properties. To turn that into a Kotlin object, we need a model. And in 99% of cases it will be a data class, because it’s a “data container,” not “behavior and business logic.”
Under the hood, kotlinx.serialization works like this: you mark a class with the @Serializable annotation, and the compiler generates code that can read/write JSON for that class.
Let’s take a simple JSON example that many public APIs use for demos. For instance, JSONPlaceholder returns a TODO like this (approximately):
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
Let’s describe the model (completely “flat,” without complications):
import kotlinx.serialization.Serializable
@Serializable
data class TodoDto(
val userId: Int,
val id: Int,
val title: String,
val completed: Boolean
)
Pay attention: field names matter. By default, serialization maps Kotlin field name ↔ JSON key. If the server sends userId, then the field should also be named userId; otherwise you’ll have to enable additional settings or annotations (for example, @SerialName, but that’s already a topic from advanced serialization).
2. Configuring HttpClient for JSON
Now we’ll take the most important step of this lecture: we’ll create a client that can work with JSON “as types.”
Technically we do two things:
- Install the ContentNegotiation plugin
- Inside it, enable JSON support based on kotlinx.serialization
The skeleton of a client factory function looks like this:
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
fun buildJsonClient(): HttpClient =
HttpClient(CIO) {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
}
)
}
}
Here the ignoreUnknownKeys = true setting is practically “mandatory” for real life. Why? Because servers love adding new fields. And your learning code should not break just because the API became a bit richer. This setting says: “If the JSON contains fields that are not in our model, just ignore them.”
Again — the point of this step is that Ktor can now:
- read the response body
- understand that it’s JSON (by the Content-Type in the response)
- turn JSON into an object of the required type
Useful nuances of Json { ... }
When you write Json { ... }, there are quite a few options there. It’s easy for a beginner to fall into the desire to enable everything at once, like with a microwave: “let it be 1000W power, grill, and ‘pizza’ mode at the same time.” But it’s better to understand the most practical ones.
We already chose ignoreUnknownKeys = true as a “don’t break on extra fields” setting.
One more option that is sometimes useful in learning projects:
val json = Json {
ignoreUnknownKeys = true
prettyPrint = true
}
prettyPrint primarily affects serialization (when you encode an object into JSON) — the JSON will look nicer, with line breaks. For a server it usually doesn’t matter, but it’s more pleasant for a human in logs.
And isLenient, coerceInputValues, explicitNulls, and others are already subtle “compatibility modes.” Here we don’t touch them: until we run into a problem, we don’t complicate things.
3. Reading a JSON response as an object: response.body<T>()
Now let’s assemble a small example where we do a GET and read a TodoDto not by hand, but via response.body().
Important: to use response.body<T>(), you usually need the import io.ktor.client.call.* (or a specific import of body).
Here’s the minimal example. We intentionally stay in a “simple” style: we get an HttpResponse, then we read the body. The entry point is main.
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = buildJsonClient()
try {
val response: HttpResponse =
client.get("https://jsonplaceholder.typicode.com/todos/1")
val todo: TodoDto = response.body()
println(todo) // TodoDto(userId=1, id=1, title=..., completed=false)
} finally {
client.close()
}
}
What’s happening here step by step:
- client.get(...) returns an HttpResponse (as before).
- response.body<TodoDto>() (in our case via val todo) asks Ktor: “Give me the body as TodoDto.”
- Ktor finds a suitable converter (JSON), parses, and returns the object.
Why response.body() looks like it has no <TodoDto>
Because Kotlin can infer the type from context: on the left we have val todo: TodoDto, so T is TodoDto. In general, body() is a generic function that returns a value of type T.
If you want to make the type more explicit (sometimes that’s more useful for beginners), you can do this:
import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse
suspend fun readTodo(response: HttpResponse): TodoDto {
return response.body<TodoDto>()
}
How to see the “raw” response and not read the body twice
In practice you will often want: “if something went wrong, show me the raw text.” This is a good habit: when JSON doesn’t parse, the easiest thing is to see the original string.
But there’s a nuance: the response body can usually be read only once. So ideally you choose one path: either bodyAsText(), or body<T>().
How we’ll do it in a learning style (without a deep dive into errors, because that’s the next lecture): we’ll create a separate function that reads as an object, and leave debugging for separate runs.
For example, a “text” variant for debugging:
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
suspend fun debugPrintBody(response: HttpResponse) {
val text = response.bodyAsText()
println(text) // prints the full JSON
}
And a “production” variant:
import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse
suspend fun readTodoDto(response: HttpResponse): TodoDto {
return response.body()
}
Sometimes developers do this: first they print bodyAsText(), then they try body<T>() — and get very mysterious problems. It’s not mysticism; it’s just “you already ate the cookie; it’s not there a second time.”
4. Sending JSON to the server: POST and setBody(...)
Up to this point we “only read.” But JSON integration becomes especially nice when you send a Kotlin object and it automatically turns into the JSON request body.
Let’s make a simple request model. For example, “create a task”:
import kotlinx.serialization.Serializable
@Serializable
data class CreateTodoRequest(
val title: String,
val completed: Boolean
)
Now let’s send a POST. Here we need setBody(...), and it’s important to set Content-Type: application/json (so the server understands the format). In Ktor, this is done via contentType(ContentType.Application.Json).
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = buildJsonClient()
try {
val response: HttpResponse =
client.post("https://jsonplaceholder.typicode.com/todos") {
contentType(ContentType.Application.Json)
setBody(CreateTodoRequest(title = "Learn Ktor", completed = false))
}
println("status=${response.status.value}") // status=201 (usually for create)
println("body=${response.bodyAsText()}") // body=... (the server will return JSON)
} finally {
client.close()
}
}
What’s important to notice:
- setBody(obj) works in a type-safe way: you actually pass an object.
- ContentNegotiation + json(...) allow Ktor to understand: “OK, I can serialize CreateTodoRequest into JSON.”
- contentType(ContentType.Application.Json) is the format contract. If you don’t specify it, the server may interpret the body however it wants (or reject it entirely).
5. Mini client: Todo Console
Now there will be a small piece of hands-on practice: we’ll make a mini app that can:
- ask the user for a task id
- go to the network
- print title and completed
No architectural layers and no advanced error handling — just to feel with your hands how much nicer it is to work with an object than with a string.
Reminder: Ktor is a library for building HTTP things in the Kotlin ecosystem, and it is actively used in practice.
A small Int input function
Let’s make the simplest possible version: read a string, parse it into Int (we already know how to do this).
fun readIntPrompt(prompt: String): Int {
print(prompt)
return readln().trim().toInt()
}
Request: get a todo by id
import io.ktor.client.call.body
import io.ktor.client.request.get
suspend fun fetchTodo(client: io.ktor.client.HttpClient, id: Int): TodoDto {
val url = "https://jsonplaceholder.typicode.com/todos/$id"
val response = client.get(url)
return response.body()
}
Pay attention: the signature is simple because we haven’t discussed network errors yet. But even in this form the idea reads clearly: “took id → did a GET → got a TodoDto.”
Main main
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = buildJsonClient()
try {
val id = readIntPrompt("Enter the task id: ")
val todo = fetchTodo(client, id)
println("title=${todo.title}") // title=...
println("completed=${todo.completed}") // completed=false
} finally {
client.close()
}
}
And this is the key effect of today’s lecture: you are not “parsing JSON,” you are working with data.
6. Typical mistakes
Mistake #1: you forgot to install ContentNegotiation and are surprised that body<T>() doesn’t work.
This is a genre classic: you wrote a model, wrote response.body<MyDto>(), and then you either get an error or strange behavior. The reason is almost always the same: the client doesn’t know how to turn JSON into an object. It’s fixed in exactly one place — the HttpClient { install(ContentNegotiation) { json(...) } } configuration.
Mistake #2: the model doesn’t match the JSON, and you think “Kotlin will figure it out.”
Kotlin is smart, but not telepathic. If the server sent userId and you named the field user_id, it will not map by itself. If the server sent the string "1" and you expect an Int, it will hurt. At the beginning, the discipline is simple: Kotlin field = JSON key, and the type must match. Later you learn @SerialName and more flexible rules.
Mistake #3: reading the body twice: first bodyAsText(), then body<T>().
It’s like opening juice, drinking it, and then complaining to the bottle that it’s empty. A network response usually provides a data stream that can be read once. Choose: either text or an object. For debugging, it’s better to do a separate run or a separate function that reads and prints the text.
Mistake #4: sending an object via setBody(...), but not specifying Content-Type: application/json.
For you the object is obviously “JSON-like,” but for the server it’s just “some bytes.” If you didn’t set Content-Type, the server may decide it’s form data, plain text, or who knows what. In Ktor the habit is simple: for POST with JSON, always write contentType(ContentType.Application.Json) next to setBody(...).
Mistake #5: you didn’t enable ignoreUnknownKeys = true, and the project starts “crashing” because of an extra field.
This is very common: the API got updated, added a field, and your client suddenly can’t parse the response. In learning projects it looks like “everything worked yesterday, today it broke, I didn’t change anything.” That’s why ignoreUnknownKeys = true is almost a standard client setting, especially when you don’t control the server.