1. Запрос не доходит до метода контроллера
Если вы только начали писать REST API, есть очень человеческое ожидание: «Я же написал метод контроллера — значит он вызывается, и уже внутри я разберусь, что там пришло». Увы, Spring MVC не обязан вызывать ваш метод просто из уважения к вашему труду. Прежде чем управление попадёт в TaskController#create(...), Spring должен убедиться, что запрос вообще можно прочитать и превратить в аргументы метода.
Нас здесь интересует именно этот участок: всё, что происходит между raw HTTP body и готовыми аргументами метода.
Представьте контроллер как кассу в кинотеатре. До кассы ещё есть турникет и контролёр. Если билет порван (malformed JSON), контролёр вас не пустит — кассир (ваш метод) даже не узнает, что вы приходили. Это, на самом деле, хорошая новость: сервисный слой не должен «чинить» синтаксически сломанный JSON. Его задача — работать с уже нормальными данными, а не играть в археолога по раскопкам запятых.
Чтобы закрепить картинку, вот упрощённая схема того, где может «сломаться» запрос:
flowchart TD
A[HTTP запрос] --> B{"Подходит ли endpoint по path+method?"}
B -->|да| C{"Подходит ли Content-Type под consumes?"}
C -->|да| D["Чтение body и сборка Java-объекта для @RequestBody"]
D -->|успех| E[Вызов метода контроллера]
E --> F[Вызов сервиса]
D -->|ошибка| X[Ответ с ошибкой без вызова контроллера]
C -->|нет| Y[415 Unsupported Media Type]
B -->|нет| Z[404 или 405 в зависимости от ситуации]
В этой лекции нас интересует блок D: всё, что связано с тем, что body либо не читается, либо читается «не в тот тип», либо вообще отсутствует.
2. Malformed JSON: когда сломана форма, а не смысл
Слово malformed звучит так, будто JSON просто «не выспался». Но смысл прозаичный: JSON синтаксически невалидный, его невозможно разобрать парсером. Это уровень «не сходятся скобки», «лишняя запятая», «кавычки не закрылись». Здесь ещё рано говорить о бизнес-смысле, потому что до смысла мы даже не добрались: данных как объекта не существует.
Самая коварная часть в том, что глазами человека JSON может выглядеть «почти нормальным». Одна лишняя запятая — и всё, приехали. Как в программировании: «я добавил всего один символ… почему прод упал?» — классика жанра.
Давайте покажем это на нашем POST /api/v1/tasks. Пусть у нас есть create-endpoint (он у вас уже появился в предыдущих лекциях). Для контекста — минимальная форма запроса:
### OK: валидный JSON
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
{
"title": "Fix login",
"description": "User can't login with correct password"
}
Теперь сделаем «почти то же самое», но добавим лишнюю запятую (это один из самых популярных способов выстрелить себе в ногу):
### BAD: malformed JSON (лишняя запятая)
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
{
"title": "Fix login",
"description": "User can't login with correct password",
}
Что важно понять концептуально: в таком сценарии метод контроллера не вызывается, сервис не вызывается, никакие ваши try/catch внутри метода не срабатывают. Spring просто не может собрать объект TaskCreateRequest, потому что прочитать JSON невозможно.
Если вы отлаживаетесь, то самый простой признак — вы ставите breakpoint в методе контроллера, отправляете malformed JSON… и breakpoint молчит. Это не «Spring сломался», это «Spring даже не начал выполнять ваш код, потому что входные данные не прошли на уровень Java-объекта».
3. Пустое тело запроса: когда серверу буквально нечего читать
Malformed JSON — это когда тело есть, но оно сломано. А теперь представим другой сценарий: тело запроса пустое. Вы отправили POST, указали Content-Type: application/json, а в body — пустота. То ли клиент забыл, то ли программист «пока протестирую без тела», то ли кто-то в Postman случайно удалил payload и нажал Send (бывает даже у лучших).
И тут происходит важная вещь: по умолчанию @RequestBody считается обязательным. То есть Spring ожидает, что он сможет прочитать тело и собрать объект. Если читать нечего, аргумент метода не собрать — и мы снова не попадаем в контроллер.
Проверим на практике:
### BAD: пустое тело (body отсутствует)
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
Вы получите ошибку (обычно это будет 400 Bad Request), и снова — контроллерный метод не выполнится.
Теперь тонкий, но очень важный момент: пустое тело — это не то же самое, что {}.
{} — это валидный JSON-объект, просто без полей. И в этом случае Spring может успешно создать TaskCreateRequest, где все поля будут null. То есть метод контроллера будет вызван, сервис будет вызван, и дальше вы можете получить уже прикладную проблему (например, NPE или «создали задачу без title», если вы пока не делали проверок).
### OK на уровне чтения body: JSON валиден, но данных нет
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
{}
Это ключевая граница сегодняшней лекции: прежде чем вы начнёте думать «как валидировать поля», вы должны увидеть, что есть два разных мира. Мир «объект не собрался вообще» и мир «объект собрался, но внутри пусто/не то».
Иногда разработчики пытаются «сделать по-доброму» и разрешают пустой body, чтобы контроллер всё-таки выполнялся. Технически это возможно, но для create-endpoint обычно не нужно. Просто чтобы вы знали этот механизм, вот минимальный пример:
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.PostMapping;
@PostMapping
public ResponseEntity<Void> create(@RequestBody(required = false) TaskCreateRequest request) {
// Если body отсутствует, а required=false — Spring передаст сюда null
if (request == null) return ResponseEntity.badRequest().build(); // Явно отвечаем 400: создавать без тела нельзя
// Здесь уже обычная логика обработчика, потому что объект запроса существует
return ResponseEntity.ok().build();
}
Смысл примера не в том, чтобы так делать везде, а в том, чтобы понять: «обязательное» тело запроса — это не философия, а настройка. Но в нормальном API создание задачи без тела — это не тот сценарий, который стоит поддерживать, потому что он лишь усложняет контракт.
4. Ошибки типов: JSON не помещается в Java-модель
Есть ещё более «взрослый» класс проблем: JSON синтаксически корректный, то есть скобки и запятые на месте, но Spring всё равно не может собрать объект. Причина в том, что структура или значения не соответствуют типам вашего Java-класса. В реальной жизни это всплывает постоянно: кто-то прислал "priority": "URGENT", а у вас такого enum нет, или "dueDate": "tomorrow" вместо даты, или вообще число вместо строки.
Для примера добавим в домен приоритет задачи:
package com.example.tasktracker.domain.model;
public enum TaskPriority {
LOW, MEDIUM, HIGH, CRITICAL
}
И пусть TaskCreateRequest ожидает этот enum:
package com.example.tasktracker.api.dto.request;
import com.example.tasktracker.domain.model.TaskPriority;
public class TaskCreateRequest {
// Эти поля заполняются из JSON по именам ключей
private String title;
// Здесь ожидается строка из набора значений enum (например, "LOW", "HIGH")
private TaskPriority priority;
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public TaskPriority getPriority() { return priority; }
public void setPriority(TaskPriority priority) { this.priority = priority; }
}
Теперь отправим JSON, где priority имеет значение, которого нет в enum:
### BAD: JSON валиден, но enum-значение не подходит
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
{
"title": "Fix login",
"priority": "URGENT"
}
В этом случае JSON синтаксически нормальный, но в Java-объект он не превращается. Результат очень похож на malformed JSON с точки зрения вашего кода: метод контроллера, скорее всего, не будет вызван, потому что объект не собран. Ошибка происходит на этапе «прочитать body и сконвертировать в тип аргумента».
Ещё один популярный вариант — неверный тип значения. Например, title у нас строка, а клиент прислал объект:
### BAD: тип поля не совпадает (ожидали строку, пришёл объект)
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
{
"title": { "text": "Fix login" }
}
Даже если человек (или клиентская библиотека) «в целом хотел передать то же самое», сервер не обязан угадывать. Контракт уже зафиксирован вашим Java-классом: title — строка. И если это не строка, чтение тела заканчивается на уровне MVC, не доходя до сервиса.
И здесь важно не спутать это с «плохими данными в бизнес-смысле». Это техническая несовместимость формы данных с ожидаемой моделью. Сервис не должен заниматься тем, чтобы «из объекта вытащить text и сделать строку». Это уже другая история и другой контракт.
5. Content-Type и consumes: иногда проблема вообще не в JSON
После предыдущих примеров легко сделать вывод: «главный источник боли — JSON». Но иногда JSON идеальный, а проблема в заголовке Content-Type. Мы как раз в прошлой лекции закрепили consumes и produces не ради красоты. Они делают контракт конкретным: «сюда присылайте JSON, иначе мы даже не будем притворяться, что понимаем запрос».
Предположим, ваш контроллер фиксирует, что принимает именно JSON:
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Task> create(@RequestBody TaskCreateRequest request) {
// ...
}
Теперь клиент делает странный ход: отправляет JSON-тело, но ставит Content-Type: text/plain. Для сервера это выглядит как: «мне прислали текст», а не «мне прислали JSON».
### BAD: Content-Type не совпадает с consumes (скорее всего будет 415)
POST http://localhost:8080/api/v1/tasks
Content-Type: text/plain
Accept: application/json
{"title":"Fix login"}
Обычно результат здесь — 415 Unsupported Media Type. И это снова история «контроллер не вызван». Просто ошибка произошла ещё раньше: сервер даже не попытался читать JSON как JSON, потому что по контракту ему прислали «простой текст».
В этом месте полезно запомнить практическое правило: если у вас @RequestBody и вы ожидаете JSON, то клиент обязан прислать корректный Content-Type: application/json. Иначе ваш API начинает напоминать «угадай мелодию по трём нотам», а так мы далеко не уедем.
6. Диагностика: сервис не вызывался
Иногда студент говорит: «Ну ладно, вы говорите “метод не вызвался”, но я хочу это увидеть». Это правильное желание, потому что мозг лучше запоминает то, что можно пощупать руками. Самый простой способ — поставить маленький маркер в сервис и убедиться, что он появляется только на валидных запросах.
Например, в вашем TaskService можно временно написать:
public void create(String title, TaskPriority priority) {
// Временный маркер: если вы НЕ видите эту строку в логах, значит запрос "умер" до сервиса
System.out.println("TaskService#create called");
}
Теперь отправьте нормальный JSON — вы увидите строку в логах/консоли. Отправьте malformed JSON, пустой body, неправильный enum — и этой строки не будет. Это прямое доказательство того, что запрос «сломался» ещё до того, как контроллер смог извлечь данные и вызвать прикладной код.
И это объясняет один частый «парадокс новичка»: он пытается обернуть логику сервиса в try/catch и удивляется, что его catch никогда не срабатывает на malformed JSON. Он не срабатывает потому, что сервис даже не вызывается. Ошибка раньше.
7. Таблица сценариев: где ломается запрос
Когда вы начинаете проектировать API как контракт, полезно держать в голове не только happy-path, но и базовую карту негативных сценариев. Ниже — упрощённая таблица, без попытки сделать из неё «финальный стандарт ошибок». Пока наша цель — видеть, где проходит граница: до контроллера или уже внутри.
| Сценарий | Пример | Обычно происходит | Типичный статус | Доходит до контроллера? |
|---|---|---|---|---|
| Malformed JSON | лишняя запятая, не закрыта скобка | тело невозможно распарсить | 400 Bad Request | нет |
| Пустое тело при обязательном @RequestBody | body отсутствует | нечего читать, аргумент не собрать | 400 Bad Request | нет |
| {} вместо ожидаемых полей | {} | объект создаётся, поля null | 200/201 (если не проверять) или прикладная ошибка | да |
| Неверный enum | "priority": "URGENT" | значение нельзя преобразовать в enum | 400 Bad Request | обычно нет |
| Неверный тип поля | "title": { ... } | структура не совпадает с ожиданием | 400 Bad Request | нет |
| Неверный Content-Type | text/plain при consumes=application/json | запрос не подходит под контракт | 415 Unsupported Media Type | нет |
Формулировка «типичный статус» здесь важна: точный формат тела ошибки и детали мы ещё будем приводить к единому виду. Но сама мысль уже полезна: многие ошибки не имеют отношения к бизнес-логике. Они про то, что запрос не соответствует контракту ещё на входе.
8. Типичные ошибки при чтении JSON
Эта тема кажется «просто теорией про плохой JSON», но на практике именно здесь чаще всего ломается первый опыт общения клиента с вашим API. Если вы один раз поймёте, что Spring может не вызвать контроллер вообще, вы перестанете тратить часы на отладку «почему мой сервис не отрабатывает» и начнёте смотреть туда, где действительно проблема: в теле запроса и его соответствии контракту.
Ошибка №1: пытаться ловить malformed JSON через try/catch внутри метода контроллера.
Это выглядит логично: «если JSON плохой, я поймаю исключение». Но исключение возникает раньше, чем метод вообще начнёт выполняться. В результате ваш catch никогда не срабатывает, и создаётся ощущение, будто Spring «игнорирует ваш код». На самом деле он просто не дошёл до него.
Ошибка №2: считать {} и пустое тело одним и тем же.
Пустое тело — это отсутствие данных, которое часто заканчивается ошибкой чтения запроса. {} — валидный JSON-объект, и он может спокойно превратиться в объект с null полями. Это два разных сценария, и они требуют разных мыслительных реакций при проектировании контракта.
Ошибка №3: «ну я же отправил JSON, зачем Content-Type».
Для человека JSON — это «то, что внутри», а для HTTP-контракта это ещё и заголовок Content-Type. Если он неправильный, сервер может честно сказать 415 Unsupported Media Type, даже если тело похоже на JSON. Протокол не читает ваши мысли, он читает заголовки.
Ошибка №4: смешивать “не смогли прочитать тело” и “прочитали, но поля плохие”.
Malformed JSON и неправильный enum — это уровень, где объект не собирается, и до прикладной логики дело не доходит. А вот «title пустой» или «не прислали description» — это уже другой класс проблем: объект собрался, и дальше нужно решать, что считать допустимым. Если всё смешать в одну корзину, вы сами себе усложните жизнь на следующих шагах курса.
Ошибка №5: добавлять “магические допущения” в контракт без явной договорённости.
Например, пытаться принимать priority: 1 и «самому догадаться», что это HIGH. Это кажется удобным, но превращает API в набор неочевидных правил. В этом курсе мы целимся в предсказуемый контракт: если ожидаем enum-строку — принимаем enum-строку. Всё остальное либо ошибка, либо отдельное осознанное решение, но не случайная «доброта сервера».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ