JSON HTTP API: Task API с валидацией, единообразными ошибками и потокобезопасным хранилищем
Содержание курса
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, зато даёт точный контроль над тем, что именно клиент передал, и корректно отклоняет неверные типы на второй стадии.
