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

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

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

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) и выход.