1. Вступ
Якщо ви лише почали програмувати, bodyAsText() здається ідеальним: зробили запит, отримали рядок, вивели в консоль — краса. Проблеми починаються рівно тоді, коли ви хочете зробити бодай щось корисне: взяти поле id, показати title, скласти числа, перевірити completed. І тоді рядок перетворюється на «комок літер», із яким складно працювати без помилок.
Уявіть, що вам надіслали посилку (JSON), а ви її не розпакували й намагаєтеся вгадати вміст за шумом, коли струшуєте коробку. Можна, але дуже швидко починає боліти голова. Оця «розпаковка» — це і є серіалізація та десеріалізація.
У світі Kotlin у нас уже є знайомий інструмент: kotlinx.serialization. А Ktor уміє під’єднуватися до нього через плагін ContentNegotiation, щоб автоматизувати «розпаковування» та «пакування».
Невелика схема того, куди ми рухаємося:
flowchart LR
A[HttpResponse] --> B["bodyAsText()"]
B --> C["Json.decodeFromString⟨T⟩() вручну"]
A --> D["response.body⟨T⟩()"]
D --> E[Обʼєкт Kotlin]
Ліва гілка — «вручну», права — «по‑дорослому» через Ktor.
Два підходи до JSON у Ktor
Тут є важливий момент: response.body<T>() — не магія. Це лише зручний шар, який Ktor підʼєднує через плагіни. У «голому» клієнті Ktor не зобов’язаний сам розуміти JSON.
Порівняймо підходи в одній таблиці — іноді мозку простіше, коли все перед очима.
| Підхід | Що пишемо | Що контролюємо | Типові відчуття |
|---|---|---|---|
| Вручну | val text = response.bodyAsText() + Json.decodeFromString<T>(text) | Усе: коли читаємо рядок, яким Json парсимо | «Я все розумію, але коду забагато» |
| Через ContentNegotiation | val obj: T = response.body() | Налаштування на рівні клієнта, далі читаємо типами | «Ого, стало коротко. Тепер би не забути налаштування» |
Сьогодні ми цілимося в другий варіант. Але перший теж тримаємо в голові: іноді він корисний для діагностики (наприклад, щоб подивитися «сиру» відповідь сервера як текст).
Моделі даних: data class і @Serializable
Коли сервер відповідає JSON, зазвичай це об’єкт із властивостями. Щоб перетворити його на Kotlin‑об’єкт, нам потрібна модель. І у 99 % випадків це буде data class, бо це «контейнер для даних», а не «поведінка й бізнес‑логіка».
Під капотом kotlinx.serialization працює так: ви позначаєте клас анотацією @Serializable, і компілятор генерує код, який уміє читати та записувати JSON для цього класу.
Візьмімо простий приклад JSON, який багато публічних API використовують для демо. Наприклад, JSONPlaceholder віддає TODO приблизно в такому вигляді:
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
Опишемо модель (повністю «пласку», без ускладнень):
import kotlinx.serialization.Serializable
@Serializable
data class TodoDto(
val userId: Int,
val id: Int,
val title: String,
val completed: Boolean
)
Зверніть увагу: імена полів важливі. За замовчуванням серіалізація зіставляє імʼя поля Kotlin ↔ ключ JSON. Якщо сервер надсилає userId, то й поле має називатися userId. Інакше доведеться вмикати додаткові налаштування або анотації (наприклад, @SerialName, але це вже тема просунутішої серіалізації).
2. Налаштовуємо HttpClient для JSON
Зараз ми зробимо найважливіший крок цієї лекції: створимо клієнт, який уміє працювати з JSON «як із типами».
Технічно ми робимо дві речі:
- Під’єднуємо плагін ContentNegotiation
- Усередині нього під’єднуємо підтримку JSON на базі kotlinx.serialization
Скелет функції‑фабрики клієнта виглядає так:
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
}
)
}
}
Налаштування ignoreUnknownKeys = true — практично «обов’язкове» для життя. Чому? Бо сервери люблять додавати нові поля. А ваш навчальний код не має ламатися лише через те, що API стало трохи багатшим. Це налаштування каже: «Якщо в JSON є поля, яких немає в нашій моделі, — просто ігноруємо».
Ще раз: сенс цього кроку в тому, що Ktor тепер може:
- прочитати тіло відповіді;
- зрозуміти, що це JSON (за Content-Type у відповіді);
- перетворити JSON на об’єкт потрібного типу.
Корисні нюанси Json { ... }
Коли ви пишете Json { ... }, там доволі багато опцій. Новачку легко піддатися спокусі увімкнути все підряд, як у мікрохвильовці: «хай буде потужність 1000 W, гриль і режим “піца” одночасно». Але краще розуміти найпрактичніші налаштування.
Ми вже вибрали ignoreUnknownKeys = true як опцію «щоб не ламалося від зайвих полів».
Ще одна опція, яка іноді корисна в навчальних проєктах:
val json = Json {
ignoreUnknownKeys = true
prettyPrint = true
}
prettyPrint впливає насамперед на серіалізацію (коли ви кодуєте об’єкт у JSON): JSON буде охайнішим, із переносами рядків. Для сервера зазвичай байдуже, але людині в логах приємніше.
А от isLenient, coerceInputValues, explicitNulls та інші — це вже тонкі «режими сумісності». Тут ми їх не чіпаємо: доки не зіткнулися з проблемою, не ускладнюємо.
3. Читаємо JSON‑відповідь як обʼєкт: response.body<T>()
Тепер зберемо невеликий приклад: робимо GET і читаємо TodoDto не вручну, а через response.body().
Важливо: щоб використовувати response.body<T>(), вам зазвичай потрібен імпорт io.ktor.client.call.* (або точковий імпорт body).
Ось мінімальний приклад. Ми свідомо лишаємося в «простому» стилі: отримали HttpResponse, а потім читаємо тіло. Точка входу — 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()
}
}
Що тут відбувається крок за кроком:
- client.get(...) повертає HttpResponse (як і раніше).
- response.body<TodoDto>() (у нашому випадку — через val todo) просить Ktor: «Дай мені тіло як TodoDto».
- Ktor знаходить відповідний конвертер (JSON), розбирає дані й повертає об’єкт.
Чому response.body() виглядає без <TodoDto>
Бо Kotlin уміє виводити тип із контексту: ліворуч у нас val todo: TodoDto, отже T — це TodoDto. Загалом body() — це узагальнена функція (generic), яка повертає значення типу T.
Якщо ви хочете зробити тип явнішим (іноді це корисніше для новачків), можна так:
import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse
suspend fun readTodo(response: HttpResponse): TodoDto {
return response.body<TodoDto>()
}
Як побачити «сиру» відповідь і не прочитати тіло двічі
На практиці часто хочеться так: «якщо щось пішло не так — покажи мені сирий текст». Це нормальна звичка: коли JSON не розбирається, найпростіше побачити вихідний рядок.
Але є тонкість: тіло відповіді зазвичай можна прочитати лише один раз. Тож в ідеалі ви обираєте один шлях: або bodyAsText(), або body<T>().
Як зробимо в навчальному стилі (без глибокого розбору помилок, бо це тема наступної лекції): заведемо окрему функцію, яка читає як об’єкт, а відлагодження залишимо для окремих запусків.
Наприклад, «текстовий» варіант для відлагодження:
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
suspend fun debugPrintBody(response: HttpResponse) {
val text = response.bodyAsText()
println(text) // друкує JSON повністю
}
І «бойовий» варіант:
import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse
suspend fun readTodoDto(response: HttpResponse): TodoDto {
return response.body()
}
Іноді розробники роблять так: спочатку друкують bodyAsText(), а потім намагаються викликати body<T>() — і отримують дуже загадкові проблеми. Це не містика, а просто «ви вже з’їли печиво — вдруге його немає».
4. Надсилання JSON на сервер: POST і setBody(...)
Досі ми «лише читали». Але JSON‑інтеграція стає особливо приємною, коли ви надсилаєте Kotlin‑об’єкт, а він автоматично перетворюється на JSON‑тіло запиту.
Зробімо просту модель запиту. Наприклад, «створити задачу»:
import kotlinx.serialization.Serializable
@Serializable
data class CreateTodoRequest(
val title: String,
val completed: Boolean
)
Тепер надішлемо POST. Тут нам потрібен setBody(...), і важливо вказати Content-Type: application/json, щоб сервер зрозумів формат. У Ktor це робиться через 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 (зазвичай для create)
println("body=${response.bodyAsText()}") // body=... (сервер поверне JSON)
} finally {
client.close()
}
}
Що важливо помітити:
- setBody(obj) працює типобезпечно: ви справді передаєте об’єкт.
- ContentNegotiation + json(...) дозволяють Ktor зрозуміти: «Ага, я вмію серіалізувати CreateTodoRequest у JSON».
- contentType(ContentType.Application.Json) — це контракт формату. Якщо не вказати, сервер може інтерпретувати тіло як завгодно (або взагалі відмовитися).
5. Міні‑клієнт Todo Console
Зараз буде невеликий шматок практики: зробимо міні‑застосунок, який уміє:
- запитати в користувача ID задачі;
- звернутися до мережі;
- вивести title і completed.
Без архітектурних шарів і без просунутого оброблення помилок — просто щоб «руками» відчути, наскільки приємніше працювати з об’єктом, ніж із рядком.
Нагадаю: Ktor — це бібліотека для роботи з HTTP у Kotlin‑екосистемі, і її активно використовують на практиці.
Маленька функція введення Int
Зробимо максимально просту версію: читаємо рядок, перетворюємо його на Int (ми вже вміємо це робити).
fun readIntPrompt(prompt: String): Int {
print(prompt)
return readln().trim().toInt()
}
Запит: отримати todo за 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()
}
Зверніть увагу: сигнатура проста, бо ми ще не обговорюємо мережеві помилки. Але навіть у такому вигляді ідея читається прозоро: «взяли id → зробили GET → отримали TodoDto».
Головний main
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val client = buildJsonClient()
try {
val id = readIntPrompt("Введіть id задачі: ")
val todo = fetchTodo(client, id)
println("title=${todo.title}") // title=...
println("completed=${todo.completed}") // completed=false
} finally {
client.close()
}
}
І ось ключовий ефект сьогоднішньої лекції: ви не «парсите JSON», ви працюєте з даними.
6. Типові помилки
Помилка № 1: забули під’єднати ContentNegotiation і дивуються, що body<T>() не працює.
Це класика жанру: ви написали модель, написали response.body<MyDto>(), а далі — або помилка, або дивна поведінка. Причина майже завжди одна: клієнт не знає, як перетворювати JSON на об’єкт. Лікується рівно одним місцем — налаштуванням HttpClient { install(ContentNegotiation) { json(...) } }.
Помилка № 2: модель не збігається з JSON, а ви думаєте, що «Kotlin сам здогадається».
Kotlin розумний, але не телепат. Якщо сервер надіслав userId, а ви назвали поле user_id, воно само не зіставиться. Якщо сервер надіслав рядок "1", а ви очікуєте Int, буде боляче. На перших кроках дисципліна проста: поле Kotlin = ключ JSON, і тип має збігатися. А вже потім вивчають @SerialName і гнучкіші правила.
Помилка № 3: читають тіло двічі — спочатку bodyAsText(), потім body<T>().
Це як відкрити сік, випити, а потім пред’явити претензію пляшці, що вона порожня. Мережева відповідь зазвичай дає потік даних, який читається один раз. Обирайте: або текст, або об’єкт. Для відлагодження краще зробити окремий запуск або окрему функцію, яка читає текст і друкує.
Помилка № 4: надсилають об’єкт через setBody(...), але не вказують Content-Type: application/json.
Для вас об’єкт очевидно «JSON‑ний», а для сервера це просто «якісь байти». Якщо ви не вказали Content-Type, сервер може вирішити, що це form-дані, plain text, або взагалі невідомо що. У Ktor звичка проста: при POST з JSON завжди пишемо contentType(ContentType.Application.Json) поруч із setBody(...).
Помилка № 5: не увімкнули ignoreUnknownKeys = true, і проєкт починає «падати» через зайве поле.
Це дуже життєва ситуація: API оновилося, додало поле — і ваш клієнт раптом не може розпарсити відповідь. У навчальних проєктах це виглядає як «учора все працювало, сьогодні зламалося, я нічого не змінював». Тому ignoreUnknownKeys = true — майже стандартне налаштування для клієнтів, особливо коли ви не контролюєте сервер.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ