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("Invalid JSON: ${e.message}")
    }
}

Обратите внимание: мы не делаем здесь никаких проверок полей amount/title. Нам пока важно только одно: «дерево построилось или нет».

Helper‑функции: меньше копипаста, больше смысла

Валидатор почти всегда состоит из повторяющихся шагов: «получи поле», «убедись, что оно примитив», «возьми строковое представление», «сконвертируй». Если писать это руками каждый раз, вы очень быстро начнёте ненавидеть и JSON, и жизнь, и клавиатуру.

Поэтому переиспользуем helper‑функции:

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("Expense must be a JSON object")

    val title = getStringOrNull(obj, "title")?.trim()
        ?: return ValidationResult.Error("Field 'title' is required")

    if (title.isBlank()) return ValidationResult.Error("Field 'title' must be non-blank")

    val amount = getIntOrNull(obj, "amount")
        ?: return ValidationResult.Error("Field 'amount' must be an integer")

    if (amount <= 0) return ValidationResult.Error("Field 'amount' must be positive")

    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("Root must be a JSON array")

    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) // Invalid 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: ранняя подмена отсутствующего поля значением по умолчанию, которая маскирует проблему.
Если вы в helper‑функции делаете «нет amount — пусть будет 0», то валидатор может “успешно” пропустить запись, которая на самом деле некорректна. Гораздо безопаснее: helper возвращает null, валидатор решает, ошибка это или допустимое отсутствие, и только валидатор имеет право подставлять дефолт (как мы сделали с category = "other").

Ошибка №4: проверки “бизнес‑правил” до проверки типов и наличия.
Если сначала писать if (amount <= 0) до того, как вы вообще убедились, что amount — это число, вы получите либо кашу из условий, либо исключение. Стабильный порядок обычно такой: структура → наличие → тип → конверсия → ограничения значений. Так сообщения об ошибках будут понятными и повторяемыми.

Ошибка №5: возврат Boolean вместо результата с причиной.
false не объясняет, что исправлять. Сообщение в Error — это не роскошь, а минимальная вежливость по отношению к будущему вам, который через неделю будет разбирать чужой JSON в два часа ночи и шептать: «кто это написал… ах да, я».

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