Урок курса

JSON HTTP API: Task API с валидацией, единообразными ошибками и потокобезопасным хранилищем

Golang с нуля

В предыдущем уроке мы научились регистрировать обработчики и формировать HTTP-ответы вручную — устанавливать заголовки, вызывать WriteHeader, писать тело. Сейчас применим это в полноценном JSON API: несколько маршрутов, несколько методов, разные статусы, и всё это — с единым контрактом как для успешных ответов, так и для ошибок.

Контракт Task, Store с Mutex/RWMutex и единый JSON-ответ об ошибке

Начнём с того, что будет видно клиенту — с контракта данных.

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

Здесь нет omitempty — намеренно. Если Done равен false, клиент всё равно должен увидеть "done": false, а не отсутствующее поле. Клиент не должен угадывать, означает ли отсутствие поля false или «сервер не вернул это поле». То же касается ID: при создании задачи сервер сам назначает монотонно возрастающий положительный ID начиная с 1, клиент его не передаёт.

Теперь хранилище:

type Store struct {
    mu     sync.Mutex
    tasks  []Task
    nextID int
}

func NewStore() *Store {
    return &Store{nextID: 1}
}

nextID стартует с 1, потому что нулевой ID плохо различим от «не установлен». Каждый раз при добавлении задачи мы возьмём текущее значение, присвоим его задаче и увеличим счётчик — всё это под мьютексом.

Чем sync.RWMutex отличается от sync.Mutex? Обычный Mutex даёт только два состояния: заблокирован или свободен. RWMutex понимает разницу между читателями и писателями. Несколько горутин могут одновременно держать RLock — они читают, не мешая друг другу. Но если кто-то вызвал Lock (запись), все новые RLock блокируются, пока запись не завершится. Это полезно, когда чтений много, а записей мало.

Если объявить поле как mu sync.RWMutex, GET-обработчик может использовать:

s.mu.RLock()
defer s.mu.RUnlock()

а POST/PATCH/DELETE — обычный s.mu.Lock() / defer s.mu.Unlock(). При sync.Mutex метод RLock недоступен вообще — это разные типы.

Для нашего API трафик чтения вряд ли критически превысит запись, поэтому базовый sync.Mutex достаточен. RWMutex — осознанная оптимизация, а не обязательное требование.

Теперь об ошибках. Если одни эндпоинты возвращают text/plain, другие — application/json, а третьи — пустое тело с кодом 400, клиент вынужден разбирать каждый случай по-разному. Единый контракт проще: любая ошибка — это JSON с полем error.

type ErrorResponse struct {
    Error string `json:"error"`
}

func writeError(w http.ResponseWriter, code int, msg string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    json.NewEncoder(w).Encode(ErrorResponse{Error: msg})
}

Порядок важен: сначала Header().Set(...), потом WriteHeader(code), потом тело. Если вызвать WriteHeader раньше установки заголовка, заголовок уйдёт с дефолтным text/plain. Мы уже разбирали этот порядок применительно к http.ResponseWriter — здесь он работает ровно так же.

Клиент при любом сбое получит:

{"error": "title is required"}

с правильным Content-Type и кодом. msg никогда не должен быть пустой строкой — иначе клиент увидит {"error": ""}, что бесполезно.

Switch по r.Method, ответ 405 и маршрутизация /tasks и /tasks/{id}

Регистрируем два паттерна:

mux := http.NewServeMux()
mux.HandleFunc("/tasks", store.handleTasks)
mux.HandleFunc("/tasks/", store.handleTaskByID)

Паттерн /tasks без слеша на конце матчит только точный путь /tasks. Паттерн /tasks/ с завершающим слешем — это префикс, он поймает /tasks/1, /tasks/42 и всё остальное, начинающееся с /tasks/. Так стандартный ServeMux разграничивает «список» и «конкретный элемент».

Внутри каждого обработчика читаем r.Method:

func (s *Store) handleTasks(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodGet:
        s.listTasks(w, r)
    case http.MethodPost:
        s.createTask(w, r)
    default:
        writeError(w, http.StatusMethodNotAllowed, "method not allowed")
    }
}

Почему не положиться на ServeMux с method-qualified паттернами вроде "GET /tasks"? Потому что когда ServeMux сам формирует 405, он отдаёт текстовый ответ без гарантии application/json и нашего поля error. Клиент нашего API ожидает единый формат ошибок — и default-ветка с writeError это обеспечивает. Собственный switch выбран не из-за ограничений Go, а ради контракта.

Для /tasks/ нужно ещё извлечь ID из пути:

func (s *Store) handleTaskByID(w http.ResponseWriter, r *http.Request) {
    idStr := strings.TrimPrefix(r.URL.Path, "/tasks/")
    id, err := strconv.Atoi(idStr)
    if err != nil || id <= 0 {
        writeError(w, http.StatusNotFound, "task not found")
        return
    }
    switch r.Method {
    case http.MethodPatch:
        s.updateTask(w, r, id)
    case http.MethodDelete:
        s.deleteTask(w, r, id)
    default:
        writeError(w, http.StatusMethodNotAllowed, "method not allowed")
    }
}

strings.TrimPrefix отрезает /tasks/ — остаётся строка с числом. Если Atoi возвращает ошибку или число не положительное, это уже некорректный запрос, и 404 здесь уместнее 400: такого ресурса не существует.

GET /tasks и POST /tasks: декодирование, валидация title, последовательный ID

GET — самый простой случай. В sec_01 мы объявили mu sync.Mutex, поэтому здесь используем обычный Lock/Unlock — у sync.Mutex нет методов RLock/RUnlock, они доступны только у sync.RWMutex:

func (s *Store) listTasks(w http.ResponseWriter, r *http.Request) {
    s.mu.Lock()
    tasks := make([]Task, len(s.tasks))
    copy(tasks, s.tasks)
    s.mu.Unlock()

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(tasks)
}

Почему копируем под мьютексом? После Unlock другая горутина может модифицировать s.tasks. Если отдать ссылку на внутренний срез, json.Encode будет читать данные без блокировки — гонка. Копия под Lock — безопасный вариант.

Отдельный момент: json.Encode для пустого []Task{} вернёт [], а не null. Именно поэтому инициализируем срез через make — даже если задач нет, клиент получит массив, а не JSON null.

Вариант с RWMutex. Если заменить поле на mu sync.RWMutex, GET сможет использовать s.mu.RLock() / s.mu.RUnlock(), позволяя нескольким горутинам читать одновременно. POST/PATCH/DELETE по-прежнему вызывают s.mu.Lock(). Для нашего API трафик чтения вряд ли критически превысит запись, поэтому базовый sync.Mutex достаточен — RWMutex это осознанная оптимизация.

POST декодирует тело и проверяет title:

func (s *Store) createTask(w http.ResponseWriter, r *http.Request) {
    var dto struct {
        Title string `json:"title"`
    }
    if err := json.NewDecoder(r.Body).Decode(&dto); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON")
        return
    }
    if strings.TrimSpace(dto.Title) == "" {
        writeError(w, http.StatusBadRequest, "title is required")
        return
    }

    s.mu.Lock()
    task := Task{
        ID:    s.nextID,
        Title: strings.TrimSpace(dto.Title),
        Done:  false,
    }
    s.nextID++
    s.tasks = append(s.tasks, task)
    s.mu.Unlock()

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(task)
}

Несколько деталей, которые здесь работают вместе:

  • json.NewDecoder(r.Body).Decode(&dto) вернёт ошибку, если тело не является валидным JSON. Пустое тело или {bad json} — сразу 400.
  • strings.TrimSpace убирает пробелы с обоих концов. Строка из одних пробелов не считается корректным названием задачи.
  • Блокировка берётся только перед изменением состояния — декодирование и валидация происходят до Lock(). Это короткий критический участок.
  • s.nextID++ выполняется под мьютексом, поэтому два одновременных POST получат разные ID.
  • Ответ 201 с полным task — клиент сразу видит, какой ID был назначен.

DELETE в обработчике /tasks/{id} устроен тривиально — находим задачу по ID, удаляем из среза, возвращаем 204 без тела:

case http.MethodDelete:
    s.mu.Lock()
    defer s.mu.Unlock()
    for i, t := range s.tasks {
        if t.ID == id {
            s.tasks = append(s.tasks[:i], s.tasks[i+1:]...)
            w.WriteHeader(http.StatusNoContent)
            return
        }
    }
    writeError(w, http.StatusNotFound, "task not found")

204 не предполагает тела — поэтому просто w.WriteHeader(http.StatusNoContent) и выход.

PATCH /tasks/{id}: двухстадийное декодирование PatchTaskDTO, различение отсутствия, null и значения

Вот где однострочный json.Decode перестаёт работать.

Предположим, клиент отправляет {"done": false} — он хочет снять отметку выполнения. После json.Unmarshal в структуру с Done *bool поле получит nil при отсутствии ключа — и тоже nil при явном null. Различить «не передал» от «передал false» по результату Unmarshal в типизированную структуру невозможно.

Для этого сначала читаем тело целиком, затем разбираем его в map[string]json.RawMessage — это первая стадия, которая проверяет синтаксис JSON и фиксирует присутствие ключей:

func (s *Store) updateTask(w http.ResponseWriter, r *http.Request, id int) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        writeError(w, http.StatusBadRequest, "cannot read body")
        return
    }

    var raw map[string]json.RawMessage
    if err := json.Unmarshal(body, &raw); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON")
        return
    }

    _, hasTitle := raw["title"]
    _, hasDone := raw["done"]

    if !hasTitle && !hasDone {
        writeError(w, http.StatusBadRequest, "no fields to update")
        return
    }

    if hasTitle && string(raw["title"]) == "null" {
        writeError(w, http.StatusBadRequest, "title cannot be null")
        return
    }
    if hasDone && string(raw["done"]) == "null" {
        writeError(w, http.StatusBadRequest, "done cannot be null")
        return
    }

json.RawMessage — это просто []byte, отложенный разбор. Проверка string(raw["title"]) == "null" работает потому, что JSON-литерал null буквально выглядит как строка null в байтах.

Важно понимать: первый Unmarshal проверил только синтаксис JSON и наличие ключей. Он не проверял совместимость типов. Например, {"done": "yes"} успешно пройдёт raw-разбор, но "yes" нельзя декодировать в *bool. Поэтому ошибку второго Unmarshal нужно обязательно проверять:

    type PatchTaskDTO struct {
        Title *string `json:"title"`
        Done  *bool   `json:"done"`
    }
    var dto PatchTaskDTO
    if err := json.Unmarshal(body, &dto); err != nil {
        writeError(w, http.StatusBadRequest, "invalid field types")
        return
    }

    if hasTitle && strings.TrimSpace(*dto.Title) == "" {
        writeError(w, http.StatusBadRequest, "title cannot be empty")
        return
    }

Здесь разыменование *dto.Title безопасно: мы уже знаем, что hasTitle истинен, значение не null и Unmarshal не вернул ошибку — указатель установлен в ненулевой адрес.

Теперь применяем изменения. Критический участок должен охватывать только поиск и модификацию хранилища — HTTP-запись вне блокировки:

    s.mu.Lock()
    var updated Task
    found := false
    for i := range s.tasks {
        if s.tasks[i].ID == id {
            if hasTitle {
                s.tasks[i].Title = strings.TrimSpace(*dto.Title)
            }
            if hasDone {
                s.tasks[i].Done = *dto.Done
            }
            updated = s.tasks[i]
            found = true
            break
        }
    }
    s.mu.Unlock()

    if !found {
        writeError(w, http.StatusNotFound, "task not found")
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(updated)
}

Заметьте два момента. Первый: итерируем через for i := range s.tasks и изменяем s.tasks[i] напрямую — переменная цикла t в for i, t := range была бы копией, и изменения не сохранились бы в хранилище. Второй: s.mu.Unlock() вызывается явно до формирования HTTP-ответа. Держать мьютекс во время json.Encode — значит блокировать все остальные горутины на время сетевой записи, что противоречит идее короткого критического участка из урока про Mutex.

Итоговая логика трёх состояний одного поля:

  • поле отсутствует в JSON → его нет в raw → has* = false → не трогаем
  • поле присутствует как null → raw["title"] == "null" → 400
  • поле присутствует с конкретным значением → проверяем типы вторым Unmarshal, проверяем непустоту для title, применяем

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

Задача 1

Обработчик PATCH декодирует тело сначала в map[string]json.RawMessage, затем в PatchTaskDTO с Title *string и Done *bool. Клиент отправляет {"title":null,"done":true}. Что содержит raw map для ключа title и какой статус вернёт сервер?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

Разработчик зарегистрировал обработчик на /tasks/ и внутри него для неизвестного метода вызвал http.Error(w, "method not allowed", 405). В чём проблема с точки зрения контракта API?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Какой HTTP-статус должен вернуть сервер на запрос DELETE /tasks/5, если задача с ID 5 существует в хранилище? Напишите только число.

Без регистрации · результат не сохраняется

Задача 4

Список покупок через HTTP API

Любой, кто хоть раз вёл список дел в блокноте, знает, как быстро он превращается в хаос: зачёркнутые строки, исправления поверх исправлений, непонятно чьи пометки на полях. Программный список задач решает эту проблему с помощью строгого интерфейса: каждая задача появляется с уникальным номером, существует в определённом состоянии и исчезает только по явной команде. HTTP-сервер становится тем самым блокнотом, к которому могут обращаться разные клиенты одновременно, не мешая друг другу.

В основе такого сервиса лежит небольшой, но выверенный контракт. Клиент может запросить весь список или добавить новый пункт, указать конкретную задачу по идентификатору, частично обновить её поля или удалить её насовсем. Каждый ответ говорит на языке JSON, включая сообщения об ошибках — это позволяет любому потребителю API одинаково обрабатывать и успех, и отказ. Особого внимания требует частичное обновление: сервер должен отличать поле, которое клиент намеренно передал с пустым значением, от поля, которое клиент не упомянул вовсе, — это принципиально разные намерения.

Что нужно сделать

Реализуйте handlers.go для готовых типов и потокобезопасного Store. Контракт: GET /tasks, POST /tasks, PATCH /tasks/{id} и DELETE /tasks/{id}. Успешные GET, POST и PATCH возвращают JSON; ошибки, которые возвращают эти маршруты, всегда имеют форму {"error":"..."}. Неизвестный ID даёт JSON-ошибку 404, неизвестный метод на маршрутах Task API — 405, DELETE успешной задачи — 204 без тела. Пути вне /tasks и /tasks/{id} остаются стандартным 404 от http.ServeMux.

В PATCH прочитайте body один раз. Сначала проверьте присутствие ключей через map[string]json.RawMessage, чтобы отличить отсутствующее поле от null, затем декодируйте те же bytes в DTO с *string и *bool. Пустой объект, null, пустой title и сломанный JSON дают 400. Значение false является настоящим обновлением и не должно теряться.

Открыть задание в уроке →

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку