HTTP-сервер, JSON API и итоговый проект

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

Содержание курса

В предыдущем уроке мы научились регистрировать обработчики и формировать 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": ""}, что бесполезно.