Валидация с Kora¶
В этом руководстве рассматривается валидация запросов для HTTP API на Kora. Вы узнаете, как аннотации ограничений описывают допустимый вход, как @Validate включает сгенерированные валидаторы на
границах контроллера и как ошибки валидации превращаются в предсказуемые HTTP-ошибки. Вы также увидите, как валидация удерживает правила DTO рядом с данными, которые они защищают, оставляя код
сервисов и репозиториев сосредоточенным на поведении приложения.
Если в процессе захочется сверить результат, используйте готовое рабочее приложение: Kora Java Validation App.
Если в процессе захочется сверить результат, используйте готовое рабочее приложение: Kora Kotlin Validation App.
Что вы создадите¶
Вы расширите существующий HTTP-сервер:
- валидацией тела запроса для
createUserиupdateUser - валидацией path-параметра
userId - валидацией query-параметров
page,sizeиsort - валидацией методов на основе AOP через
@Validate - структурированными JSON-ответами при ошибках валидации
Что вам понадобится¶
- JDK 25 или новее
- Gradle 9+
- текстовый редактор или среда разработки
- пройденное руководство HTTP-сервер
Требования¶
Обязательная основа
Это руководство предполагает, что вы прошли HTTP-сервер и у вас уже есть готовое CRUD-приложение с UserController, UserService, UserRepository и InMemoryUserRepository.
Если вы еще не прошли руководство по HTTP-серверу, сначала сделайте это, потому что валидация полезнее всего тогда, когда тело запроса, path-параметры, query-параметры и поток сервиса уже существуют.
Обзор¶
Валидация защищает границу между внешним входом и поведением приложения. Контроллер может десериализовать JSON в DTO, но десериализация доказывает лишь то, что у полезной нагрузки правильная общая форма. Она не доказывает, что email выглядит как email, что имя не пустое, что размер страницы в допустимых пределах или что path-параметр имеет ожидаемый формат.
Без валидации приложение принимает плохой вход и оставляет обнаружение проблемы более глубоким слоям. Обычно это дает менее внятные ошибки, более оборонительный код сервисов и правила данных, разбросанные по всей кодовой базе. С валидацией API может отклонить неверный вход рано и вернуть ответ, который явно относится к запросу клиента.
Валидация Kora — это не Jakarta Bean Validation
Kora поставляет собственный API валидации в io.koraframework.validation.common.annotation. Имена аннотаций намеренно выглядят знакомо, но это собственные типы Kora, они обеспечиваются
сгенерированным на этапе компиляции кодом, а не рефлексивным рантаймом, и они не взаимозаменяемы с jakarta.validation.constraints. Импорт одноименной Jakarta-аннотации дает класс, который
компилируется и молча ничего не валидирует.
Как валидация вписывается в HTTP API¶
В слоеном HTTP-приложении валидация обычно защищает границу, через которую внешний вход попадает в систему.
Это значит:
- контроллер валидирует тела запросов, path-параметры и query-параметры
- сервис продолжает заниматься бизнес-логикой
- репозиторий продолжает заниматься хранением
Такое разделение полезно, потому что неверный HTTP-вход обычно нужно отклонять до того, как он дойдет до более глубоких слоев. Оно же делает правила валидации проще для поиска и понимания.
Kora поддерживает здесь два стиля:
- декларативную валидацию через аннотации вроде
@Validи@Validate - императивное использование через внедрение сгенерированного
Validator<T>и самостоятельный вызовvalidate(...)илиvalidateAndThrow(...), как описано в Ручной валидации
В этом руководстве мы используем декларативный подход на контроллере, потому что он — самое естественное продолжение http-server.md.
Валидация на границе¶
Лучшее место для базовой валидации входа — граница API. Если неверные данные отклоняются до того, как дойдут до слоя сервиса, остальная часть приложения может работать с более сильными допущениями. В этом руководстве валидация появляется в трех местах:
- DTO тела запроса, где можно ограничить поля вроде
nameиemail - path-параметры, где можно проверить значения маршрута вроде
userId - query-параметры, где можно ограничить вход постраничной выдачи и сортировки
Это не заменяет бизнес-валидацию. Правило DTO может сказать «email должен быть синтаксически корректным», а правило сервиса — «этот email должен быть уникальным». Это разные слои валидации.
Аннотации ограничений¶
Набор заметно шире тех четырех ограничений, которые понадобились этому руководству. Все они живут в io.koraframework.validation.common.annotation и могут стоять на поле, методе или параметре метода:
| Группа | Аннотации |
|---|---|
| Текст | @NotBlank, @Pattern, @Size, @OneOf, @UUID, @Uri, @Url |
| Числа | @Min, @Max, @Range, @Positive, @PositiveOrZero, @Negative, @NegativeOrZero, @Digits |
| Время | @Past, @PastOrPresent, @Future, @FutureOrPresent |
| Логические | @AssertTrue, @AssertFalse |
| Коллекции | @NotEmpty, @Size |
| Структура | @Valid, @Validate, @ValidatedBy |
@Valid и @Validate — не ограничения: @Valid говорит «спустись внутрь этого типа и примени его собственные правила», а @Validate включает валидацию методов. @ValidatedBy — точка расширения,
через которую вы строите собственное ограничение поверх своей ValidatorFactory. Полный справочник, включая точные сообщения о нарушениях для каждого ограничения, — в
Аннотациях валидации.
Сгенерированная валидация и @Validate¶
Полные правила для сгенерированных валидаторов, валидации классов и валидации методов описаны в Валидации класса и Валидации метода.
Валидация Kora использует аннотации для описания ограничений и сгенерированный код для их применения. @Valid на типе генерирует реализацию Validator<T> для него, @Validate включает валидацию
методов, а модуль валидации добавляет необходимые компоненты графа. Поскольку обвязка валидации генерируется, отсутствующие валидаторы или неподдерживаемые формы обнаруживаются на этапе сборки, а не
только после того, как плохой запрос доберется до промышленной среды.
Это руководство также заглядывает в сгенерированный AOP-код, чтобы вы видели, где валидация действительно выполняется. Это важно, потому что валидация — не магия, спрятанная внутри разбора JSON. Это сгенерированная граничная проверка вокруг методов контроллера.
Практический порядок такой:
- включить модуль валидации в граф Kora
- добавить ограничения в DTO запросов
- включить валидацию методов через
@Validate - провалидировать тело, path- и query-вход
- изучить сгенерированную обертку валидации
- отобразить ошибки валидации на стабильный JSON-ответ
Контракты ошибок¶
Ошибки валидации — это клиентские ошибки, но клиентам нужно больше, чем сырое сообщение исключения. Полезный API возвращает предсказуемую форму ответа, которая говорит клиенту, какой вход не прошел и почему. Последняя часть этого руководства добавляет JSON-контракт ошибок, чтобы ошибки валидации стали частью публичного HTTP-поведения, а не случайным выводом фреймворка.
Зависимости¶
Валидация в этом руководстве опирается на несколько совместно работающих модулей Kora:
validation-moduleвключает генерацию валидаторов, валидацию методов и HTTP-перехватчик, превращающий нарушения в ответыhttp-server-undertowпубликует контроллер как HTTP-эндпоинтыjson-commonсериализует DTO запросов и ответовconfig-hoconиlogging-logbackдают стандартную рантайм-обвязку, используемую во всех руководствах
Для более широкого контекста смотрите документацию Kora по Валидации, HTTP-серверу и JSON.
Обновите build.gradle:
dependencies {
// ... existing dependencies from http-server.md ...
implementation("io.koraframework:config-hocon")
implementation("io.koraframework:http-server-undertow")
implementation("io.koraframework:json-common")
implementation("io.koraframework:logging-logback")
implementation("io.koraframework:validation-module")
}
Обновите build.gradle.kts:
dependencies {
// ... existing dependencies from http-server.md ...
implementation("io.koraframework:config-hocon")
implementation("io.koraframework:http-server-undertow")
implementation("io.koraframework:json-common")
implementation("io.koraframework:logging-logback")
implementation("io.koraframework:validation-module")
}
Артефакт валидации один, а не два. validation-module приносит с собой validation-common, а генератор кода живет в артефакте annotation-processors / symbol-processors, который вы уже
подключили.
Модули¶
Прежде чем заработают любые аннотации валидации, графу приложения нужен ValidationModule.
На этом шаге мы включаем только сам модуль. Собственную HTTP-обработку ошибок валидации мы добавим позже, когда сам поток валидации уже станет понятен.
Обновите src/main/java/io/koraframework/guide/validation/Application.java:
package io.koraframework.guide.validation;
import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.config.hocon.HoconConfigModule;
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule;
import io.koraframework.json.common.JsonModule;
import io.koraframework.logging.logback.LogbackModule;
import io.koraframework.validation.module.ValidationModule;
@KoraApp
public interface Application extends
HoconConfigModule,
JsonModule,
LogbackModule,
ValidationModule, // <----- Connected module
UndertowPublicHttpServerModule {
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
Обновите src/main/kotlin/io/koraframework/guide/validation/Application.kt:
package io.koraframework.guide.validation
import io.koraframework.application.graph.KoraApplication
import io.koraframework.common.annotation.KoraApp
import io.koraframework.config.hocon.HoconConfigModule
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule
import io.koraframework.json.common.JsonModule
import io.koraframework.logging.logback.LogbackModule
import io.koraframework.validation.module.ValidationModule
@KoraApp
interface Application :
HoconConfigModule,
JsonModule,
LogbackModule,
ValidationModule, // <----- Connected module
UndertowPublicHttpServerModule
fun main() {
KoraApplication.run(ApplicationGraph::graph)
}
Валидация модели¶
Проще всего начать с того же тела запроса, которое уже используют createUser и updateUser.
Это валидация объекта. Вместо того чтобы проверять каждое JSON-поле прямо в методе контроллера, мы описываем правила один раз внутри UserRequest.
В этом руководстве:
nameдолжно присутствовать, быть непустым и разумного размераemailдолжен присутствовать и соответствовать простому шаблону email
Это дает хороший первый пример валидации DTO без изменения общего CRUD-дизайна из предыдущего руководства.
Создайте или обновите src/main/java/io/koraframework/guide/validation/dto/UserRequest.java:
package io.koraframework.guide.validation.dto;
import io.koraframework.json.common.annotation.Json;
import io.koraframework.validation.common.annotation.NotBlank;
import io.koraframework.validation.common.annotation.Valid;
import io.koraframework.validation.common.annotation.Pattern;
import io.koraframework.validation.common.annotation.Size;
@Json
@Valid
public record UserRequest(
@NotBlank @Size(min = 2, max = 100) String name,
@NotBlank @Pattern("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") String email
) {}
Создайте или обновите src/main/kotlin/io/koraframework/guide/validation/dto/UserRequest.kt:
package io.koraframework.guide.validation.dto
import io.koraframework.json.common.annotation.Json
import io.koraframework.validation.common.annotation.NotBlank
import io.koraframework.validation.common.annotation.Pattern
import io.koraframework.validation.common.annotation.Size
import io.koraframework.validation.common.annotation.Valid
@Json
@Valid
data class UserRequest(
@field:NotBlank
@field:Size(min = 2, max = 100)
val name: String,
@field:NotBlank
@field:Pattern("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
val email: String
)
Именно @Valid на самом типе заставляет обработчик аннотаций выпустить компонент Validator<UserRequest> с именем $UserRequest_Validator. Без него ограничения полей бездействуют: валидатор никто
не генерирует, и в графе нечего внедрить, чтобы их выполнить.
В Kotlin обязателен use-site target @field:. Голая @NotBlank на свойстве конструктора попадет на параметр конструктора, а не на поле, и обработчик ее не увидит.
Обратите внимание, что на этом шаге мы только описали правила. Их еще нужно применить на границе контроллера, чем мы и займемся дальше.
Валидация контроллера¶
Связка @Valid и @Validate опирается на правила из Валидации класса и Валидации метода.
Теперь мы подключаем эти правила DTO к настоящим HTTP-эндпоинтам из http-server.md.
Здесь важнее всего две аннотации:
@Validна параметре говорит, что аргумент-составной объект нужно провалидировать сгенерированным валидатором этого DTO@Validateвключает валидацию на уровне самого метода контроллера
@Validate важна, потому что она велит Kora сгенерировать логику валидации вокруг вызова метода. @Valid важна, потому что она велит этой сгенерированной логике спуститься внутрь объекта
UserRequest и провалидировать его поля.
Обновите методы POST и PUT в src/main/java/io/koraframework/guide/validation/controller/UserController.java:
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
@Validate
public HttpResponseEntity<UserResponse> createUser(@Valid @Json UserRequest request) {
UserResponse user = userService.createUser(request);
return HttpResponseEntity.of(201, HttpHeaders.of(), user);
}
@HttpRoute(method = HttpMethod.PUT, path = "/users/{userId}")
@Json
@Validate
public HttpResponseEntity<UserResponse> updateUser(
@Path String userId,
@Valid @Json UserRequest request) {
UserResponse updated = userService.updateUser(userId, request);
return HttpResponseEntity.of(200, HttpHeaders.of("X-Updated-At", Instant.now().toString()), updated);
}
Обновите те же методы в src/main/kotlin/io/koraframework/guide/validation/controller/UserController.kt:
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
@Validate
open fun createUser(@Valid @Json request: UserRequest): HttpResponseEntity<UserResponse> {
val user = userService.createUser(request)
return HttpResponseEntity.of(201, HttpHeaders.of(), user)
}
@HttpRoute(method = HttpMethod.PUT, path = "/users/{userId}")
@Json
@Validate
open fun updateUser(
@Path userId: String,
@Valid @Json request: UserRequest
): HttpResponseEntity<UserResponse> {
val updated = userService.updateUser(userId, request)
return HttpResponseEntity.of(200, HttpHeaders.of("X-Updated-At", Instant.now().toString()), updated)
}
На этом этапе:
- некорректный JSON по-прежнему падает на этапе разбора JSON
- корректный JSON с неверными значениями полей теперь падает на этапе валидации
- корректный JSON продолжает идти в тот же поток сервиса и репозитория, который вы построили раньше
По умолчанию @Validate собирает все нарушения перед тем, как выбросить исключение. Если вы предпочитаете останавливаться на первом, используйте @Validate(failFast = true): сгенерированный код тогда
выбрасывает исключение, как только не прошло одно ограничение, — это дешевле, но сообщает лишь об одной проблеме на запрос.
После компиляции сгенерированный AOP-прокси показывает, как @Valid делегирует сгенерированному валидатору UserRequest до вызова метода контроллера:
guides/java/kora-java-guide-validation-app/build/generated/sources/annotationProcessor/java/main/io/koraframework/guide/validation/controller/$UserController__AopProxy.java
private HttpResponseEntity<UserResponse> _createUser_AopProxy_ValidateMethodKoraAspect(UserRequest request) {
var _argCtx = ValidationContext.builder().failFast(false).build();
var _argViolations = new ArrayList<Violation>();
if (request == null) {
var _argCtx_request = _argCtx.addPath("request");
_argViolations.add(_argCtx_request.violates("Parameter 'request' must be non null, but was null"));
} else {
var _argCtx_request = _argCtx.addPath("request");
var _argValidatorResult_request_1 = validator6.validate(request, _argCtx_request);
if (!_argValidatorResult_request_1.isEmpty()) {
_argViolations.addAll(_argValidatorResult_request_1);
}
}
if (!_argViolations.isEmpty()) {
throw new ViolationException(_argViolations);
}
return super.createUser(request);
}
guides/kotlin/kora-kotlin-guide-validation-app/build/generated/ksp/main/kotlin/io/koraframework/guide/validation/controller/$UserController__AopProxy.kt
private fun _createUser_AopProxy_ValidateMethodKoraAspect(request: UserRequest):
HttpResponseEntity<UserResponse> {
val _argsContext = ValidationContext.full()
val _argsViolations = mutableListOf<Violation>()
val _argsContext_request = _argsContext.addPath("request")
_argsViolations.addAll(validator6.validate(request, _argsContext_request))
if (_argsViolations.isNotEmpty()) {
throw ViolationException(_argsViolations)
}
val _result = super.createUser(request)
return _result
}
Важная деталь: validator6.validate(request, ...) выполняется до super.createUser(request), поэтому неверные поля DTO никогда не доходят до тела вашего контроллера. validator6 — это внедренный
$UserRequest_Validator; нумерация лишь отражает порядок, в котором прокси выделил поля валидаторов.
Path-параметры¶
Тела запросов — не единственный источник неверного входа. Path-параметры тоже могут быть неправильными.
В этом руководстве userId приходит из репозитория в памяти, который использует числовые строковые идентификаторы вроде 1, 2 и 3. Так что это допущение можно выразить в контроллере явно:
@NotBlankотклоняет пустые идентификаторы@Pattern("^\\d+$")говорит, что значение пути должно состоять только из цифр
Это валидация аргументов метода, а не DTO. Она полезна, когда данные простые и не оправдывают создание отдельного объекта только ради валидации.
Обновите методы GET, PUT и DELETE в src/main/java/io/koraframework/guide/validation/controller/UserController.java:
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
@Validate
public UserResponse getUser(@Path @NotBlank @Pattern("^\\d+$") String userId) {
return userService.getUser(userId)
.orElseThrow(() -> HttpServerResponseException.of(404, "User not found"));
}
@HttpRoute(method = HttpMethod.PUT, path = "/users/{userId}")
@Json
@Validate
public HttpResponseEntity<UserResponse> updateUser(
@Path @NotBlank @Pattern("^\\d+$") String userId,
@Valid @Json UserRequest request) {
UserResponse updated = userService.updateUser(userId, request);
return HttpResponseEntity.of(200, HttpHeaders.of("X-Updated-At", Instant.now().toString()), updated);
}
@HttpRoute(method = HttpMethod.DELETE, path = "/users/{userId}")
@Validate
public HttpServerResponse deleteUser(@Path @NotBlank @Pattern("^\\d+$") String userId) {
userService.deleteUser(userId);
return HttpServerResponse.of(204, HttpBody.empty());
}
Обновите те же методы в src/main/kotlin/io/koraframework/guide/validation/controller/UserController.kt:
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
@Validate
open fun getUser(@Path @NotBlank @Pattern("^\\d+$") userId: String): UserResponse {
return userService.getUser(userId)
?: throw HttpServerResponseException.of(404, "User not found")
}
@HttpRoute(method = HttpMethod.PUT, path = "/users/{userId}")
@Json
@Validate
open fun updateUser(
@Path @NotBlank @Pattern("^\\d+$") userId: String,
@Valid @Json request: UserRequest
): HttpResponseEntity<UserResponse> {
val updated = userService.updateUser(userId, request)
return HttpResponseEntity.of(200, HttpHeaders.of("X-Updated-At", Instant.now().toString()), updated)
}
@HttpRoute(method = HttpMethod.DELETE, path = "/users/{userId}")
@Validate
open fun deleteUser(@Path @NotBlank @Pattern("^\\d+$") userId: String): HttpServerResponse {
userService.deleteUser(userId)
return HttpServerResponse.of(204, HttpBody.empty())
}
Ограничениям на параметрах не нужен target @field:, который понадобился свойствам DTO: здесь аннотация уже стоит на параметре, а это один из целевых элементов, объявленных каждым ограничением Kora.
Такая валидация особенно полезна для переменных пути, заголовков, cookie и других простых параметров, которым не место внутри DTO запроса.
После компиляции сгенерированный прокси показывает, как ограничения path-параметра превращаются в обычные вызовы валидаторов:
guides/java/kora-java-guide-validation-app/build/generated/sources/annotationProcessor/java/main/io/koraframework/guide/validation/controller/$UserController__AopProxy.java
private UserResponse _getUser_AopProxy_ValidateMethodKoraAspect(String userId) {
var _argCtx = ValidationContext.builder().failFast(false).build();
var _argViolations = new ArrayList<Violation>();
if (userId == null) {
var _argCtx_userId = _argCtx.addPath("userId");
_argViolations.add(_argCtx_userId.violates("Parameter 'userId' must be non null, but was null"));
} else {
var _argCtx_userId = _argCtx.addPath("userId");
var _argConstResult_userId_1 = validator1.validate(userId, _argCtx_userId);
if (!_argConstResult_userId_1.isEmpty()) {
_argViolations.addAll(_argConstResult_userId_1);
}
var _argConstResult_userId_2 = validator2.validate(userId, _argCtx_userId);
if (!_argConstResult_userId_2.isEmpty()) {
_argViolations.addAll(_argConstResult_userId_2);
}
}
if (!_argViolations.isEmpty()) {
throw new ViolationException(_argViolations);
}
return super.getUser(userId);
}
guides/kotlin/kora-kotlin-guide-validation-app/build/generated/ksp/main/kotlin/io/koraframework/guide/validation/controller/$UserController__AopProxy.kt
private fun _getUser_AopProxy_ValidateMethodKoraAspect(userId: String): UserResponse {
val _argsContext = ValidationContext.full()
val _argsViolations = mutableListOf<Violation>()
val _argsContext_userId = _argsContext.addPath("userId")
_argsViolations.addAll(validator1.validate(userId, _argsContext_userId))
_argsViolations.addAll(validator2.validate(userId, _argsContext_userId))
if (_argsViolations.isNotEmpty()) {
throw ViolationException(_argsViolations)
}
val _result = super.getUser(userId)
return _result
}
Это делает границу метода наглядной: Kora сначала валидирует userId, а затем делегирует вашей исходной реализации getUser(...). Каждое ограничение становится отдельным вызовом validate(...),
поэтому две аннотации на одном параметре дают _argConstResult_userId_1 и _argConstResult_userId_2.
Query-параметры¶
Следующая частая цель валидации — строка запроса.
Наш эндпоинт GET /users уже поддерживает постраничную выдачу и сортировку. Это делает его хорошим местом для демонстрации валидации параметров метода для необязательных значений:
pageнеобязателен, но если присутствует, должен быть не меньше0sizeнеобязателен, но если присутствует, должен оставаться в безопасном диапазонеsortнеобязателен, но если присутствует, должен быть одним из поддерживаемых полей сортировки
Такая валидация защищает API от неверных запросов постраничной выдачи до того, как выполнится любая бизнес-логика или логика хранения.
Обновите getUsers в src/main/java/io/koraframework/guide/validation/controller/UserController.java:
@HttpRoute(method = HttpMethod.GET, path = "/users")
@Json
@Validate
public List<UserResponse> getUsers(
@Nullable @Range(from = 0, to = 1_000) @Query("page") Integer page,
@Nullable @Range(from = 1, to = 100) @Query("size") Integer size,
@Nullable @Pattern("^(?i)(name|email|createdat)$") @Query("sort") String sort) {
int pageNum = page == null ? 0 : page;
int pageSize = size == null ? 10 : size;
String sortBy = sort == null ? "name" : sort;
return userService.getUsers(pageNum, pageSize, sortBy);
}
Обновите getUsers в src/main/kotlin/io/koraframework/guide/validation/controller/UserController.kt:
@HttpRoute(method = HttpMethod.GET, path = "/users")
@Json
@Validate
open fun getUsers(
@Query("page") @Range(from = 0.0, to = 1_000.0) page: Int?,
@Query("size") @Range(from = 1.0, to = 100.0) size: Int?,
@Query("sort") @Pattern("^(?i)(name|email|createdat)$") sort: String?
): List<UserResponse> {
val pageNum = page ?: 0
val pageSize = size ?: 10
val sortBy = sort ?: "name"
return userService.getUsers(pageNum, pageSize, sortBy)
}
@Range объявляет from и to как double. Java расширяет целочисленные литералы за вас, а Kotlin — нет, поэтому в версии на Kotlin написано 0.0 и 1_000.0. У @Range есть еще атрибут
boundary (по умолчанию INCLUSIVE_INCLUSIVE, плюс три другие комбинации), когда границу нужно исключить.
Необязательными эти параметры делает допустимость null. В Java параметр помечен @Nullable, в Kotlin у него тип Int?. Сгенерированный код проверяет null перед валидацией, так что пропущенный
query-параметр никогда не становится нарушением.
После этого шага руководство охватывает три разные цели валидации в отдельных главах:
- составные JSON-объекты
- простые path-параметры
- простые query-параметры
Такое разделение полезно, потому что каждый вид входа в реальных API развивается по-своему.
После компиляции сгенерированный прокси показывает, что необязательные query-параметры валидируются только при наличии значения:
guides/java/kora-java-guide-validation-app/build/generated/sources/annotationProcessor/java/main/io/koraframework/guide/validation/controller/$UserController__AopProxy.java
private List<UserResponse> _getUsers_AopProxy_ValidateMethodKoraAspect(Integer page, Integer size, String sort) {
var _argCtx = ValidationContext.builder().failFast(false).build();
var _argViolations = new ArrayList<Violation>();
if (page != null) {
var _argCtx_page = _argCtx.addPath("page");
var _argConstResult_page_1 = validator3.validate(page, _argCtx_page);
if (!_argConstResult_page_1.isEmpty()) {
_argViolations.addAll(_argConstResult_page_1);
}
}
if (size != null) {
var _argCtx_size = _argCtx.addPath("size");
var _argConstResult_size_1 = validator4.validate(size, _argCtx_size);
if (!_argConstResult_size_1.isEmpty()) {
_argViolations.addAll(_argConstResult_size_1);
}
}
if (sort != null) {
var _argCtx_sort = _argCtx.addPath("sort");
var _argConstResult_sort_1 = validator5.validate(sort, _argCtx_sort);
if (!_argConstResult_sort_1.isEmpty()) {
_argViolations.addAll(_argConstResult_sort_1);
}
}
if (!_argViolations.isEmpty()) {
throw new ViolationException(_argViolations);
}
return super.getUsers(page, size, sort);
}
guides/kotlin/kora-kotlin-guide-validation-app/build/generated/ksp/main/kotlin/io/koraframework/guide/validation/controller/$UserController__AopProxy.kt
private fun _getUsers_AopProxy_ValidateMethodKoraAspect(
page: Int?,
size: Int?,
sort: String?,
): List<UserResponse> {
val _argsContext = ValidationContext.full()
val _argsViolations = mutableListOf<Violation>()
if(page != null) {
val _argsContext_page = _argsContext.addPath("page")
_argsViolations.addAll(validator3.validate(page, _argsContext_page))
}
if(size != null) {
val _argsContext_size = _argsContext.addPath("size")
_argsViolations.addAll(validator4.validate(size, _argsContext_size))
}
if(sort != null) {
val _argsContext_sort = _argsContext.addPath("sort")
_argsViolations.addAll(validator5.validate(sort, _argsContext_sort))
}
if (_argsViolations.isNotEmpty()) {
throw ViolationException(_argsViolations)
}
val _result = super.getUsers(page, size, sort)
return _result
}
Этот сгенерированный код точно объясняет поведение с необязательными значениями: null означает «параметр не передан», а присутствующее значение проверяется своим ограничением.
Сгенерированный код¶
Валидация порождает два вида сгенерированных исходников, и их полезно различать.
@Valid на типе порождает класс-валидатор. Для UserRequest это $UserRequest_Validator — Validator<UserRequest>, опубликованный в графе. Это обычный компонент: его можно внедрить куда угодно
и вызвать validate(value) или validateAndThrow(value) вообще без участия AOP.
@Validate на методе порождает AOP-прокси. Kora не меняет исходник вашего контроллера напрямую. Вместо этого она генерирует класс-наследник вокруг валидируемого компонента и помещает логику
валидации в этот сгенерированный класс. Ваш код по-прежнему выглядит просто, но сгенерированный прокси выполняет проверки до того, как вызов дойдет до тела метода.
Именно поэтому:
- валидируемые Java-классы не должны быть
final - валидируемые Kotlin-классы должны быть
open - валидируемые Kotlin-методы тоже должны быть
open
После компиляции сгенерированный исходник можно посмотреть здесь:
Этот файл — самое простое место, чтобы увидеть реальный поток валидации. Вы обнаружите, что Kora:
- читает входящие аргументы метода
- валидирует простые параметры метода вроде
userId,page,sizeиsort - валидирует вложенные объекты вроде
UserRequest - выбрасывает
ViolationException, когда правила не выполнены - вызывает ваш исходный метод контроллера только при успешной валидации
В предыдущих главах сгенерированные фрагменты показывались рядом с той целью валидации, которая их породила: валидация DTO тела, валидация path-параметров и валидация query-параметров. Урок во всех
случаях один: валидация происходит до логики вашего контроллера, а вызов super... появляется только после того, как нарушения собраны. Этот сгенерированный код — еще и хорошая цель для отладки
AI-ассистентами, потому что он раскрывает конкретные валидаторы и имена параметров, которые Kora вывела из ваших аннотаций.
Это помогает, когда вы учитесь, отлаживаете или просто хотите подтвердить, что именно фреймворк вам сгенерировал. Более широкие подробности — в документации Kora по Валидации и Контейнеру.
Обработка ошибок валидации¶
Настройка HTTP-ответа здесь связывает валидацию с общими правилами обработки ошибок HTTP-сервера и полностью описана в разделе HTTP-ответ валидации.
Пока что валидация работает, но опыт HTTP-клиента можно улучшить.
ValidationModule уже добавляет ValidationHttpServerInterceptor как @DefaultComponent, и этот перехватчик уже превращает ViolationException в 400 с сообщением исключения. Чего он не делает —
так это не привязывает себя к серверу и не формирует машиночитаемое тело. В реальном API обычно лучше возвращать стабильный JSON-контракт ошибок, который клиенты могут разобрать и показать.
Kora дает здесь гибкость. Такую обработку можно определить только для выбранных эндпоинтов или зарегистрировать глобально для всего HTTP-приложения. В этом руководстве мы используем глобальный подход, потому что так проще всего сохранить согласованность всех контроллеров.
Мы добавим:
ValidationErrorDetailsиValidationErrorResponseкак явные JSON-DTOViolationExceptionHttpServerResponseMapper, чтобы превратитьViolationExceptionв это DTOValidationHttpServerInterceptorс меткой@Tag(HttpServer.class), чтобы применить это отображение в HTTP-конвейере
Создайте src/main/java/io/koraframework/guide/validation/dto/ValidationErrorDetails.java:
package io.koraframework.guide.validation.dto;
import io.koraframework.json.common.annotation.Json;
@Json
public record ValidationErrorDetails(String field, String message) {}
Создайте src/main/java/io/koraframework/guide/validation/dto/ValidationErrorResponse.java:
package io.koraframework.guide.validation.dto;
import java.util.List;
import io.koraframework.json.common.annotation.Json;
@Json
public record ValidationErrorResponse(String code, String message, List<ValidationErrorDetails> errors) {
public static ValidationErrorResponse of(List<ValidationErrorDetails> errors) {
return new ValidationErrorResponse("VALIDATION_ERROR", "Validation failed", errors);
}
}
Обновите src/main/java/io/koraframework/guide/validation/Application.java:
package io.koraframework.guide.validation;
import java.util.List;
import java.util.stream.Collectors;
import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.common.annotation.Tag;
import io.koraframework.config.hocon.HoconConfigModule;
import io.koraframework.guide.validation.dto.ValidationErrorDetails;
import io.koraframework.guide.validation.dto.ValidationErrorResponse;
import io.koraframework.http.common.body.HttpBody;
import io.koraframework.http.server.common.HttpServer;
import io.koraframework.http.server.common.response.HttpServerResponse;
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule;
import io.koraframework.json.common.JsonWriter;
import io.koraframework.json.common.JsonModule;
import io.koraframework.logging.logback.LogbackModule;
import io.koraframework.validation.common.Violation;
import io.koraframework.validation.module.ValidationModule;
import io.koraframework.validation.module.http.server.ValidationHttpServerInterceptor;
import io.koraframework.validation.module.http.server.ViolationExceptionHttpServerResponseMapper;
@KoraApp
public interface Application extends
HoconConfigModule,
JsonModule,
LogbackModule,
ValidationModule, // <----- Connected module
UndertowPublicHttpServerModule {
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
default ViolationExceptionHttpServerResponseMapper violationExceptionHttpServerResponseMapper(
JsonWriter<ValidationErrorResponse> errorResponseJsonWriter) {
return (request, exception) -> HttpServerResponse.of(
400,
HttpBody.json(errorResponseJsonWriter.toByteArray(
ValidationErrorResponse.of(toValidationErrors(exception.getViolations())))));
}
@Tag(HttpServer.class)
default ValidationHttpServerInterceptor validationHttpServerInterceptor(
ViolationExceptionHttpServerResponseMapper violationExceptionHttpServerResponseMapper) {
return new ValidationHttpServerInterceptor(violationExceptionHttpServerResponseMapper);
}
private static List<ValidationErrorDetails> toValidationErrors(List<Violation> violations) {
return violations.stream()
.map(violation -> new ValidationErrorDetails(normalizeField(violation), violation.message()))
.collect(Collectors.toList());
}
private static String normalizeField(Violation violation) {
String fullPath = violation.path().full();
int lastDot = fullPath.lastIndexOf('.');
return lastDot >= 0 ? fullPath.substring(lastDot + 1) : fullPath;
}
}
Создайте src/main/kotlin/io/koraframework/guide/validation/dto/ValidationErrorDetails.kt:
package io.koraframework.guide.validation.dto
import io.koraframework.json.common.annotation.Json
@Json
data class ValidationErrorDetails(
val field: String,
val message: String
)
Создайте src/main/kotlin/io/koraframework/guide/validation/dto/ValidationErrorResponse.kt:
package io.koraframework.guide.validation.dto
import io.koraframework.json.common.annotation.Json
@Json
data class ValidationErrorResponse(
val code: String,
val message: String,
val errors: List<ValidationErrorDetails>
) {
companion object {
fun of(errors: List<ValidationErrorDetails>): ValidationErrorResponse {
return ValidationErrorResponse(
code = "VALIDATION_ERROR",
message = "Validation failed",
errors = errors
)
}
}
}
Обновите src/main/kotlin/io/koraframework/guide/validation/Application.kt:
package io.koraframework.guide.validation
import io.koraframework.application.graph.KoraApplication
import io.koraframework.common.annotation.KoraApp
import io.koraframework.common.annotation.Tag
import io.koraframework.config.hocon.HoconConfigModule
import io.koraframework.guide.validation.dto.ValidationErrorDetails
import io.koraframework.guide.validation.dto.ValidationErrorResponse
import io.koraframework.http.common.body.HttpBody
import io.koraframework.http.server.common.HttpServer
import io.koraframework.http.server.common.response.HttpServerResponse
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule
import io.koraframework.json.common.JsonWriter
import io.koraframework.json.common.JsonModule
import io.koraframework.logging.logback.LogbackModule
import io.koraframework.validation.common.Violation
import io.koraframework.validation.module.ValidationModule
import io.koraframework.validation.module.http.server.ValidationHttpServerInterceptor
import io.koraframework.validation.module.http.server.ViolationExceptionHttpServerResponseMapper
@KoraApp
interface Application :
HoconConfigModule,
JsonModule,
LogbackModule,
ValidationModule, // <----- Connected module
UndertowPublicHttpServerModule {
fun violationExceptionHttpServerResponseMapper(
errorResponseJsonWriter: JsonWriter<ValidationErrorResponse>
): ViolationExceptionHttpServerResponseMapper {
return ViolationExceptionHttpServerResponseMapper { _, exception ->
HttpServerResponse.of(
400,
HttpBody.json(
errorResponseJsonWriter.toByteArray(
ValidationErrorResponse.of(toValidationErrors(exception.violations))
)
)
)
}
}
// the module default is untagged, so it is overridden only to bind the interceptor to the server;
// 2.0 declares the mapper parameter as @Nullable, which Kotlin enforces on the override
@Tag(HttpServer::class)
override fun validationHttpServerInterceptor(
violationExceptionHttpServerResponseMapper: ViolationExceptionHttpServerResponseMapper?
): ValidationHttpServerInterceptor {
return ValidationHttpServerInterceptor(violationExceptionHttpServerResponseMapper)
}
private fun toValidationErrors(violations: List<Violation>): List<ValidationErrorDetails> {
return violations.map { violation ->
ValidationErrorDetails(normalizeField(violation), violation.message())
}
}
private fun normalizeField(violation: Violation): String {
val fullPath = violation.path().full()
val lastDot = fullPath.lastIndexOf('.')
return if (lastDot >= 0) fullPath.substring(lastDot + 1) else fullPath
}
}
fun main() {
KoraApplication.run(ApplicationGraph::graph)
}
В этой обвязке стоит выделить три детали.
Метка — это HttpServer из io.koraframework.http.server.common, маркер, по которому модуль Undertow собирает глобальные перехватчики. Непомеченный ValidationHttpServerInterceptor компилируется и
собирается нормально, но никогда не выполняется — ровно таков и собственный @DefaultComponent модуля: доступен, но ни к какому серверу не подключен.
ViolationExceptionHttpServerResponseMapper — функциональный интерфейс, возвращающий @Nullable HttpServerResponse. Возврат null из него — осознанный отказ от обработки этого запроса: перехватчик
откатывается к простому 400 с сообщением исключения.
Violation.path().full() дает полный точечный путь к неверному значению, например request.email. В этом примере он обрезается до последнего сегмента, чтобы в JSON попало email; оставьте полный
путь, если вашим клиентам нужно находить значение внутри вложенного объекта.
Важное разделение здесь такое:
- AOP-валидация решает, корректен ли вызов метода
- перехватчик и маппер решают, как сбой видит HTTP-клиент
Запуск приложения¶
Используйте стандартный порядок из руководств:
Проверка приложения¶
Корректный запрос createUser:
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"John Doe","email":"john@example.com"}'
Некорректное тело запроса:
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"","email":"broken-email"}'
Ожидаемая форма ответа:
{
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [
{
"field": "name",
"message": "Should be not blank"
},
{
"field": "email",
"message": "Should match RegEx ..."
}
]
}
Некорректный path-параметр:
Ожидаемая форма ответа:
{
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [
{
"field": "userId",
"message": "Should match RegEx ..."
}
]
}
Некорректные query-параметры:
Ожидаемая форма ответа:
{
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [
{
"field": "page",
"message": "Should be in range ..."
},
{
"field": "size",
"message": "Should be in range ..."
},
{
"field": "sort",
"message": "Should match RegEx ..."
}
]
}
Лучшие практики¶
- Добавляйте валидацию на границе контроллера, когда цель — защитить HTTP-вход.
- Используйте валидацию DTO для структурированных JSON-тел и валидацию параметров метода для простых path- или query-значений.
- Импортируйте ограничения из
io.koraframework.validation.common.annotation, но не изjakarta.validation.constraints. Имена пересекаются, поведение — нет. - Ставьте
@Validи на тип DTO, и на параметр: аннотация на типе генерирует валидатор, а аннотация на параметре его вызывает. - Держите
UserServiceиUserRepositoryсосредоточенными на бизнес-логике и хранении, а не дублируйте там правила HTTP-входа. - Помните, что
@Validateоснована на AOP. В Java валидируемый класс не должен бытьfinal. В Kotlin класс и валидируемые методы должны бытьopen. - Когда ошибка валидации должна стать стабильным контрактом API, определите явное DTO ошибки вместо утечки сырых исключений фреймворка.
- В Kotlin продолжайте использовать
@field:для аннотаций свойств вроде@field:NotBlank,@field:Sizeи@field:Pattern.
Итоги¶
Вы постепенно расширили CRUD-приложение из http-server.md валидацией.
Сначала вы включили ValidationModule в граф приложения. Затем провалидировали тело UserRequest, используемое createUser и updateUser. После этого провалидировали path-параметры userId и
query-параметры постраничной выдачи и сортировки в getUsers. Затем изучили сгенерированный AOP-исходник, чтобы увидеть, где на самом деле выполняется валидация методов. Наконец, вы ввели глобальную
стратегию отображения ошибок валидации с ViolationExceptionHttpServerResponseMapper и ValidationHttpServerInterceptor с меткой @Tag(HttpServer.class).
Ключевые понятия¶
- Валидация Kora — это собственный API Kora в
io.koraframework.validation.common.annotation, генерируемый на этапе компиляции, а не Jakarta Bean Validation. ValidationModuleвключает поддержку валидации Kora в графе приложения.@Validна типе генерируетValidator<T>;@Validна параметре велит валидации метода его использовать.@Validateвключает валидацию аргументов и результата метода через сгенерированный AOP-код, а@Validate(failFast = true)останавливается на первом нарушении.- Валидация DTO и валидация параметров метода решают разные задачи и часто используются вместе.
ViolationExceptionHttpServerResponseMapperопределяет, как ошибки валидации становятся HTTP-ответами.ValidationHttpServerInterceptorприменяет этот маппер глобально, но только когда он помечен@Tag(HttpServer.class).
Устранение неполадок¶
Валидация не срабатывает:
- Убедитесь, что
ValidationModuleвключен в граф приложения. - Убедитесь, что сам метод контроллера помечен
@Validate. - Для DTO запросов убедитесь, что параметр метода помечен
@Valid, а тип DTO — тоже@Valid. - Проверьте импорты:
io.koraframework.validation.common.annotation.NotBlank, а неjakarta.validation.constraints.NotBlank. - Помните, что
@Validateработает через сгенерированный AOP-код. В Java валидируемый класс не должен бытьfinal. - В Kotlin валидируемый класс и валидируемые методы должны быть
open, а ограничениям свойств нужен target@field:.
Validator<UserRequest> not found при сборке графа:
- У типа DTO нет собственной
@Valid. Только аннотация@Validна типе заставляет обработчик выпустить$UserRequest_Validator.
Хочу увидеть, где валидация выполняется на самом деле:
- Выполните
./gradlew clean classes. - Откройте сгенерированный исходник по пути:
guides/java/kora-java-guide-validation-app/build/generated/sources/annotationProcessor/java/main/io/koraframework/guide/validation/controller/$UserController__AopProxy.java
guides/kotlin/kora-kotlin-guide-validation-app/build/generated/ksp/main/kotlin/io/koraframework/guide/validation/controller/$UserController__AopProxy.kt
- Посмотрите, как прокси валидирует аргументы, прежде чем делегировать вашему исходному методу контроллера.
HTTP возвращает простой текстовый 400 вместо JSON:
- Это работает
ValidationHttpServerInterceptorиз модуля по умолчанию, без маппера. Убедитесь, что ваш собственныйViolationExceptionHttpServerResponseMapperзарегистрирован. - Убедитесь, что перехватчик помечен
@Tag(HttpServer.class)в Java или@Tag(HttpServer::class)в Kotlin. Без метки он никогда не подключается к серверу.
Kotlin отказывается компилировать переопределение перехватчика:
ValidationModuleобъявляет параметр маппера как@Nullable, поэтому переопределение в Kotlin должно приниматьViolationExceptionHttpServerResponseMapper?.
Kotlin отвергает @Range(from = 0, to = 1_000):
fromиtoимеют типdouble. Пишите0.0и1_000.0.
Валидация выглядит корректной, но эндпоинт все равно возвращает 404:
- Обычно это значит, что валидация прошла и запрос дошел до обычной логики приложения.
- Например, в этом руководстве
updateUser("999", ...)может по-прежнему вернуть404 User not found, потому что формат пути корректен, хотя пользователя и не существует.
Сборка Gradle зависает или блокирует файлы в Windows:
- Выполните
./gradlew --stopи повторите. - Если видите
AccessDeniedExceptionна кешах Gradle или выходных каталогах сборки, закройте процессы IDE или тестов, которые могут держать файловые дескрипторы.
Что дальше?¶
- База данных JDBC или База данных Cassandra, чтобы сохранять провалидированные запросы.
- Тестирование с JUnit, чтобы протестировать валидацию и отображение ошибок на уровне компонентов.
- Черноящичное тестирование после добавления хранения, чтобы валидацию можно было проверить через собранное HTTP-приложение.
- Шаблоны отказоустойчивости, чтобы добавить отказоустойчивость на уровне сервиса вокруг провалидированных операций.
Помощь¶
Если застряли:
- сравните с Kora Java Validation App и Kora Kotlin Validation App
- посмотрите документацию по Валидации
- посмотрите документацию по HTTP-серверу
- посмотрите документацию по JSON