JavaRush /Курси /Kotlin SELF /Валідація JSON‑даних

Валідація JSON‑даних

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

1. Парсинг і валідація: ролі та контракт результату

Навіть якщо JSON успішно розпарсився, це ще не означає, що дані підходять нашому застосунку. У цій лекції ми побудуємо шар валідації, який перетворює JsonElement на нормальні Kotlin‑дані (або зрозумілу помилку) — і робить це без винятків як «звичайного» механізму керування логікою.

Коли ви починаєте приймати дані «із зовнішнього світу» (файл, користувач, сторонній сервіс), дуже хочеться вірити в краще: «ну там же написано amount, значить буде число». Та реальність зазвичай не така романтична: поле може бути відсутнім, бути null, бути рядком "100", бути рядком "сто" або взагалі називатися інакше. Тому нам потрібна дисципліна: спочатку відповідаємо на запитання «це взагалі JSON?», а потім — «це ті дані, які ми готові обробляти?».

Парсинг vs валідація

Парсинг — це перевірка синтаксису. Тобто «рядок відповідає правилам JSON». Якщо там пропущено кому або лапки, парсер скаже «не можу» (і це нормально). Валідація — це перевірка сенсу та вимог застосунку: поля присутні, типи правильні, значення в адекватних діапазонах, рядки не порожні, числа не відʼємні тощо.

Зручно тримати в голові таку схему (вона ж буде нашою архітектурою функцій):

flowchart LR
    A["String (JSON text)"] --> B["parseToJsonElement()"]
    B -->|успіх| C["JsonElement (дерево)"]
    B -->|помилка| E["Error: 'invalid JSON'"]
    C --> D["validate(...)"]
    D -->|Ok| F["Нормальні Kotlin-дані"]
    D -->|Error| G["Зрозуміла помилка для користувача/лога"]

Головна користь цього розділення — у передбачуваності: код стає прозорішим. У нас є «точка», де JSON перетворюється на дерево, і окрема «точка», де дерево перетворюється на коректну модель. Якщо щось не так, ми точно розуміємо на якому етапі і чому.

Чому Boolean недостатньо, і навіщо Ok/Error

Коли новачок пише «валідацію», часто виходить функція виду fun isValid(x): Boolean. Це непоганий початок, але в реальності він швидко дає збій: якщо валідація повернула false, нам треба зрозуміти чому. Інакше застосунок перетворюється на ворожку: «дані неправильні… які саме? де? що виправити?».

Тому ми задаємо валідатору «дорослий» контракт: він повертає або успіх, або помилку з повідомленням. На цьому етапі домовимося не збирати «мішок із 17 помилок за раз», а повертати одну причину — так простіше і для коду, і для користувача.

Зробимо універсальний тип результату:

sealed class ValidationResult<out T> {
    data class Ok<T>(val value: T) : ValidationResult<T>()
    data class Error(val message: String) : ValidationResult<Nothing>()
}

Тут важливі дві речі. По‑перше, Ok зберігає вже готове значення потрібного типу T, щоб подальший код працював із нормальними даними, а не продовжував «намацувати JSON палицею». По‑друге, Error зберігає повідомлення, яке можна вивести користувачеві або в лог (у консольному проєкті часто це майже одне й те саме).

І ще один бонус: sealed class чудово дружить з when — компілятор змусить нас обробити всі варіанти (тобто ми не забудемо про помилку й не зробимо вигляд, що все добре).

2. Міні‑сюжет: імпорт витрат у CLI‑застосунок

Щоб приклади не були «у вакуумі», продовжимо розвивати наш навчальний консольний застосунок — умовний Expense Tracker (облік витрат). Раніше ми вже могли зберігати витрати й серіалізувати їх у JSON через @Serializable, але сьогодні сценарій інший: уявіть, що нам надіслали JSON невідомого формату (або «майже нашого», але із сюрпризами), і ми хочемо його імпортувати.

Ми будемо валідувати одну сутність витрати з полями:

  • title: рядок, не порожній (наприклад, "Coffee")
  • amount: ціле число, строго більше нуля (наприклад, 250)
  • category: рядок (може бути порожнім/відсутнім — тоді підставимо "other")

Зробимо модель «вже нормалізованих даних», яку отримаємо після валідації:

data class ExpenseDraft(
    val title: String,
    val amount: Int,
    val category: String
)

Чому Draft? Тому що це «чернетка» після валідації входу: ми ще не говоримо про збереження, ID та інше. Ми просто хочемо гарантувати, що дані нормальні й безпечні для подальшої логіки.

3. Конвеєр: парсимо JSON і валідуємо дані

Дуже поширена помилка — зробити одну гігантську функцію, яка і парсить, і валідує, і друкує, і «про всяк випадок» ловить усі винятки. Зазвичай це виглядає як комбайн: важкий, крихкий і страшний. Зробімо охайніше: parseJson(text) відповідає тільки за те, що текст — коректний JSON; validate... відповідає тільки за те, що структура й значення підходять.

Парсимо текст у дерево JsonElement

parseToJsonElement може викинути виняток, якщо текст не є коректним JSON, тому ми чесно перехопимо помилку й повернемо Error:

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement

fun parseJson(text: String): ValidationResult<JsonElement> {
    return try {
        ValidationResult.Ok(Json.parseToJsonElement(text))
    } catch (e: Exception) {
        ValidationResult.Error("Некоректний JSON: ${e.message}")
    }
}

Зверніть увагу: ми не робимо тут жодних перевірок полів amount/title. Нам поки важливо тільки одне: «дерево побудувалося чи ні».

Допоміжні функції: менше копіпасту, більше сенсу

Валідатор майже завжди складається з повторюваних кроків: «отримай поле», «переконайся, що воно примітив», «візьми рядкове представлення», «сконвертуй». Якщо писати це вручну щоразу, ви дуже швидко почнете ненавидіти і JSON, і життя, і клавіатуру.

Тому перевикористаємо допоміжні функції:

import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive

fun getPrimitiveOrNull(obj: JsonObject, key: String): JsonPrimitive? {
    val el = obj[key] ?: return null
    return el as? JsonPrimitive
}
import kotlinx.serialization.json.JsonObject

fun getStringOrNull(obj: JsonObject, key: String): String? =
    getPrimitiveOrNull(obj, key)?.content
import kotlinx.serialization.json.JsonObject

fun getIntOrNull(obj: JsonObject, key: String): Int? {
    val text = getStringOrNull(obj, key) ?: return null
    return text.toIntOrNull()
}

Тут є своя «філософія»: ці функції не викидають винятків і не підставляють значення за замовчуванням. Вони просто кажуть: «можу дістати коректно — дістав, не можу — повернув null». А вже валідатор вирішить, null тут допустимо чи це помилка.

Валідуємо одну витрату: JsonElement -> Ok(ExpenseDraft) / Error(message)

Тепер найцікавіша частина: перетворюємо фрагмент JSON на нормальні дані. Ми очікуємо обʼєкт (JsonObject), перевіряємо поля, перевіряємо обмеження й повертаємо результат. Важливо дотримуватися порядку перевірок: спочатку структура (обʼєкт чи ні), потім наявність і тип, а вже далі — правила (діапазони, порожні рядки).

import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject

fun validateExpense(el: JsonElement): ValidationResult<ExpenseDraft> {
    val obj = el as? JsonObject ?: return ValidationResult.Error("Витрата має бути JSON-обʼєктом")

    val title = getStringOrNull(obj, "title")?.trim()
        ?: return ValidationResult.Error("Поле 'title' є обовʼязковим")

    if (title.isBlank()) return ValidationResult.Error("Поле 'title' не повинно бути порожнім")

    val amount = getIntOrNull(obj, "amount")
        ?: return ValidationResult.Error("Поле 'amount' має бути цілим числом")

    if (amount <= 0) return ValidationResult.Error("Поле 'amount' має бути додатним")

    val categoryRaw = getStringOrNull(obj, "category")?.trim()
    val category = if (categoryRaw.isNullOrBlank()) "other" else categoryRaw

    return ValidationResult.Ok(ExpenseDraft(title = title, amount = amount, category = category))
}

Зверніть увагу на стиль: багато ранніх return. Це не «лінощі», а практична техніка guard clauses: щойно виявили проблему — одразу повертаємо помилку й не продовжуємо обчислення в «зламаному» стані.

Ще один момент: ми нормалізуємо дані. Ми робимо trim() для рядків, а порожню або відсутню категорію зводимо до "other". Завдяки цьому далі в коді маємо менше умов і менше шансів «завалити» застосунок через порожній рядок.

Валідуємо список: додаємо індекс і зупиняємося на першій помилці

Імпорт майже завжди приходить списком: масив витрат. І тут зʼявляється додаткове питання: якщо один елемент поганий, що робити з рештою? У межах цієї лекції виберемо стратегію «fail fast, але красиво»: щойно зустріли помилку — зупиняємося й повертаємо Error, але додаємо до повідомлення індекс елемента. Це помітно полегшує життя під час налагодження даних.

import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement

fun validateExpenses(el: JsonElement): ValidationResult<List<ExpenseDraft>> {
    val arr = el as? JsonArray ?: return ValidationResult.Error("Корінь має бути JSON-масивом")

    val items = mutableListOf<ExpenseDraft>()
    for ((index, itemEl) in arr.withIndex()) {
        when (val r = validateExpense(itemEl)) {
            is ValidationResult.Ok -> items.add(r.value)
            is ValidationResult.Error -> return ValidationResult.Error("Item[$index]: ${r.message}")
        }
    }
    return ValidationResult.Ok(items)
}

Так, ми могли б «збирати всі помилки», але це окрема (і складніша) техніка. Зараз нам важливіше навчитися тримати код простим і передбачуваним: одна помилка — одна зрозуміла відповідь.

Склеюємо конвеєр у main: читабельність важливіша за «все в один рядок»

Коли все готово, хочеться зробити красиво: «в один рядок розпарсив, провалідував, пішов далі». Але для початківців це часто перетворюється на магію, яку складно налагоджувати. Тому зберемо конвеєр явно — через проміжні val. Такий код виглядає трохи довшим, зате його легко читати й легко виправляти.

import kotlinx.serialization.json.JsonElement

fun main() {
    val text = """
        [
          {"title":"Coffee","amount":250,"category":"food"},
          {"title":"Book","amount":900,"category":"education"}
        ]
    """.trimIndent()

    val parsed: ValidationResult<JsonElement> = parseJson(text)

    when (parsed) {
        is ValidationResult.Ok -> {
            val validated = validateExpenses(parsed.value)
            println(validated) // Ok(value=[ExpenseDraft(...), ...]) або Error(...)
        }
        is ValidationResult.Error -> println(parsed.message) // Некоректний JSON: ...
    }
}

Тут добре видно розділення відповідальності: parseJson відповідає за синтаксис, validateExpenses — за вимоги застосунку. І так, println(validated) друкує «технічний» вигляд результату; у реальному CLI ви б виводили більш дружні повідомлення, але це вже питання оформлення UI, а не валідації.

4. require() і check(): де їхнє місце, а де — Ok/Error

Іноді студент цілком справедливо питає: «А навіщо весь цей ValidationResult, якщо є require(condition) і check(condition)?». Питання хороше, тому що Kotlin справді вміє автоматично викидати винятки через передумови: require() кидає IllegalArgumentException, а check()IllegalStateException. Це зручно й часто робить код коротшим.

Але нюанс у тому, що саме ви перевіряєте. require() і check() — це інструменти для ситуацій, коли програма не повинна продовжувати роботу, бо сталося порушення контракту: наприклад, розробник неправильно викликав функцію або обʼєкт опинився в неможливому стані. Такі перевірки доречні у «внутрішньому» коді, де помилки означають баг у логіці.

А от зовнішні дані (JSON «з вулиці») — це не баг у програмі. Це нормальна життєва ситуація: дані можуть бути зіпсовані, неповні, несумісні за версією. І в таких випадках винятки як «звичайний шлях керування» часто роблять код менш читабельним. Набагато приємніше повернути ValidationResult.Error("..."), показати користувачеві зрозумілий текст і коректно завершити імпорт без падіння всієї програми.

Якщо зовсім коротко: require/check — про помилки розробника, Ok/Error — про помилки вхідних даних.

5. Типові помилки під час валідації JSON‑даних

Помилка №1: змішування парсингу й валідації в одній функції‑«комбайні».
Коли в одній функції одночасно робляться parseToJsonElement, перевірки полів, друк у консоль і запис у колекцію, стає складно зрозуміти, на якому кроці все зламалося. Розділення на два етапи — парсинг і валідацію — робить код прозорим: якщо не парситься, проблема в синтаксисі; якщо парситься, але валідатор лається — проблема в структурі або значеннях.

Помилка №2: використання примусових приведень as і оператора !! під час читання дерева.
Такий код працює рівно до першого сюрпризу в даних, а потім падає в найбільш невідповідний момент. Валідація цінна тим, що вона має бути стійкою: as? плюс зрозуміла Error(...) майже завжди краще, ніж «воно має бути обʼєктом, я так вирішив».

Помилка №3: надто рання підміна відсутнього поля значенням за замовчуванням, яка маскує проблему.
Якщо ви в допоміжній функції робите «нема amount — нехай буде 0», то валідатор може «успішно» пропустити запис, який насправді некоректний. Набагато безпечніше так: допоміжна функція повертає null, валідатор вирішує, помилка це чи допустима відсутність, і лише валідатор має право підставляти значення за замовчуванням (як ми зробили з category = "other").

Помилка №4: перевірка бізнес‑правил до перевірки типів і наявності.
Якщо спочатку писати if (amount <= 0) до того, як ви взагалі переконалися, що amount — це число, ви отримаєте або кашу з умов, або виняток. Стабільний порядок зазвичай такий: структура → наявність → тип → конверсія → обмеження значень. Так повідомлення про помилки будуть зрозумілими й передбачуваними.

Помилка №5: повернення Boolean замість результату з причиною.
false не пояснює, що саме потрібно виправити. Повідомлення в Error — це не розкіш, а мінімальна ввічливість до майбутнього вас, який за тиждень розбиратиме чужий JSON о другій годині ночі й шепотітиме: «хто це написав… ах так, я».

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