Урок курса

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 → его нет в rawhas* = false → не трогаем
  • поле присутствует как nullraw["title"] == "null" → 400
  • поле присутствует с конкретным значением → проверяем типы вторым Unmarshal, проверяем непустоту для title, применяем

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

Попробуйте решить

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

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

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