JSON HTTP API: Task API с валидацией, единообразными ошибками и потокобезопасным хранилищем
Содержание курса
Список покупок через HTTP API
Любой, кто хоть раз вёл список дел в блокноте, знает, как быстро он превращается в хаос: зачёркнутые строки, исправления поверх исправлений, непонятно чьи пометки на полях. Программный список задач решает эту проблему с помощью строгого интерфейса: каждая задача появляется с уникальным номером, существует в определённом состоянии и исчезает только по явной команде. HTTP-сервер становится тем самым блокнотом, к которому могут обращаться разные клиенты одновременно, не мешая друг другу.
В основе такого сервиса лежит небольшой, но выверенный контракт. Клиент может запросить весь список или добавить новый пункт, указать конкретную задачу по идентификатору, частично обновить её поля или удалить её насовсем. Каждый ответ говорит на языке JSON, включая сообщения об ошибках — это позволяет любому потребителю API одинаково обрабатывать и успех, и отказ. Особого внимания требует частичное обновление: сервер должен отличать поле, которое клиент намеренно передал с пустым значением, от поля, которое клиент не упомянул вовсе, — это принципиально разные намерения.
Что нужно сделать
Реализуйте handlers.go для готовых типов и потокобезопасного Store. Контракт: GET /tasks, POST /tasks, PATCH /tasks/{id} и DELETE /tasks/{id}. Успешные GET, POST и PATCH возвращают JSON; ошибки, которые возвращают эти маршруты, всегда имеют форму {"error":"..."}. Неизвестный ID даёт JSON-ошибку 404, неизвестный метод на маршрутах Task API — 405, DELETE успешной задачи — 204 без тела. Пути вне /tasks и /tasks/{id} остаются стандартным 404 от http.ServeMux.
В PATCH прочитайте body один раз. Сначала проверьте присутствие ключей через map[string]json.RawMessage, чтобы отличить отсутствующее поле от null, затем декодируйте те же bytes в DTO с *string и *bool. Пустой объект, null, пустой title и сломанный JSON дают 400. Значение false является настоящим обновлением и не должно теряться.
О данных в ответах
Используйте учебные данные. Не вставляйте пароли, токены, ключи доступа, паспортные и банковские данные, а также персональные данные других людей. Политика обработки данных.
- Public go test ./...
- healthGET /health → 200
- empty listGET /tasks → 200
- createPOST /tasks → 201
Сначала выполняется go test ./..., затем сервис собирается и проверяется HTTP-запросами. Внешняя сеть и скачивание модулей отключены.
model.go
package main
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
type CreateTaskDTO struct {
Title string `json:"title"`
}
type PatchTaskDTO struct {
Title *string `json:"title"`
Done *bool `json:"done"`
}
type errorResponse struct {
Error string `json:"error"`
}
store.go
package main
import "sync"
type Store struct {
mu sync.RWMutex
tasks []Task
nextID int
}
func NewStore() *Store {
return &Store{tasks: make([]Task, 0), nextID: 1}
}
func (s *Store) Add(title string) Task {
s.mu.Lock()
defer s.mu.Unlock()
task := Task{ID: s.nextID, Title: title, Done: false}
s.nextID++
s.tasks = append(s.tasks, task)
return task
}
func (s *Store) GetAll() []Task {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]Task, len(s.tasks))
copy(result, s.tasks)
return result
}
func (s *Store) Update(id int, dto PatchTaskDTO) (Task, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.tasks {
if s.tasks[i].ID != id {
continue
}
if dto.Title != nil {
s.tasks[i].Title = *dto.Title
}
if dto.Done != nil {
s.tasks[i].Done = *dto.Done
}
return s.tasks[i], true
}
return Task{}, false
}
func (s *Store) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.tasks {
if s.tasks[i].ID == id {
s.tasks = append(s.tasks[:i], s.tasks[i+1:]...)
return true
}
}
return false
}
main.go
package main
import (
"log"
"net/http"
)
func main() {
if err := http.ListenAndServe(":18080", newMux(NewStore())); err != nil {
log.Fatal(err)
}
}
handlers_test.go
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func perform(t *testing.T, mux http.Handler, method, path, body string) *http.Response {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
return rr.Result()
}
func requireJSON(t *testing.T, resp *http.Response) {
t.Helper()
if !strings.Contains(resp.Header.Get("Content-Type"), "application/json") {
t.Fatalf("content-type=%q", resp.Header.Get("Content-Type"))
}
}
func decodeTask(t *testing.T, resp *http.Response) Task {
t.Helper()
defer resp.Body.Close()
requireJSON(t, resp)
var got Task
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decode task: %v", err)
}
return got
}
func requireError(t *testing.T, resp *http.Response, status int) {
t.Helper()
defer resp.Body.Close()
if resp.StatusCode != status { t.Fatalf("status=%d want=%d", resp.StatusCode, status) }
requireJSON(t, resp)
var got map[string]any
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { t.Fatalf("decode error: %v", err) }
message, ok := got["error"].(string)
if !ok || strings.TrimSpace(message) == "" { t.Fatalf("error object=%#v", got) }
}
func TestTaskAPI(t *testing.T) {
t.Run("get empty", func(t *testing.T) {
resp := perform(t, newMux(NewStore()), http.MethodGet, "/tasks", "")
if resp.StatusCode != http.StatusOK { t.Fatalf("status=%d", resp.StatusCode) }
defer resp.Body.Close(); requireJSON(t, resp)
var got []Task
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil || len(got) != 0 { t.Fatalf("list=%#v err=%v", got, err) }
})
t.Run("post created", func(t *testing.T) {
resp := perform(t, newMux(NewStore()), http.MethodPost, "/tasks", `{"title":"buy tea"}`)
if resp.StatusCode != http.StatusCreated { t.Fatalf("status=%d", resp.StatusCode) }
got := decodeTask(t, resp)
if got != (Task{ID: 1, Title: "buy tea", Done: false}) { t.Fatalf("task=%#v", got) }
})
t.Run("patch false", func(t *testing.T) {
store := NewStore(); seed := store.Add("x"); yes := true
store.Update(seed.ID, PatchTaskDTO{Done: &yes})
resp := perform(t, newMux(store), http.MethodPatch, "/tasks/1", `{"done":false}`)
if resp.StatusCode != http.StatusOK { t.Fatalf("status=%d", resp.StatusCode) }
got := decodeTask(t, resp)
if got != (Task{ID: 1, Title: "x", Done: false}) { t.Fatalf("task=%#v", got) }
})
t.Run("title only preserves done", func(t *testing.T) {
store := NewStore(); seed := store.Add("x"); yes := true
store.Update(seed.ID, PatchTaskDTO{Done: &yes})
resp := perform(t, newMux(store), http.MethodPatch, "/tasks/1", `{"title":" y "}`)
if resp.StatusCode != http.StatusOK { t.Fatalf("status=%d", resp.StatusCode) }
got := decodeTask(t, resp)
if got != (Task{ID: 1, Title: "y", Done: true}) { t.Fatalf("task=%#v", got) }
})
for _, tc := range []struct{name, body string}{
{"empty patch", `{}`},
{"null title", `{"title":null}`},
{"null done", `{"done":null}`},
{"combined null", `{"title":null,"done":true}`},
} {
t.Run(tc.name, func(t *testing.T) {
store := NewStore(); store.Add("x")
requireError(t, perform(t, newMux(store), http.MethodPatch, "/tasks/1", tc.body), http.StatusBadRequest)
})
}
t.Run("post blank", func(t *testing.T) { requireError(t, perform(t, newMux(NewStore()), http.MethodPost, "/tasks", `{"title":" "}`), http.StatusBadRequest) })
t.Run("post malformed", func(t *testing.T) { requireError(t, perform(t, newMux(NewStore()), http.MethodPost, "/tasks", `{`), http.StatusBadRequest) })
t.Run("patch missing", func(t *testing.T) { requireError(t, perform(t, newMux(NewStore()), http.MethodPatch, "/tasks/99", `{"done":true}`), http.StatusNotFound) })
t.Run("delete", func(t *testing.T) {
store := NewStore(); store.Add("x")
resp := perform(t, newMux(store), http.MethodDelete, "/tasks/1", "")
if resp.StatusCode != http.StatusNoContent { t.Fatalf("status=%d", resp.StatusCode) }
defer resp.Body.Close(); data, _ := io.ReadAll(resp.Body)
if len(data) != 0 { t.Fatalf("body=%q", data) }
})
t.Run("delete missing", func(t *testing.T) { requireError(t, perform(t, newMux(NewStore()), http.MethodDelete, "/tasks/99", ""), http.StatusNotFound) })
t.Run("method", func(t *testing.T) { requireError(t, perform(t, newMux(NewStore()), http.MethodPut, "/tasks", ""), http.StatusMethodNotAllowed) })
}
Отправьте решение, чтобы увидеть результаты тестов.
