JavaRush /Курсы /Spring REST & MVC /Как выбрать 201,

Как выбрать 201, 200, 204 и Location

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

1. Успешный ответ как часть контракта

Когда пишешь API впервые, очень хочется думать так: “главное, чтобы создалось/обновилось/удалилось, а что там в ответе — да какая разница”. Но клиент вашего API, даже если это вы же через неделю, живёт в мире HTTP-семантики: он смотрит на статус, заголовки и тело, и по этому набору делает вывод “что произошло”. Если этот вывод каждый раз приходится угадывать — контракт получается хрупким.

Представьте, что ваш сервер всегда отвечает 200 OK, и в теле пишет что-то вроде { "message": "created" }. Формально “работает”, но клиенту теперь нужно парсить строку, сравнивать тексты, держать в голове миллион “если message такое — значит создание”. Это и неудобно, и плохо расширяется, и легко ломается при рефакторинге. HTTP уже придумал стандартный язык для таких ситуаций — статусы и заголовки, и нам выгоднее им пользоваться, чем изобретать свой “мини-HTTP поверх HTTP”.

Ниже — небольшая шпаргалка, которую полезно держать в голове. Мы не превращаем это в догму, но это отличная базовая дисциплина для коммерческого REST API:

Операция Что произошло по смыслу Базовый статус Нужен ли Location Есть ли тело ответа
Create (POST) Создали новый ресурс 201 Created Да, очень желательно Часто да (текущее представление ресурса)
Update (PUT/PATCH) Обновили существующий ресурс 200 OK Обычно нет Часто да (обновлённый ресурс)
Delete (DELETE) Удалили ресурс 204 No Content Нет Нет (и это важно)

В этой лекции мы разбираем именно эти три кода: 201, 200, 204. Да, в мире есть и другие варианты, но если вы научитесь уверенно применять эти три — вы уже будете выглядеть как человек, который проектирует контракт, а не “просто пишет контроллеры”.

2. Создание: 201 Created vs 200 OK

Создание — особая операция: до запроса ресурса “не существовало”, после запроса — “существует” и у него появился адрес. Это как зарегистрировать нового пользователя или создать новую задачу: самое ценное, что клиенту нужно узнать, — где теперь живёт созданный ресурс. Именно поэтому для POST базовым ответом считается 201 Created, а не “обычный успех” 200 OK.

Давайте зафиксируем контекст проекта. Мы сейчас развиваем Task Tracker API без БД, поэтому в сервисе можем генерировать id как UUID. Этот id понадобится нам не ради красоты — он нужен, чтобы построить Location и дать клиенту ссылку на новый ресурс.

Возьмём упрощённую модель задачи, где нам нужны только поля, влияющие на разговор про ответ сервера:

package com.example.tasktracker.domain.model;

// Доменная модель задачи (упрощённая для примеров в лекции)
public class Task {
    // Уникальный идентификатор задачи (генерируется на сервере)
    private final String id;

    // Человекочитаемый заголовок задачи
    private final String title;

    // Текстовое описание задачи
    private final String description;

    // Конструктор для создания объекта доменной модели
    public Task(String id, String title, String description) {
        this.id = id;
        this.title = title;
        this.description = description;
    }

    // id нужен клиенту, например, чтобы ходить по ссылке из Location
    public String getId() { return id; }

    // title — главное краткое представление задачи
    public String getTitle() { return title; }

    // description — полезная часть текущего состояния ресурса
    public String getDescription() { return description; }
}

Теперь ключевой момент: контроллер должен вернуть 201 Created и заголовок Location. Самый понятный способ сделать это в Spring MVC — ResponseEntity.created(location).

Простой учебный вариант: Location строкой

Для начала возьмём максимально простой подход: соберём URI руками. Это не идеал, но как первый шаг отлично показывает смысл.

import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import java.net.URI;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@PostMapping
public ResponseEntity<Task> create(@RequestBody TaskCreateRequest request) {
    // 1) Создаём ресурс на стороне сервера
    Task created = taskService.create(request.getTitle(), request.getDescription());

    // 2) Собираем адрес нового ресурса (Location) — клиент сможет потом сделать GET по этому URI
    URI location = URI.create("/api/v1/tasks/" + created.getId());

    // 3) Возвращаем 201 Created + Location + (опционально) тело с представлением ресурса
    return ResponseEntity.created(location).body(created);
}

Здесь в одном коротком фрагменте зафиксировано всё важное: статус 201, заголовок Location, и при желании — тело ответа с текущим представлением созданной задачи. Клиенту удобно: он сразу видит и данные, и адрес, куда можно сходить GET-ом.

Если вы хотите вернуть только Location без тела, вариант тоже честный:

import java.net.URI;
import org.springframework.http.ResponseEntity;

// Адрес созданного ресурса всё равно стоит сообщить клиенту
URI location = URI.create("/api/v1/tasks/" + created.getId());

// .build() — подчёркиваем, что тело отсутствует
return ResponseEntity.created(location).build();

В учебных API обычно приятно возвращать тело, потому что клиенту не нужно делать второй запрос. Но важно понимать: тело — опционально, а вот 201 и Location — это прям “вежливый тон” хорошего REST API.

Как это выглядит для клиента

Представим, что клиент отправляет запрос, например, через .http файл:

POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json

{
  "title": "Fix login",
  "description": "Investigate failed sign-in after password reset"
}

И получает примерно такой ответ:

HTTP/1.1 201 Created
Location: /api/v1/tasks/3c5a4f0f-2b4d-4bde-9f6d-9a2c6a4ddf7a
Content-Type: application/json

{
  "id": "3c5a4f0f-2b4d-4bde-9f6d-9a2c6a4ddf7a",
  "title": "Fix login",
  "description": "Investigate failed sign-in after password reset"
}

Обратите внимание на полезную вещь: теперь клиент не “угадывает”, создано ли что-то новое, — он видит 201 Created. И у него есть адрес, который можно сохранить, показать пользователю, положить в лог, отправить дальше по цепочке.

3. Обновление: 200 OK и 204 No Content

Обновление — другая история. Здесь ресурс уже существует, мы не “рождаем” новый адрес, а меняем состояние по уже известному taskId. Поэтому Location обычно не нужен, а базовый успешный статус — 200 OK, если вы возвращаете обновлённое представление ресурса. Это удобно и для клиента, и для вас: клиент сразу получает “как теперь выглядит задача после изменения”.

Пока не будем углубляться в тонкую разницу между PUT и PATCH; нам здесь нужен только выбор успешного ответа после изменения уже существующего ресурса. Обновление может возвращать тело и тогда это 200, либо может не возвращать тело и тогда разумно использовать 204. Оба варианта допустимы — важно, чтобы вы делали это осознанно.

Вариант “обновили и вернули ресурс”: 200 OK

import com.example.tasktracker.api.dto.request.TaskUpdateRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@PutMapping("/{taskId}")
public ResponseEntity<Task> replace(@PathVariable String taskId,
                                    @RequestBody TaskUpdateRequest request) {
    // Обновляем существующий ресурс по известному идентификатору
    Task updated = taskService.replace(taskId, request.getTitle(), request.getDescription());

    // Возвращаем 200 OK и актуальное представление ресурса в теле ответа
    return ResponseEntity.ok(updated);
}

Код читается как предложение на человеческом языке: “верни 200 OK и тело updated”. У клиента после этого есть актуальное состояние. Это особенно удобно, если сервер сам меняет какие-то поля, например, “время обновления”, потому что клиенту не нужно гадать.

Вариант “обновили молча”: 204 No Content

Иногда вы хотите сделать обновление “без болтовни”: сервер всё применил, но тело не возвращает. Тогда логично использовать 204 No Content.

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@PutMapping("/{taskId}")
public ResponseEntity<Void> replace(@PathVariable String taskId,
                                    @RequestBody TaskUpdateRequest request) {
    // Обновление выполняем, но результат в теле ответа не отдаём
    taskService.replace(taskId, request.getTitle(), request.getDescription());

    // 204 означает: успех есть, тела нет (и быть не должно)
    return ResponseEntity.noContent().build();
}

Этот вариант обычно короче по трафику и по объёму ответа. Но есть нюанс: клиенту тогда сложнее сразу получить итоговое состояние. Для учебного проекта и для большинства “обычных” API чаще выбирают 200 OK + тело, потому что так проще интегрироваться и меньше лишних запросов.

Если вы возвращаете JSON с ресурсом — выбирайте 200 OK. Если вы не возвращаете тело вообще — выбирайте 204 No Content. Самая частая ошибка новичка — вернуть 204, но при этом попытаться вернуть JSON. Это выглядит как “сказал ‘молчу’, но всё равно говорю” — и клиенты или инструменты могут реагировать неожиданно.

4. Удаление: 204 No Content без тела

Удаление — операция, после которой… обсуждать уже нечего. Ресурс исчез, или, в реальном мире, хотя бы стал недоступен. Поэтому типовой успешный ответ — 204 No Content. Это прям очень человеческий ответ сервера: “Сделано. Ничего возвращать не буду, потому что возвращать нечего”. И да, это один из немногих случаев, когда “сервер молчит” — признак хорошего тона, а не грубости.

В Spring MVC это выглядит максимально просто:

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@DeleteMapping("/{taskId}")
public ResponseEntity<Void> delete(@PathVariable String taskId) {
    // Удаляем ресурс на стороне сервера
    taskService.delete(taskId);

    // Возвращаем 204 — без тела (даже без пустого JSON)
    return ResponseEntity.noContent().build();
}

Смысл здесь важнее кода: ResponseEntity<Void> подчёркивает, что тела нет. Мы не возвращаем {} и не возвращаем строку “deleted”. Клиент и так всё понимает по статусу.

Пример запроса:

DELETE http://localhost:8080/api/v1/tasks/3c5a4f0f-2b4d-4bde-9f6d-9a2c6a4ddf7a

И ответ:

HTTP/1.1 204 No Content

Никакого JSON. Даже пустого. Даже {}. Даже “ок”. Просто 204. Это тот случай, когда “молчание — золото”, а ещё экономия трафика.

5. Формирование заголовка Location

Когда вы впервые пишете Location, очень легко сделать это через строковую конкатенацию: "/api/v1/tasks/" + id. На локалхосте это выглядит нормально. Но как только приложение окажется за прокси, поменяется порт, появится context path или вы захотите честный абсолютный URL, строковая конкатенация начинает подкидывать сюрпризы. Проблема не в том, что “конкатенация плохая”, а в том, что она слишком многое предполагает про окружение.

В Spring MVC есть удобный способ строить Location относительно текущего запроса: ServletUriComponentsBuilder. Он берёт базу (scheme/host/port/path) из входящего запроса и аккуратно добавляет кусочек пути с вашим id.

import java.net.URI;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

// Строим Location на основе текущего запроса (scheme/host/port/path берём из входящего HTTP)
URI location = ServletUriComponentsBuilder
        .fromCurrentRequest()      // базовый URI: то, по чему пришёл запрос (например, /api/v1/tasks)
        .path("/{taskId}")         // добавляем сегмент пути под конкретный ресурс
        .buildAndExpand(created.getId()) // подставляем {taskId} = id созданной задачи
        .toUri();                  // получаем итоговый URI

Теперь ваш Location будет корректно сформирован даже если базовый адрес приложения выглядит не как http://localhost:8080, а как, например, https://api.example.com или приложение живёт под /task-tracker. Этот фрагмент кода — прям хороший “прагматичный middle ground”: не слишком сложно, но уже надёжно.

И да, маленькая самоирония: Location — это тот заголовок, который учишь один раз, а потом внезапно понимаешь, что он спасает много времени. Примерно как Ctrl+Z, только для API.

6. Матрица ответов для Task Tracker API

Чтобы картина была цельной, полезно прямо сейчас зафиксировать, какие ответы мы считаем “каноническими” для операций записи в нашем проекте. Мы пока сознательно держим проект простым: без БД, без сложного домена, без security. Но дисциплина HTTP-ответов от этого не становится менее важной — наоборот, её проще закрепить, пока вокруг не появилось ещё десять слоёв.

Endpoint Успех Статус Важные заголовки Тело
POST /api/v1/tasks ресурс создан 201 Created Location: .../tasks/{id} обычно Task (пока без DTO)
PUT /api/v1/tasks/{taskId} ресурс обновлён 200 OK обычно нет Task (обновлённый)
DELETE /api/v1/tasks/{taskId} ресурс удалён 204 No Content обычно нет нет

Если вы придерживаетесь этой матрицы, клиенту проще жить. Даже если клиент — это Postman и ваши глаза. Особенно если клиент — ваши глаза в пятницу вечером.

Для наглядности можно представить create-сценарий как маленький маршрут:

sequenceDiagram
    %% Сценарий: клиент создаёт задачу, сервер отвечает 201 + Location
    participant C as Client
    participant API as TaskController
    participant S as TaskService

    C->>API: "POST /api/v1/tasks (JSON body)"
    API->>S: "create(title, description)"
    S-->>API: "created Task (id generated)"
    API-->>C: 201 Created + Location + JSON body

Заметьте, что в этом маршруте контроллер не “придумывает бизнес”. Его роль — оформить HTTP-смысл результата: не просто “вернуть объект”, а “вернуть созданный ресурс так, чтобы клиент понял, что именно произошло”.

7. Типичные ошибки при выборе 201, 200, 204 и работе с Location

Ошибка №1: возвращать 200 OK при создании ресурса “потому что так проще”.
Так действительно проще на одну строчку кода, но вы теряете смысл операции. Клиент не понимает, создали вы новый ресурс или сделали “что-то успешное”. 201 Created — это короткий, стандартный и очень понятный сигнал: “появился новый ресурс”.

Ошибка №2: поставить 201 Created, но забыть Location.
Получается странная ситуация: сервер говорит “создано”, но не говорит “где”. Иногда это терпимо, если вы возвращаете тело с id, но хороший тон — всё-таки дать клиенту адрес. Location делает создание завершённым с точки зрения HTTP-контракта.

Ошибка №3: вернуть 204 No Content, но при этом отправить JSON в теле ответа.
Семантика 204 именно в том, что тела нет. Не “пустое тело”, не “пустой JSON”, а “вообще отсутствует”. Если вы хотите вернуть JSON — выбирайте 200 OK. Если хотите молчать — выбирайте 204. Смешивание даёт непредсказуемое поведение у клиентов и инструментов.

Ошибка №4: использовать 204 как универсальный “у меня нет настроения возвращать тело”.
Иногда разработчик начинает ставить 204 везде подряд, потому что “так меньше данных”. Но клиенту часто нужно актуальное состояние ресурса после обновления. Для учебного проекта и для большинства обычных API 200 OK + обновлённый ресурс после update обычно делает интеграцию проще и яснее.

Ошибка №5: формировать Location строкой, забыв про базовый путь или версию API.
Если вы случайно построили Location: /tasks/{id}, а ваш API на самом деле живёт под /api/v1/tasks/{id}, клиент получит ссылку, которая ведёт в никуда. Такие ошибки неприятны тем, что выглядят мелкими, но ломают “красивый” контракт. Если чувствуете, что строковые URI начали путаться — переходите на ServletUriComponentsBuilder.

1
Задача
Spring REST & MVC, 7 уровень, 2 лекция
Недоступна
Создание тикета со статусом `201 Created`
Создание тикета со статусом `201 Created`
1
Задача
Spring REST & MVC, 7 уровень, 2 лекция
Недоступна
Удаление купона со статусом `204 No Content`
Удаление купона со статусом `204 No Content`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ