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 { ... }, там довольно много опций. Новичку легко впасть в желание включить всё подряд, как в микроволновке: «пусть будет мощность 1000W, гриль и режим “пицца” одновременно». Но лучше понимать самые практичные.

Мы уже выбрали 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 — почти стандартная настройка для клиентов, особенно когда вы не контролируете сервер.

1
Задача
Kotlin SELF, 58 уровень, 2 лекция
Недоступна
TODO объект
TODO объект
1
Задача
Kotlin SELF, 58 уровень, 2 лекция
Недоступна
Короткая модель
Короткая модель
1
Задача
Kotlin SELF, 58 уровень, 2 лекция
Недоступна
TODO по id
TODO по id
1
Задача
Kotlin SELF, 58 уровень, 2 лекция
Недоступна
Создание TODO
Создание TODO
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ