1. Явная фиксация формата в JSON-first API
На старте кажется, что consumes/produces — это какая-то бюрократия уровня «поставьте галочку, что вы поставили галочку». Но в API-дизайне мелочи из заголовков — это как подпись в договоре: пока всё хорошо, её не замечают, а когда всё плохо — внезапно выясняется, что именно она решает спор. Если мы не фиксируем формат явно, клиент может прислать тело запроса в неожиданном виде (или с неожиданным Content-Type), а мы потом будем долго гадать, почему контроллер «как будто не вызывается». Аналогично, если клиент просит один формат через Accept, а сервер отдаёт другой, это превращается в «плавающую» проблему, где каждый уверен, что виноват не он.
В нашем курсе мы сознательно делаем JSON-first API, то есть основной формат — application/json. Это не значит, что можно перестать думать о media types. Наоборот: раз мы сознательно выбираем один формат, логично сознательно и объявлять его в контракте endpoint'ов. Тогда любой разработчик, открыв контроллер, за 10 секунд понимает: «ага, это endpoint принимает JSON и отдаёт JSON», а не «ну, наверное, оно так по умолчанию». И это экономит вам время на отладке, время на объяснение коллегам и время на переписку с клиентами, которые присылают text/plain, потому что “так удобнее”.
Content-Type и Accept: заголовки формата
Чтобы правильно использовать consumes и produces, нужно снова быстро вспомнить смысл двух ключевых заголовков. Это не «какая-то теория из RFC», а буквально язык, на котором клиент и сервер договариваются, как читать и как писать body. Content-Type относится к телу запроса: клиент говорит серверу «вот мой body, он в таком формате». Accept относится к телу ответа: клиент говорит серверу «я хочу получить ответ вот в таком формате».
В JSON-first API это обычно выглядит так: клиент отправляет Content-Type: application/json, а также (часто) добавляет Accept: application/json. Если Accept не указан, многие клиенты по умолчанию ведут себя как будто там */* (то есть «мне подойдёт любой формат»), и сервер отдаёт то, что умеет. Но в реальной жизни наличие явного Accept сильно помогает: он превращает «надежду» в контракт.
Небольшая табличка, чтобы всё встало в голове ровно (и чтобы вы не путали это на автомате):
| Сущность | Где живёт | Кто задаёт | Про что говорит | Пример |
|---|---|---|---|---|
| Content-Type | заголовок HTTP запроса | клиент | «Какой формат у моего request body» | Content-Type: application/json |
| Accept | заголовок HTTP запроса | клиент | «В каком формате я хочу response body» | Accept: application/json |
| consumes | аннотация mapping в Spring MVC | сервер (мы) | «Какие Content-Type я готов принимать в request body» | consumes = "application/json" |
| produces | аннотация mapping в Spring MVC | сервер (мы) | «Какие форматы я готов отдавать в response body» | produces = "application/json" |
Обратите внимание на лёгкую «ловушку для начинающих»: Content-Type и Accept — это заголовки в запросе, а consumes/produces — это «правила» на стороне сервера, по которым Spring либо выберет ваш метод контроллера, либо скажет «нет, так мы не договаривались». И именно это поведение нам сегодня нужно.
2. consumes и produces как фильтр контракта
Если объяснить максимально по-человечески, то consumes и produces — это часть правил матчинга endpoint'а. То есть Spring не только смотрит на путь и HTTP-метод (POST /api/v1/tasks), но и смотрит: «а подходит ли этот запрос по формату?». И вот тут consumes/produces включают дисциплину: если запрос пришёл с Content-Type, который вы не принимаете, Spring может вернуть 415 Unsupported Media Type. Если запрос пришёл с Accept, который вы не можете удовлетворить, Spring может вернуть 406 Not Acceptable.
Важно почувствовать именно момент: в таких случаях ваш код внутри метода контроллера может вообще не выполниться. И это нормально. Это не «Spring сломался». Это значит, что запрос не соответствует контракту на уровне HTTP-рамки. Ваша бизнес-логика тут ни при чём: вы просто не понимаете, как читать тело, или не можете отдать ответ так, как просит клиент.
Можно представить это как турникет в метро. У вас есть билет — проходите, нет билета — не проходите. И турникет не обязан обсуждать с вами философские вопросы «а почему так», его задача — быстро и понятно фильтровать поток. consumes и produces — такой же турникет, только для формата body.
Схема на уровне «достаточно для Junior» может выглядеть так:
flowchart TD
A[HTTP запрос] --> B[Spring MVC: поиск подходящего handler method]
B --> C{Путь и метод совпали?}
C -->|нет| X["404 / no handler"]
C -->|да| D{consumes совпал с Content-Type?}
D -->|нет| E[415 Unsupported Media Type]
D -->|да| F{produces совпал с Accept?}
F -->|нет| G[406 Not Acceptable]
F -->|да| H[Вызов метода контроллера]
Мы пока не разбираем внутреннюю механику «кто именно читает body» и «какой компонент отвечает за JSON». Но уже сейчас полезно иметь в голове: consumes/produces — это часть фильтрации запроса до вашей прикладной логики, и это напрямую относится к контракту API.
3. Фиксируем media type в контроллере и методах
produces на уровне TaskController
Теперь давайте сделаем практическую вещь в нашем Task Tracker API: на уровне контроллера зафиксируем, что он возвращает JSON. Почему на уровне контроллера? Потому что так мы не будем писать одно и то же в каждом методе, а контракт станет читаемым «снаружи»: открыл класс — сразу понял, что тут у нас application/json.
Представьте, что контроллер — это “папка” с endpoint'ами. Если в этой папке почти все ответы — JSON, логично написать это один раз на папке, а не на каждом файле внутри. Для JSON-first контроллера это удобно ещё и потому, что другой produces нужен только там, где сам ответ действительно имеет другой формат. Но сегодня мы в JSON-first мире, и нам важно сделать его явным.
Минимальный каркас контроллера с produces:
package com.example.tasktracker.api.controller;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // Говорим Spring: это REST-контроллер, ответы будут уходить в body
@RequestMapping(
path = "/api/v1/tasks", // Базовый путь для всех endpoint'ов задач
produces = MediaType.APPLICATION_JSON_VALUE // По умолчанию отдаём JSON
)
public class TaskController {
// Здесь будут handler-методы (GET/POST/PUT/DELETE) для работы с задачами
}
Здесь мы делаем две важные вещи. Во-первых, мы фиксируем базовый путь /api/v1/tasks, чтобы методы внутри могли быть компактнее. Во-вторых, мы говорим Spring MVC: «всё, что выходит из этого контроллера как response body, будет отдаваться как JSON». И это не только про «чтобы клиенту было красиво». Это про то, чтобы наш API был предсказуемым: даже если вы вернёте объект напрямую, Spring выставит правильный Content-Type в ответе.
consumes для @RequestBody
Следующий логичный шаг — зафиксировать входной формат для тех операций, где мы читаем request body. В нашем проекте это создание и обновление задач. Когда в сигнатуре метода появляется @RequestBody, по смыслу мы говорим: «клиент присылает тело, мы его читаем». Значит, нам полезно сразу сказать: «мы читаем только JSON». И это как раз выражается через consumes = application/json.
Почему это не просто “красивость”? Потому что иначе клиент может прислать body без Content-Type (или с text/plain), и вы будете удивляться, почему Spring не хочет превращать это в ваш Java-объект. С consumes всё становится честно: у нас договор — JSON, и если клиент приносит не JSON, запрос не проходит.
Пример POST создания задачи:
import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import java.net.URI;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) // Принимаем только JSON в request body
public ResponseEntity<Task> create(@RequestBody TaskCreateRequest request) {
// request пришёл из JSON-тела запроса и уже распарсен Spring'ом
Task created = taskService.create(request.getTitle(), request.getDescription());
// Для 201 Created хорошая практика — вернуть Location с URL созданного ресурса
URI location = URI.create("/api/v1/tasks/" + created.getId());
return ResponseEntity
.created(location) // HTTP 201 + заголовок Location
.body(created); // Тело ответа (JSON, потому что produces задан на контроллере)
}
Обратите внимание на нюанс: produces мы тут не пишем, потому что уже зафиксировали его на уровне контроллера. Если вам так читается лучше, можно писать и на методе тоже, но тогда вы начнёте повторять одно и то же. В учебном проекте лучше держать повторение под контролем, чтобы код не выглядел как «обои из аннотаций».
Для PUT и PATCH подход тот же: если есть @RequestBody, значит у метода есть входной формат, и мы можем его явно задать:
import com.example.tasktracker.api.dto.request.TaskUpdateRequest;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
@PutMapping(path = "/{taskId}", consumes = MediaType.APPLICATION_JSON_VALUE) // Читаем JSON из body
public ResponseEntity<Task> replace(@PathVariable String taskId,
@RequestBody TaskUpdateRequest request) {
// taskId пришёл из URL, request — из JSON-тела
Task updated = taskService.replace(taskId, request.getTitle(), request.getDescription());
return ResponseEntity.ok(updated); // 200 OK + JSON-тело (produces на контроллере)
}
А вот DELETE, как правило, consumes не требует: у удаления обычно нет тела запроса. С produces тоже можно быть аккуратнее, потому что 204 No Content — это успех без body. У вас может быть общий produces на контроллере, и это будет работать, но на уровне смысла полезно помнить: 204 — это “нет тела”, а значит формат тела уже не так важен, как статус.
4. Клиентские запросы и 415/406
Сейчас будет полезный момент, который сильно экономит часы отладки. Когда вы добавили consumes/produces, часть запросов может начать возвращать 415/406, и разработчик-новичок часто воспринимает это как «у меня что-то сломалось в контроллере». Но нет: вы просто добавили контрактную дисциплину, и теперь запросы должны ей соответствовать.
Пример корректного запроса создания задачи в .http стиле:
# Корректный запрос: и Content-Type, и Accept согласованы с контрактом контроллера
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
{
"title": "Fix login bug",
"description": "Users cannot sign in after last deploy"
}
Если вы случайно отправите тело без Content-Type или с неправильным типом, например так:
# Некорректный Content-Type: сервер ждёт JSON, а пришёл text/plain
POST http://localhost:8080/api/v1/tasks
Content-Type: text/plain
Accept: application/json
{"title":"Fix login bug"}
то Spring может вернуть 415 Unsupported Media Type. И важно: это не ошибка «вашей бизнес-логики». Это ошибка уровня «мы не договорились, как читать body». Ваш taskService.create(...) в этом сценарии не виноват и, скорее всего, даже не был вызван.
А теперь пример с Accept. Допустим, клиент говорит: «я хочу текст»:
# Некорректный Accept: клиент просит text/plain, а сервер по контракту отдаёт JSON
GET http://localhost:8080/api/v1/tasks
Accept: text/plain
Если у контроллера стоит produces = application/json, то Spring может вернуть 406 Not Acceptable. Это ситуация «сервер может отдать только JSON, а клиент хочет text/plain». В реальной жизни браузеры обычно не будут так делать, но некоторые инструменты и интеграции — вполне могут.
И вот здесь появляется очень полезная инженерная привычка: когда вы видите 415 или 406, вы первым делом смотрите не на сервис и не на repository, а на заголовки запроса. Это то место, где чаще всего сидит проблема.
5. Как избежать “леса аннотаций”
Теперь — про дисциплину в коде. Очень легко сделать две крайности. Первая крайность — вообще ничего не фиксировать и надеяться, что “по умолчанию и так JSON”. Вторая крайность — написать consumes и produces вообще везде, причём строками, причём иногда с опечатками, и потом героически отлаживать, почему половина endpoint'ов не матчится.
Здоровый путь для учебного проекта обычно выглядит так: produces фиксируем на уровне контроллера (или общего @RequestMapping), чтобы весь набор endpoint'ов был в одном формате. А consumes ставим только там, где действительно читаем body через @RequestBody. Это делает контракт явным, но не превращает код в занавес из аннотаций.
При этом важно избегать магических строк. Писать "application/json" руками везде — это как вручную вбивать “localhost” в каждой строке кода: один раз ошиблись — и всё, «почему-то не работает». Поэтому используйте константы Spring:
import org.springframework.http.MediaType;
// Единое место, где мы фиксируем media types нашего API
public final class ApiMediaTypes {
// JSON — базовый формат нашего JSON-first API
public static final String JSON = MediaType.APPLICATION_JSON_VALUE;
private ApiMediaTypes() {
// Запрещаем создавать экземпляры: это утилитный класс с константами
}
}
И тогда в контроллере можно писать компактно и без опечаток:
import static com.example.tasktracker.api.ApiMediaTypes.JSON;
import org.springframework.web.bind.annotation.PostMapping;
@PostMapping(consumes = JSON)
public ResponseEntity<Task> create(@RequestBody TaskCreateRequest request) {
// Остальной код метода (создание задачи, формирование ответа и т.д.)
}
Это не обязательная техника, но она хорошо показывает общую мысль: формат — часть контракта, а контракт хочется держать аккуратно и единообразно, а не через копипасту.
6. Типичные ошибки при работе с consumes и produces
Ошибка №1: путаница “кто за что отвечает” между Content-Type, Accept, consumes, produces.
Очень частая ситуация: разработчик ставит produces, думая, что это “мы принимаем вот это”, или ставит consumes, думая, что это “мы отдаём вот это”. В итоге endpoint внезапно начинает возвращать 415/406, а человек ищет проблему в сервисе. Полезная проверка простая: consumes — это про то, что сервер читает (как переварить request body), а produces — про то, что сервер пишет (как сформировать response body).
Ошибка №2: механически ставят consumes на GET, где нет тела запроса.
На уровне смысла это просто шум: вы фиксируете формат того, чего нет. А на уровне поведения иногда можно получить неожиданные эффекты, особенно если какой-то клиент внезапно отправляет Content-Type там, где вы его не ожидаете. В учебном проекте лучше придерживаться простого правила: consumes появляется там, где есть @RequestBody.
Ошибка №3: пишут "application/json" руками в двадцати местах и один раз ошибаются.
Опечатка вроде "applicaton/json" выглядит смешно ровно до момента, пока вы не потратили 40 минут на дебаг и не поняли, что endpoint “не находится”, потому что consumes не совпал. Используйте MediaType.APPLICATION_JSON_VALUE или свою константу, и вы уберёте целый класс ошибок из жизни.
Ошибка №4: считают, что 415/406 — это “ошибка моего кода в методе контроллера”.
На самом деле часто это означает, что до метода вы вообще не дошли. Сервис может быть идеальным, репозиторий — гениальным, а @RequestBody — красивым, но если запрос пришёл с неправильным Content-Type или с несовместимым Accept, турникет не пустит. Привычка смотреть на заголовки запроса в таких случаях — это прям взрослая инженерная экономия времени.
Ошибка №5: ставят produces = application/json на endpoint, который возвращает 204 No Content, и начинают ожидать “пустой JSON”.
204 означает “нет тела”, а не “тело пустое”. Это тонкая, но очень важная разница. Если вам хочется возвращать {} или какой-то объект подтверждения — это уже будет 200 OK с body, а не 204. Не делайте вид, что у 204 есть формат тела: формально тела там быть не должно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ