Опирается на правила:
R-QRY-1..9иR-QRY-X1..X5→ раздел Query-параметры.
Важно знать
- camelCase для имён параметров:
createdFrom,pageSize,customerId.page— 1-based;page=0нарушает контракт — возвращай400.- Диапазоны: суффиксы
From/To(createdFrom,amountTo).- Массивы — повтор параметра:
?status=NEW&status=PAID, читай черезr.Form["status"].- Запятые в значениях массива (
?status=NEW,PAID) — запрещено.- Cursor — непрозрачный токен; клиент не парсит его структуру.
- Сложный поиск —
POST /resources/searchс JSON-телом.- Бизнес-логика в query-параметре (вместо action) — запрещена.
Структура query-объекта
R-QRY-1..6:
type ListOrdersQuery struct {
CustomerID string `schema:"customerId"`
Statuses []string `schema:"status"`
CreatedFrom string `schema:"createdFrom"`
CreatedTo string `schema:"createdTo"`
AmountFrom int64 `schema:"amountFrom"`
AmountTo int64 `schema:"amountTo"`
Q string `schema:"q"`
Sort string `schema:"sort"`
Page int `schema:"page"`
Size int `schema:"size"`
}
Парсинг вручную (без сторонних библиотек)
Стандартный подход для net/http — ручной парсинг с явной валидацией:
func parseListOrdersQuery(r *http.Request) (ListOrdersQuery, []Violation) {
if err := r.ParseForm(); err != nil {
return ListOrdersQuery{}, []Violation{{Field: "", Code: "INVALID_QUERY", Message: "malformed query string"}}
}
q := ListOrdersQuery{Page: 1, Size: 20}
var violations []Violation
// массив через повтор параметра (R-QRY-8)
q.Statuses = r.Form["status"]
// строковые параметры
q.CustomerID = r.URL.Query().Get("customerId")
q.CreatedFrom = r.URL.Query().Get("createdFrom")
q.CreatedTo = r.URL.Query().Get("createdTo")
q.Q = r.URL.Query().Get("q")
q.Sort = r.URL.Query().Get("sort")
// page — 1-based (R-QRY-4, R-QRY-X2)
if v := r.URL.Query().Get("page"); v != "" {
page, err := strconv.Atoi(v)
if err != nil || page < 1 {
violations = append(violations, Violation{
Field: "page", Code: "INVALID_VALUE",
Message: "page должен быть >= 1",
})
} else {
q.Page = page
}
}
// size
if v := r.URL.Query().Get("size"); v != "" {
size, err := strconv.Atoi(v)
if err != nil || size < 1 || size > 100 {
violations = append(violations, Violation{
Field: "size", Code: "INVALID_VALUE",
Message: "size должен быть от 1 до 100",
})
} else {
q.Size = size
}
}
// числовые диапазоны
if v := r.URL.Query().Get("amountFrom"); v != "" {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
violations = append(violations, Violation{
Field: "amountFrom", Code: "INVALID_VALUE",
Message: "amountFrom должен быть целым числом",
})
} else {
q.AmountFrom = n
}
}
return q, violations
}
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
}
writeJSON(w, http.StatusOK, toPageResponse(result))
}
Cursor-based пагинация
R-QRY-5: cursor — непрозрачный токен.
type ListProductsQuery struct {
Cursor string `schema:"cursor"`
Size int `schema:"size"`
}
type CursorPageResponse[T any] struct {
Content []T `json:"content"`
NextCursor string `json:"nextCursor,omitempty"`
HasMore bool `json:"hasMore"`
}
func listProducts(w http.ResponseWriter, r *http.Request) {
cursor := r.URL.Query().Get("cursor")
size := 20
if v := r.URL.Query().Get("size"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 100 {
size = n
}
}
result, err := svc.ListProducts(r.Context(), cursor, size)
if err != nil {
httperr.Write(w, r, err)
return
}
resp := CursorPageResponse[ProductResponse]{
Content: toProductResponses(result.Items),
HasMore: result.HasMore,
}
if result.HasMore {
resp.NextCursor = result.NextCursor // непрозрачный токен (base64 / UUID)
}
writeJSON(w, http.StatusOK, resp)
}
R-QRY-X5: клиент не должен парсить cursor — это внутренняя структура сервера.
Сортировка
R-QRY-6: параметр sort, формат поле,направление.
GET /api/v1/orders?sort=createdAt,desc
GET /api/v1/orders?sort=total,asc&sort=createdAt,desc
func parseSortParam(sortParam []string) []SortField {
var fields []SortField
for _, s := range sortParam {
parts := strings.Split(s, ",")
if len(parts) != 2 {
continue
}
field, dir := parts[0], parts[1]
if dir != "asc" && dir != "desc" {
continue
}
fields = append(fields, SortField{Field: field, Dir: dir})
}
return fields
}
// читается как повтор параметра:
sortParams := r.Form["sort"]
sorts := parseSortParam(sortParams)
Полнотекстовый поиск
R-QRY-7: параметр q.
func listCustomers(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
page := 1
// ... парсинг page/size ...
result, err := svc.SearchCustomers(r.Context(), q, page, size)
// ...
}
Сложный поиск
R-QRY-9: POST /resources/search с JSON.
r.Post("/orders/search", searchOrders)
type SearchOrdersRequest struct {
CustomerIDs []string `json:"customerIds"`
Statuses []string `json:"statuses"`
AmountFrom int64 `json:"amountFrom"`
AmountTo int64 `json:"amountTo"`
Tags []string `json:"tags"`
Page int `json:"page"`
Size int `json:"size"`
}
func searchOrders(w http.ResponseWriter, r *http.Request) {
var req SearchOrdersRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid request body"))
return
}
if req.Page < 1 {
req.Page = 1
}
if req.Size < 1 || req.Size > 100 {
req.Size = 20
}
result, err := svc.SearchOrders(r.Context(), req)
if err != nil {
httperr.Write(w, r, err)
return
}
writeJSON(w, http.StatusOK, toPageResponse(result))
}
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
?customer_id=, ?CustomerID= | R-QRY-X1 | ?customerId= |
?page=0 или 0-based | R-QRY-X2 | ?page=1 |
?status=NEW,PAID через запятую | R-QRY-X3 | ?status=NEW&status=PAID |
?action=confirm вместо action-эндпоинта | R-QRY-X4 | POST /orders/{id}/confirm |
| Парсинг cursor на клиенте | R-QRY-X5 | cursor — непрозрачный токен |
Бизнес-фильтр через query вместо POST /search | R-QRY-9 | JSON-тело при сложной логике |
Куда дальше
- go/url-and-resources.md — path-параметры chi.
- go/json-and-responses.md — формат
PageResponseсcontent. - go/errors.md —
VALIDATION_ERRORпри неверных параметрах. - go/alias-and-actions.md — когда query → action-эндпоинт.