JavaRush /Курсы /Go SELF /Done endpoint — PATCH/POST семантика и ответы 200/204

Done endpoint — PATCH/POST семантика и ответы 200/204

Go SELF
61 уровень , 2 лекция
Открыта

1. Зачем вообще нужен done endpoint

Когда вы делаете CRUD для tasks, возникает момент истины: «Окей, задачи мы создаём и читаем, а как менять состояние?» В учебных проектах хочется просто написать task.Done = true и жить дальше. Но в HTTP‑API это превращается в отдельный контракт: какой метод, какой путь, что возвращаем, идемпотентно ли действие, что делать при повторных вызовах. И именно на таких «простых» местах чаще всего получается API, которое потом неудобно поддерживать.

Представим, что у нас задача — это ресурс, и у ресурса есть состояние done (выполнена/не выполнена). Клиент (CLI, фронтенд, другой сервис) должен уметь сказать серверу: «вот эту задачу отметь выполненной». Технически это update. Но update в HTTP бывает разным, и дальше мы разберём, как выбрать правильную форму и не запутать ни клиента, ни себя через неделю.

PATCH vs POST: что мы выбираем и почему

Если вы только начинаете, то вполне естественная мысль: «У нас есть действие “mark done”, значит надо POST: /tasks/{id}/done». Это логика “POST = сделать действие”. Она не совсем неправильная, но у неё есть побочные эффекты: вы смешиваете “ресурс” и “команду”, а также рискуете потерять идемпотентность (а потом удивляться, почему ретраи клиента ломают жизнь).

Для “изменить часть ресурса” в HTTP чаще всего подходит PATCH. Мысленно это звучит так: «Я не пересоздаю задачу целиком (не PUT), я не создаю под‑ресурс (не POST), я частично меняю существующий ресурс». И отметка “done” — как раз частичное изменение.

При этом у вас есть два популярных дизайна:

  • PATCH /api/v1/tasks/{id} с телом {"done": true}
  • PATCH /api/v1/tasks/{id}/done без тела (или с телом, но обычно без)

В рамках этого дня мы держим более прямолинейный и дружелюбный к новичкам вариант: PATCH /api/v1/tasks/{id}/done, потому что он проще читается глазами и проще валидируется (нам не нужно разбирать JSON только ради true). А ещё он хорошо укладывается в идею “endpoint на изменение состояния”.

Чтобы не превращать это в религиозную войну “PATCH vs POST”, зафиксируем полезную табличку — она не истина в последней инстанции, но отлично дисциплинирует мысли:

Вариант Пример Плюсы Минусы
PATCH
PATCH /tasks/10/done
ближе к “изменению части ресурса”, проще сделать идемпотентным иногда спорят, что “done” выглядит как команда
POST
POST /tasks/10/done
хорошо читается как действие чаще ассоциируется с “создать/запустить”, легко сделать неидемпотентным случайно

Мой практический совет: если действие по смыслу — изменение состояния ресурса, и вы хотите, чтобы повторный вызов не создавал хаоса, используйте PATCH.

2. Идемпотентность: почему повторный запрос не должен делать «ещё больше done»

Слово “идемпотентность” звучит как заклинание из книги по матану (и да, оно пугает). Но в HTTP это очень житейская идея: если клиент повторит запрос из‑за сетевого сбоя или таймаута, результат должен оставаться предсказуемым.

Для “mark done” хочется, чтобы:

  • первый вызов сделал задачу выполненной;
  • второй/третий/пятидесятый вызов не портил данные и не превращал done в “done done done”.

То есть операция по смыслу идемпотентна: после выполнения состояние одно и то же.

Отсюда вытекает важный контракт доменного слоя: метод MarkDone(id) должен вести себя спокойно, даже если задача уже done=true. Варианты поведения тут бывают разные, но для нашего учебного API удобно следующее:

  • если задача существует, мы гарантируем done=true и возвращаем успех;
  • если задача не существует, возвращаем ErrNotFound (возможно обёрнутый);
  • если id невалидный, это validation‑ошибка ещё до доменного слоя.

4. 200 vs 204: что возвращать клиенту

Теперь самое вкусное: что мы возвращаем в ответ на успешный “mark done”.

Есть два основных варианта:

  • 204 No Content — успех без тела ответа
  • 200 OK — успех с телом, например вернуть обновлённую задачу

Оба варианта допустимы, но они тянут за собой разный стиль работы клиента.

Вариант A: 204 No Content

Этот вариант говорит: «Я сделал. Ничего больше не скажу». Он хорош тем, что:

  • проще реализация (не нужен DTO, не нужен json.NewEncoder(w).Encode(...));
  • меньше трафика;
  • меньше шансов случайно “дрейфнуть” формат ответа.

Но есть строгий нюанс: при 204 нельзя писать body. То есть вообще. Даже "ok". Даже один пробел. Даже “честное слово, там всего 2 байта”. Некоторые прокси и клиенты будут вести себя странно.

Вариант B: 200 OK + JSON

Этот вариант говорит: «Я сделал и вот тебе итоговое состояние». Он хорош тем, что клиенту не нужно делать дополнительный GET, чтобы увидеть обновлённые поля.

Но здесь вы платите сложностью: вам нужно решить, что именно вернуть. Полную задачу? Только {"id":10,"done":true}? А ещё нужно держать DTO стабильным.

В рамках нашего дня мы выбираем 204 No Content как базовый, “чистый” и легко поддерживаемый контракт. Если вам прям очень хочется вернуть JSON, это тоже можно сделать, но тогда договоритесь об этом явно и придерживайтесь во всём API одинакового стиля.

Небольшая мини‑схема, чтобы закрепить, чем эти варианты отличаются по “ощущению”:

sequenceDiagram
    participant C as Client
    participant S as Server

    C->>S: PATCH /tasks/10/done
    alt 204 No Content
        S-->>C: 204 (no body)
        Note over C: Клиент при желании делает GET
    else 200 OK + body
        S-->>C: 200 + {"id":10,"done":true,...}
        Note over C: Клиент сразу видит итог
    end

5. Контракт endpoint’а: метод, путь, вход, выход

Сейчас мы зафиксируем контракт так, чтобы его можно было прочитать как “договор”, а не как “ну примерно так работает”.

Мы реализуем:

  • Метод: PATCH
  • Путь: /api/v1/tasks/{id}/done
  • Path params: {id} — обязателен, целое число > 0
  • Тело запроса: отсутствует
  • Успех: 204 No Content
  • Ошибки:
    • 400 (validation) если id пустой/не число/<=0
    • 404 (not_found) если задачи нет
    • 500 (internal) для прочих неожиданных проблем

Заметьте важную педантичность: “id не число” — это не 404. Это плохой ввод, а значит 400. 404 мы оставляем для ситуации “число нормальное, но такого ресурса нет”.

6. Роутинг в Go 1.22+: регистрируем handler

Прежде чем писать handler, ему нужно дать адрес в ServeMux. Раз у нас Go 1.22+ и мы используем patterns с методом, это выглядит очень читабельно: строка маршрута сама документирует контракт.

package main

import (
	"net/http"
)

func routes(s *Server) *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("PATCH /api/v1/tasks/{id}/done", s.handleMarkDone)
	return mux
}

Обратите внимание на важную мелочь: мы не “склеиваем строки” и не пытаемся вручную распарсить URL. Path‑параметр {id} потом достаётся через r.PathValue("id"). Это снимает кучу рутины и уменьшает шанс сделать ошибку в разборе пути.

7. Единый разбор {id}: parseID и ValidationError

Смысл parseID в нашем приложении не только в том, чтобы превратить строку в число. Главное — чтобы все endpoint’ы одинаково трактовали плохой id и возвращали одну и ту же форму validation‑ошибки.

Пример (напоминаю: это может быть уже написано ранее; здесь лишь фиксируем идею, какую ошибку хотим вернуть):

package main

import (
	"strconv"
)

func parseID(raw string) (int, error) {
	id, err := strconv.Atoi(raw)
	if err != nil || id <= 0 {
		return 0, &ValidationError{
			Fields: map[string]string{"id": "must be positive integer"},
		}
	}
	return id, nil
}

Важный момент: parseID возвращает именно ValidationError, чтобы writeError мог стабильно превратить это в 400 с fields. То есть обработчик не решает “что такое ошибка id” — он просто зовёт общий helper.

8. Handler: parse → app → status

Handler для done — отличный пример “короткого и честного кода”. Он должен выглядеть максимально линейно: разобрали id, вызвали доменную операцию, отдали 204. Никаких JSON‑энкодеров, никаких “а давайте ещё лог напечатаем в ответ”.

package main

import (
	"net/http"
)

func (s *Server) handleMarkDone(w http.ResponseWriter, r *http.Request) {
	id, err := parseID(r.PathValue("id"))
	if err != nil {
		writeError(w, err)
		return
	}

	if err := s.app.MarkDone(r.Context(), id); err != nil {
		writeError(w, err)
		return
	}

	w.WriteHeader(http.StatusNoContent) // 204
}

Если вы поймаете себя на мысли “а может напишем fmt.Fprintln(w, "ok")?”, остановитесь. При 204 это будет нарушением контракта. В мире API “я чуть-чуть написал body” — это как “я чуть-чуть сломал протокол”: вроде мелочь, но потом кто-то будет дебажить это ночью.

9. Доменный слой: как вернуть not_found

HTTP‑слой у нас не должен “понимать базу данных” или детали хранилища. Он умеет маппить доменные классы ошибок в HTTP‑коды. Поэтому доменный слой обязан вернуть ошибку так, чтобы классификация работала стабильно.

Мы договорились, что not found — это sentinel ErrNotFound, распознаваемый через errors.Is. И очень важно: если вы добавляете контекст через fmt.Errorf, используйте %w, чтобы ошибка оставалась распознаваемой в цепочке. В Go это стандартная практика работы с sentinel errors.

Вот минимальный пример доменного метода:

package main

import (
	"context"
	"fmt"
)

func (a *App) MarkDone(ctx context.Context, id int) error {
	ok := a.store.SetDone(id, true)
	if !ok {
		return fmt.Errorf("mark done %d: %w", id, ErrNotFound)
	}
	return nil
}

Почему мы так делаем? Потому что errors.Is(err, ErrNotFound) будет работать даже при оборачивании (wrapping), и это намного надёжнее, чем err == ErrNotFound.

10. Если задача уже выполнена: ошибка или успех?

Это место, где многие новички (и не только новички) начинают спорить с реальностью. Кажется логичным: “Если она уже done, значит это ошибка”. Но в API это часто превращается в неудобство: клиенту приходится сначала делать GET, чтобы узнать состояние, потом решать, вызывать PATCH или нет. А если два клиента одновременно? А если UI на телефоне и сеть прыгает?

Для нашего учебного контракта удобно и практично правило: если задача существует, MarkDone всегда успех, даже если она уже done=true. Это делает операцию естественно идемпотентной. Клиент может повторять запрос безопасно, и состояние не ломается.

Если вам очень хочется сообщить клиенту “она уже была выполнена”, это можно сделать только если вы возвращаете 200 с телом и, например, полем updated: false. Но это усложняет контракт, и для базового курса мы это не берём: 204 проще, чище, меньше поверхностей для багов.

11. Альтернативы: POST и ответ 200

Если всё-таки POST

Иногда вы встретите API, где делают POST /tasks/{id}/done. Это особенно часто в системах, где всё построено вокруг “команд” (command‑style API) или событий (event‑style). Там POST означает “создай событие/соверши действие”, и это нормально в своей архитектуре.

Но если вы выбрали POST, вам нужно быть вдвойне внимательным к идемпотентности. Если клиент повторит POST из‑за ретрая, а ваш сервер наивно пишет “событие done” в лог, очередь или историю, вы можете получить два события вместо одного. Иногда это допустимо, иногда ломает аналитику, иногда ломает биллинг (и тогда у вас появляется новый друг — бухгалтер).

Поэтому в рамках нашего проекта PATCH остаётся более безопасным выбором, потому что семантика “частичного обновления” изначально ближе к “сделай done=true”.

Если вернуть 200 OK + JSON

Чтобы вы не думали, что 204 — единственная религия, покажу, как выглядел бы “200 OK + вернуть задачу”. Это полезно хотя бы для понимания, какую цену вы платите за удобство клиента.

Предположим, MarkDone возвращает обновлённый DTO:

package main

import (
	"context"
)

type TaskDTO struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

type TaskApp interface {
	MarkDone(ctx context.Context, id int) (TaskDTO, error)
}

Тогда handler будет уже не таким минимальным:

package main

import (
	"encoding/json"
	"net/http"
)

func (s *Server) handleMarkDone200(w http.ResponseWriter, r *http.Request) {
	id, err := parseID(r.PathValue("id"))
	if err != nil {
		writeError(w, err)
		return
	}

	task, err := s.app.MarkDone(r.Context(), id)
	if err != nil {
		writeError(w, err)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	_ = json.NewEncoder(w).Encode(task) // 200 по умолчанию
}

Заметьте, как сразу появляется больше решений: какой DTO, какие поля, что делать с Title (вдруг мы не хотим его раскрывать?), как держать совместимость. Поэтому в текущем модуле мы предпочитаем 204: он учит аккуратному минимализму и стабильным контрактам.

12. Типичные ошибки

Ошибка №1: возвращают 404, когда id не число.
Это одна из самых частых логических ошибок: “ну раз не нашли, то 404”. Но id=abc — это не “не нашли ресурс”, это “клиент прислал мусор”. Поэтому сначала всегда идёт валидация и 400 validation, а 404 not_found — только когда id валиден, но сущности нет.

Ошибка №2: пишут body при 204 No Content.
Часто это выглядит невинно: w.WriteHeader(204) и потом fmt.Fprintln(w, "ok"). На локальных тестах “вроде работает”, а потом клиент или прокси начинает чудить. Если контракт 204 — значит после WriteHeader вы просто выходите из handler’а и не пытаетесь “быть полезным текстиком”.

Ошибка №3: делают err == ErrNotFound и теряют not found при wrapping.
Когда доменный слой добавляет контекст через fmt.Errorf("...: %w", ErrNotFound), сравнение err == ErrNotFound перестаёт работать. Правильный путь для sentinel — errors.Is(err, ErrNotFound), потому что он идёт по цепочке обёрток.

Ошибка №4: реализуют “done” как неидемпотентное действие.
Иногда внутри делают “переключатель” (done = !done) или “если уже done — возвращаем ошибку”. Это превращает повторный запрос (который в реальности случается из‑за ретраев) в источник случайных багов. Для базового API проще и надёжнее контракт “после вызова done=true”.

Ошибка №5: плодят разные разборы {id} в разных handler’ах.
Один handler считает id=0 нормальным, другой — ошибкой; один возвращает {"id":"bad"}, другой — {"path":"bad"}. Снаружи это выглядит как будто у вас два разных API в одном. Лечится просто: единый parseID и единый ValidationError, а дальше один writeError на всех.

1
Задача
Go SELF, 61 уровень, 2 лекция
Недоступна
Проверка идентификатора
Проверка идентификатора
1
Задача
Go SELF, 61 уровень, 2 лекция
Недоступна
PATCH без тела
PATCH без тела
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ