Опирается на правила:
R-RSP-1..8,R-RSP-X1..X4,R-FLD-1..7→ раздел JSON и ответы.
Важно знать
- camelCase для всех JSON-полей:
json:"orderId",json:"createdAt".omitemptyна необязательных полях —nullне появляется в 2xx-ответах.- Не используй
*Tв response-структурах — указатели тянутnull.- Коллекция —
{"content": [...], "page": 1, "size": 20, "total": 150}.- Пустая коллекция —
"content": [], никогдаnull.- Создание —
201 Created+Location+ тело ресурса.- Удаление —
204 No Content, пустое тело.- Деньги —
int64в минорных единицах (копейки), неfloat64.- Enum — UPPER_SNAKE_CASE константы (
"NEW","CONFIRMED").
Хелперы ответов
Единые функции для всех хендлеров:
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("writeJSON failed", "err", err)
}
}
func writeNoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
Структуры ответа
R-FLD-1..7:
type OrderResponse struct {
OrderID string `json:"orderId"`
CustomerID string `json:"customerId"`
Status string `json:"status"` // "NEW", "CONFIRMED", "SHIPPED"
Total int64 `json:"total"` // копейки (R-FLD: деньги int64)
Currency string `json:"currency"` // "RUB"
CreatedAt time.Time `json:"createdAt"` // ISO 8601 автоматически
UpdatedAt time.Time `json:"updatedAt"`
Items []OrderItemResponse `json:"items"`
Note string `json:"note,omitempty"` // необязательное поле
}
type OrderItemResponse struct {
ItemID string `json:"itemId"`
ProductID string `json:"productId"`
Name string `json:"name"`
Quantity int `json:"quantity"`
Price int64 `json:"price"`
}
omitempty убирает поле из JSON, если оно пустое ("", 0, false, nil). Это реализует R-RSP-X1 — null не появляется в 2xx.
type ProductResponse struct {
ProductID string `json:"productId"`
Name string `json:"name"`
Description string `json:"description,omitempty"` // отсутствует если ""
IsActive bool `json:"isActive"`
CreatedAt time.Time `json:"createdAt"`
}
Не используй *string в response-структурах — указатели сериализуются в null:
// ✗ — null в 2xx
type Bad struct {
Note *string `json:"note"`
}
// ✓ — поле отсутствует если ""
type Good struct {
Note string `json:"note,omitempty"`
}
Единичный ресурс
R-RSP-1: плоский объект без обёртки.
func getOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
order, err := svc.GetOrder(r.Context(), id)
if err != nil {
httperr.Write(w, r, err)
return
}
writeJSON(w, http.StatusOK, toOrderResponse(order))
}
{
"orderId": "a3f2d1",
"customerId": "c-101",
"status": "CONFIRMED",
"total": 49900,
"currency": "RUB",
"createdAt": "2026-03-14T09:00:00Z",
"updatedAt": "2026-03-14T09:05:00Z",
"items": [...]
}
R-RSP-X4: envelope-обёртка запрещена — не {"data": {...}}.
Коллекция с пагинацией
R-RSP-2:
type PageResponse[T any] struct {
Content []T `json:"content"`
Page int `json:"page"`
Size int `json:"size"`
Total int `json:"total"`
}
func toPageResponse[T any](items []T, page, size, total int) PageResponse[T] {
if items == nil {
items = []T{} // пустой slice, не nil — R-RSP-7
}
return PageResponse[T]{
Content: items,
Page: page,
Size: size,
Total: total,
}
}
func listOrders(w http.ResponseWriter, r *http.Request) {
q, violations := parseListOrdersQuery(r)
if len(violations) > 0 {
writeValidationProblem(w, violations, traceIDFromCtx(r.Context()))
return
}
result, err := svc.ListOrders(r.Context(), q)
if err != nil {
httperr.Write(w, r, err)
return
}
resp := toPageResponse(toOrderResponses(result.Items), q.Page, q.Size, result.Total)
writeJSON(w, http.StatusOK, resp)
}
Пустая коллекция (R-RSP-7):
{"content": [], "page": 1, "size": 20, "total": 0}
Создание ресурса — 201 + Location
R-RSP-3:
func createOrder(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid request body"))
return
}
if err := validate.Struct(req); err != nil {
writeValidationProblem(w, toViolations(err.(validator.ValidationErrors)),
traceIDFromCtx(r.Context()))
return
}
order, err := svc.CreateOrder(r.Context(), toCreateOrderCommand(req))
if err != nil {
httperr.Write(w, r, err)
return
}
w.Header().Set("Location", "/api/v1/orders/"+order.ID)
writeJSON(w, http.StatusCreated, toOrderResponse(order))
}
PUT/PATCH — 200 + обновлённый ресурс
R-RSP-4:
type PatchOrderRequest struct {
Note *string `json:"note"` // nil = не трогать; explicit null = удалить (R-FLD-6)
}
func patchOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req PatchOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid request body"))
return
}
order, err := svc.PatchOrder(r.Context(), id, req.Note)
if err != nil {
httperr.Write(w, r, err)
return
}
writeJSON(w, http.StatusOK, toOrderResponse(order))
}
DELETE — 204 No Content
R-RSP-5:
func deleteOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := svc.DeleteOrder(r.Context(), id); err != nil {
httperr.Write(w, r, err)
return
}
writeNoContent(w)
}
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
null поле в 2xx (*string без omitempty) | R-RSP-X1 | string + omitempty |
"" пустая строка вместо отсутствия | R-RSP-X2 | omitempty или не включать |
{"data": {...}} envelope | R-RSP-X4 | плоский объект |
"content": null при пустой коллекции | R-RSP-7 | "content": [] |
float64 для денежных значений | R-FLD-1 | int64 в минорных единицах |
snake_case в json-тегах | R-FLD-1 | camelCase |
201 Created без Location | R-RSP-3 | обязателен |
200 OK на создание | R-RSP-3 | 201 Created |
Куда дальше
- go/query-params.md — параметры для
listOrders. - go/errors.md — формат ошибочного ответа (противопоставление).
- go/headers.md —
Location,Content-Type,Idempotency-Key. - go/openapi-and-antipatterns.md — схема
PageResponseв спеке.