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

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

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