Урок курса
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, зато даёт точный контроль над тем, что именно клиент передал, и корректно отклоняет неверные типы на второй стадии.
Попробуйте решить
Обработчик PATCH декодирует тело сначала в map[string]json.RawMessage, затем в PatchTaskDTO с Title *string и Done *bool. Клиент отправляет {"title":null,"done":true}. Что содержит raw map для ключа title и какой статус вернёт сервер?
Продолжить с проверкой и прогрессом
Откройте интерактивный раннер с заданиями урока.
