JavaRush /Курсы /Spring REST & MVC /Валидация page, <...

Валидация page, size, sort и taskId

Spring REST & MVC
16 уровень , 2 лекция
Открыта

1. Валидация query и path параметров

Когда мы только начинаем писать REST API, есть соблазн считать, что настоящий «вход» — это @RequestBody, а всё остальное как будто вторично: ну подумаешь, ?page= или {taskId} в пути. На практике всё наоборот: list-endpoint’ы и resource-endpoint’ы живут на query/path параметрах постоянно, и именно они чаще всего ломают сценарии раньше, чем JSON вообще появляется.

Представьте, что клиент вызывает GET /api/v1/tasks?page=-1. С точки зрения человека это выглядит как «хочу страницу минус один» — то есть как просьба открыть в книге страницу, которой не существует. Или запрос size=100000: клиент будто просит сервер «принеси мне все задачи мира, желательно за один раз, и заодно кофе». Даже если у нас пока in-memory хранилище, это всё равно контрактно неверный вход, и он не должен проходить.

То же самое с taskId в GET /api/v1/tasks/{taskId}. В проекте идентификаторы — UUID-строки. Если клиент прислал taskId=123, то это не «просто не найдено». Это не тот формат идентификатора, с которым мы вообще работаем. А значит, это категория ошибки «вход некорректен», и её лучше ловить на границе, а не в сервисе, где потом начинается цирк с парсингом и непонятными исключениями.

Важно удержать одну простую мысль: query и path параметры — часть внешнего контракта API. Если контракт говорит, что page не может быть отрицательным, а size не может быть больше 100, то это нужно выразить прямо там, где параметр объявлен. То есть в сигнатуре controller method.

2. Binding и validation: два шлюза

Очень полезно понимать, что прежде чем Spring вообще вызовет ваш метод контроллера, запрос проходит минимум два слоя «проверок». Первый слой — это привязка входных строк к Java-типам (binding + type conversion). Второй слой — Bean Validation constraints, которые вы написали аннотациями (@Min, @Max, @Pattern и т.д.). Эти два слоя похожи по эффекту (в обоих случаях вы получите 400), но по смыслу это разные проблемы.

Если запрос пришёл как ?page=abc, то Spring не может превратить строку "abc" в int. Это ошибка формата/типа: параметр не конвертируется. Ваш метод контроллера даже не запускается, потому что аргументы не удалось собрать.

Если запрос пришёл как ?page=-1, то "-1" прекрасно конвертируется в int, но ограничение @Min(0) говорит: «значение допустимого диапазона нарушено». Это уже не ошибка конвертации, а ошибка контракта.

Схематично это удобно представлять так:

flowchart LR
    %% Сначала происходит привязка и конвертация типов (binding/type conversion)
    A[HTTP запрос] --> B[Binding + type conversion]
    %% Дальше запускаются Bean Validation ограничения на параметрах/DTO
    B -->|успех| C[Bean Validation constraints]
    C -->|успех| D[Метод контроллера]
    B -->|ошибка формата| E[HTTP 400]
    C -->|нарушено ограничение| E

Практический вывод из этой схемы простой: если вы держите параметры в виде String и разбираете их вручную в контроллере, вы фактически выключаете первый шлюз и переносите его себе на плечи. А если вы не ставите constraints (@Min, @Max, @Pattern), то второй шлюз просто не на что опереть: формально корректные значения (-1, 999999) спокойно пролезут внутрь приложения.

Чтобы не превращать контроллер в мини-парсер и мини-валидатор одновременно, лучше позволить Spring сделать своё дело: он и так умеет конвертировать типы, и так умеет запускать Bean Validation — нужно лишь правильно описать ограничения.

3. Валидация параметров контроллера: не тот же механизм, что у @Valid

На этом месте многие новички делают классическую ошибку: «Я уже поставил @Valid на @RequestBody, значит validation включена вообще для всего метода». Не совсем. @Valid отвечает за проверку объекта (DTO) и каскадную валидацию вложенных данных. Но простые параметры метода (int page, String sort, String taskId) попадают в контроллер другой дорогой: сначала конвертируются из строки, а потом уже проверяются как часть сигнатуры метода.

В более старых примерах вы легко встретите @Validated на контроллере. Полезно знать эту аннотацию, но в текущей MVC-линии не надо воспринимать её как обязательный рубильник для базовых controller snippets. Здесь важнее другое различие: @Valid — про объектный граф, а @Min / @Max / @Pattern на @RequestParam и @PathVariable — про контракт простых параметров метода.

В проекте Task Tracker API контроллеры живут в com.example.tasktracker.api.controller. Минимальный каркас TaskController здесь самый обычный:

package com.example.tasktracker.api.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController // Обычный REST-контроллер Spring MVC
@RequestMapping("/api/v1/tasks") // Базовый путь для всех эндпоинтов контроллера
public class TaskController {
}

Дальше constraints висят прямо на параметрах методов, и Spring проверяет их до входа в метод. А @Valid нам ещё понадобится для object-аргументов вроде @RequestBody и @ModelAttribute.

4. Валидация page и size

Параметры пагинации — это один из самых «эксплуатируемых» входов в API. Их вызывают постоянно, их передают руками, их передают UI, их подставляют в ссылки. И если их не ограничить на границе, то дальше вы будете ловить очень странные эффекты: пустые страницы «из прошлого», отрицательные индексы, гигантские размеры ответа и, в итоге, недовольных пользователей (или недовольный ваш же браузер).

Пока параметров у list-endpoint’а немного, полезно подержать их прямо в сигнатуре метода. Так проще изолированно увидеть, где срабатывают @Min, @Max и defaultValue. Но это именно промежуточный shape: как только к page, size и sort добавляются фильтры, такой метод уже просится в criteria DTO.

В нашем контракте Task Tracker API параметры такие: page — zero-based, default 0; size — default 20, максимальный 100. Это означает, что минимально разумные ограничения выглядят так: page >= 0, 1 <= size <= 100. Именно это и выразим через @Min и @Max.

Вот пример list-endpoint’а, где ограничения стоят прямо в сигнатуре метода:

package com.example.tasktracker.api.controller;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TaskController {

    @GetMapping("/api/v1/tasks")
    public String list(
            @RequestParam(defaultValue = "0") @Min(0) int page, // page: только 0 и больше
            @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size // size: 1..100, иначе 400
    ) {
        return "ok";
    }
}

Обратите внимание на две вещи. Во‑первых, мы принимаем page и size как int, а не как String. Это позволяет Spring’у сделать конвертацию, а нам — не писать ручной Integer.parseInt(...) и не ловить NumberFormatException в контроллере. Во‑вторых, defaultValue — строковый, но он обязан быть корректным для типа и удовлетворять constraints. Если вы случайно поставите defaultValue = "-5" и одновременно @Min(0), то вы буквально создадите эндпоинт, который сам себе противоречит.

Хорошая привычка: воспринимать defaultValue как часть контракта. Это не «техническая деталь», это публичное поведение вашего API. Клиент может не передавать параметр — и тогда сервер решает за него. Поэтому это решение должно быть валидным.

Если же вы захотите сделать параметры опциональными без defaultValue и используете Integer, важно помнить: большинство constraints (например, @Min) по умолчанию считают null валидным значением. То есть @Min(0) Integer page не «запрещает отсутствие page». Оно просто говорит: «если page есть — пусть будет >= 0». Это нормально, но тогда в коде нужно аккуратно обрабатывать null (или использовать @NotNull), иначе вы получите NPE там, где вообще не хотели заниматься детективом.

5. Валидация sort и taskId

Валидация sort: whitelist через @Pattern

Сортировка — это параметр, который внешне выглядит простым: строка вроде updatedAt,desc. Но именно сортировка часто превращает API в «скрытый контракт», если не договориться о формате и допустимых значениях. Клиент присылает sort=priority,sideways, вы думаете «ну ладно, я попробую», потом кто‑то добавляет новое поле в модель, кто‑то меняет название, и внезапно сортировка начинает давать странные результаты или падать.

В нашем проекте мы договорились о формате field,dir, где dirasc или desc, а field принадлежит whitelist: createdAt, updatedAt, dueDate, priority, status, title. Эту договорённость можно выразить прямо в контракте через @Pattern.

Минимальный пример выглядит так:

import jakarta.validation.constraints.Pattern;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping("/api/v1/tasks")
public String listBySort(
        @RequestParam(defaultValue = "updatedAt,desc")
        // Фиксируем контракт: только перечисленные поля и только asc/desc
        @Pattern(regexp = "^(createdAt|updatedAt|dueDate|priority|status|title),(asc|desc)$")
        String sort
) {
    return "ok";
}

Регулярное выражение здесь — не ради «магии regex», а ради whitelist’а. Оно говорит: «сортировать можно только по этим полям и только в этих направлениях». Это сразу делает контракт предсказуемым. Важно, что мы используем ^ и $, чтобы строка совпадала целиком, а не «где-то внутри есть нужные символы». То есть updatedAt,descPLEASE не пройдёт, и это хорошо.

Да, регулярки можно ненавидеть, и вы имеете на это моральное право. Но в этом месте regex — это не попытка решить проблему мирового масштаба, а простой способ зафиксировать формат и whitelist в одном месте. Альтернатива — разбирать строку вручную, писать if/else, придумывать сообщения об ошибках, и снова превращать контроллер в мини-парсер. А мы с вами договорились, что контроллеры у нас тонкие и не любят лишней драмы.

Пока sort живёт отдельным @RequestParam, whitelist удобно держать прямо на параметре. Если тот же search-контракт собрать в один criteria-object, правило никуда не денется — оно просто переедет на поле sort внутри него.

Валидация taskId: @Pattern для UUID

Параметр taskId в пути (/api/v1/tasks/{taskId}) — это адрес конкретного ресурса. У нас по архитектуре проекта идентификатор — UUID string. Это значит, что «валидный taskId» — не любая строка, а строка определённого формата. И если клиент прислал «не UUID», это не сценарий «задача не найдена». Это сценарий «вы неправильно сформировали запрос».

Самый прямолинейный способ зафиксировать это — поставить @Pattern прямо на @PathVariable. Для учебного проекта вполне достаточно простой проверки «36 символов и дефисы», чтобы отсечь очевидный мусор:

import jakarta.validation.constraints.Pattern;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@GetMapping("/api/v1/tasks/{taskId}")
public String details(
        @PathVariable
        // Лёгкая проверка формата: отсекаем совсем уж не похожие значения
        @Pattern(regexp = "^[0-9a-fA-F-]{36}$")
        String taskId
) {
    return "ok";
}

Да, это выражение не проверяет все тонкости UUID (например, позицию дефисов), но оно хорошо демонстрирует принцип: path-параметр можно и нужно валидировать, и это делается теми же constraints, что и для тела запроса.

Если хочется сделать проверку строгой, вы можете использовать более точную регулярку или даже принимать параметр как UUID, чтобы Spring сделал type conversion. Но ключевая мысль для этой лекции не в «идеальной валидации UUID», а в том, что идентификатор ресурса — это тоже вход. И если он не соответствует нашему контракту, запрос не должен попадать в сервисный слой.

Отдельный приятный бонус: когда taskId валидируется на границе, сервис может работать с ним как с «нормальным» идентификатором, не оглядываясь постоянно на возможность формата «я вообще не UUID, я пришёл ломать вам настроение».

6. Пример TaskController с параметрами

Теперь давайте соберём общий фрагмент, максимально похожий на наш проект. Для разбора механики пока оставим параметры отдельно: так проще увидеть, как на одном методе живут page, size, sort и taskId. Но как только у GET /api/v1/tasks нарастают фильтры, тот же search-контракт уже удобнее свернуть в TaskSearchCriteria.

Сначала покажем класс контроллера и константы для регулярных выражений (чтобы не хранить «ковёр из regex» прямо в параметрах):

package com.example.tasktracker.api.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {

    // Упрощённая проверка UUID (учебный вариант): «похоже на UUID по длине/символам»
    static final String UUID_LITE = "^[0-9a-fA-F-]{36}$";

    // Whitelist формата sort: field,dir — только разрешённые поля и направления
    static final String SORT = "^(createdAt|updatedAt|dueDate|priority|status|title),(asc|desc)$";
}

Теперь list-метод с page, size, sort:

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping
public String list(
        @RequestParam(defaultValue = "0") @Min(0) int page, // page: страницы начинаются с 0
        @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size, // size: ограничиваем максимальный размер ответа
        @RequestParam(defaultValue = "updatedAt,desc") @Pattern(regexp = SORT) String sort // sort: только whitelist
) {
    return "ok";
}

И detail-метод с taskId:

import jakarta.validation.constraints.Pattern;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@GetMapping("/{taskId}")
public String details(
        @PathVariable @Pattern(regexp = UUID_LITE) String taskId // taskId: отсеиваем мусор на границе API
) {
    return "ok";
}

Если теперь вызвать эндпоинты с «плохими» параметрами, вы увидите, что метод просто не выполняется. Это можно проверить даже без красивого форматирования ошибок: поставьте breakpoint в метод — и он не сработает. Spring оборвёт запрос раньше, потому что вход нарушил контракт.

Для самопроверки удобно иметь пару запросов в .http-файле:

### Набор запросов для ручной проверки: каждый из них должен привести к 400
### (а значит — контроллерный метод не должен выполняться)

### page < 0 -> 400
GET http://localhost:8080/api/v1/tasks?page=-1
Accept: application/json

### size too big -> 400
GET http://localhost:8080/api/v1/tasks?size=1000
Accept: application/json

### sort not in whitelist -> 400
GET http://localhost:8080/api/v1/tasks?sort=unknownField,asc
Accept: application/json

### taskId not UUID-ish -> 400
GET http://localhost:8080/api/v1/tasks/not-a-uuid
Accept: application/json

И ещё один запрос «про другой вид ошибки», где валидатор вообще не успевает сработать, потому что тип не конвертируется:

### page not a number -> 400 (type conversion error)
GET http://localhost:8080/api/v1/tasks?page=abc
Accept: application/json

Это тот самый случай, когда «ошибка формата» и «нарушено ограничение» различаются, но оба сценария блокируются на границе и не пускают мусор в сервис.

7. Типичные ошибки при валидации параметров

Ошибка №1: думать, что @Valid на @RequestBody автоматически покрывает @RequestParam и @PathVariable.
Проверка object-аргумента и проверка простых параметров — не один и тот же механизм. Если page, size, sort и taskId участвуют в контракте, ограничения нужно писать прямо на этих параметрах, а не надеяться, что один @Valid где-то в контроллере “подсветит всё сразу”.

Ошибка №2: defaultValue не удовлетворяет ограничениям и endpoint противоречит сам себе.
Если вы поставили @RequestParam(defaultValue = "-1") @Min(0) int page, то при отсутствии page клиентом вы сами создаёте невалидный вход. Это выглядит как «у сервера сломана логика по умолчанию». Всегда проверяйте, что дефолты попадают в диапазон.

Ошибка №3: принимают page/size как String и делают ручной парсинг в контроллере.
Так контроллер быстро превращается в «клуб любителей NumberFormatException». Spring уже умеет конвертировать типы, и делает это раньше. Когда вы оставляете String, вы отказываетесь от этого механизма и усложняете код без пользы.

Ошибка №4: ставят слишком тяжёлую регулярку на sort и потом не могут её поддерживать.
@Pattern хорош как whitelist и как фиксация формата. Но если регулярное выражение превращается в 200 символов без возможности понять, что там происходит, то контракт становится нечитаемым. Лучше держать формат простым и явно ограниченным, чем пытаться одним regex-ударом покрыть «все возможные варианты сортировки в мире».

Ошибка №5: валидируют taskId “примерно”, а потом внутри сервиса всё равно делают UUID.fromString(taskId) без защиты.
Если вы выбрали стратегию “taskId — строка”, то и дальше относитесь к нему как к строке, которая уже прошла проверку. Если же внутри сервиса вы хотите именно UUID, то лучше конвертировать в одном месте (и придерживаться одного подхода), иначе можно получить ситуацию, когда валидация пропустила значение, а парсинг позже всё равно упал.

1
Задача
Spring REST & MVC, 16 уровень, 2 лекция
Недоступна
Параметры каталога прямо в сигнатуре контроллера
Параметры каталога прямо в сигнатуре контроллера
1
Задача
Spring REST & MVC, 16 уровень, 2 лекция
Недоступна
UUID-подобный идентификатор посылки
UUID-подобный идентификатор посылки
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ