Опирается на правила: 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-basedR-QRY-X2?page=1
?status=NEW,PAID через запятуюR-QRY-X3?status=NEW&status=PAID
?action=confirm вместо action-эндпоинтаR-QRY-X4POST /orders/{id}/confirm
Парсинг cursor на клиентеR-QRY-X5cursor — непрозрачный токен
Бизнес-фильтр через query вместо POST /searchR-QRY-9JSON-тело при сложной логике

Куда дальше

  • 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-эндпоинт.