JavaRush /Курси /Kotlin SELF /JSON‑інтеграція в Ktor: ContentNegotiation і kotlinx.seri...

JSON‑інтеграція в Ktor: ContentNegotiation і kotlinx.serialization

Kotlin SELF
Рівень 58 , Лекція 2
Відкрита

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 «як із типами».

Технічно ми робимо дві речі:

  1. Під’єднуємо плагін ContentNegotiation
  2. Усередині нього під’єднуємо підтримку 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()
    }
}

Що тут відбувається крок за кроком:

  1. client.get(...) повертає HttpResponse (як і раніше).
  2. response.body<TodoDto>() (у нашому випадку — через val todo) просить Ktor: «Дай мені тіло як TodoDto».
  3. 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()
    }
}

Що важливо помітити:

  1. setBody(obj) працює типобезпечно: ви справді передаєте об’єкт.
  2. ContentNegotiation + json(...) дозволяють Ktor зрозуміти: «Ага, я вмію серіалізувати CreateTodoRequest у JSON».
  3. 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 — майже стандартне налаштування для клієнтів, особливо коли ви не контролюєте сервер.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ