← назад к разделу

Когда клиент делает запрос GET /orders?status=NEW&page=2&sort=createdAt,desc — все эти штуки после знака вопроса называются query-параметрами. В Go они не парсятся автоматически, как в некоторых других фреймворках: их нужно вытащить вручную из объекта запроса. Разберёмся, как делать это правильно.

Как читать параметры из запроса

В стандартном net/http все query-параметры доступны через r.URL.Query():

// GET /orders?customerId=42&status=NEW
customerID := r.URL.Query().Get("customerId")  // "42"
status     := r.URL.Query().Get("status")      // "NEW"

Если параметр отсутствует — Get вернёт пустую строку. Всё просто.

Важный момент с именами: параметры пишутся в camelCasecustomerId, pageSize, createdFrom. Варианты customer_id или CustomerID — ошибка: они нарушают единообразие API и сломают клиентов, которые уже используют camelCase.

Структура запроса с фильтрами

Удобно собрать все параметры в одну структуру — так сигнатура функции остаётся чистой и все параметры видны сразу:

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"`
}

Диапазоны задаются парой полей с суффиксами From / To: createdFrom и createdTo, amountFrom и amountTo. Это предсказуемый паттерн — клиент сразу понимает, как фильтровать по промежутку.

Парсинг и валидация параметров

Числа, массивы и необязательные значения требуют явной обработки. Вот полный пример функции парсинга:

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

    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")

    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
        }
    }

    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))
}

Несколько вещей, на которые стоит обратить внимание:

page начинается с 1, не с 0. Это важно: page=0 — некорректный запрос, нужно вернуть 400 Bad Request. Нумерация с нуля противоречит ожиданиям большинства клиентов.

size ограничен сверху. Без ограничения клиент может случайно запросить миллион записей — это защита от перегрузки.

Дефолты устанавливаются сразу. Page: 1, Size: 20 — если клиент не передал параметр, используются разумные значения.

Массивы: повтор параметра, не запятые

Типичная ошибка при передаче нескольких значений — разделять их запятой:

GET /orders?status=NEW,PAID   ← неправильно

Правильный способ — повторить параметр:

GET /orders?status=NEW&status=PAID   ← правильно

В Go это читается через r.Form["status"] (не через .Get):

if err := r.ParseForm(); err != nil { /* ... */ }
statuses := r.Form["status"]  // ["NEW", "PAID"]

Вариант с запятой плохо переносится между фреймворками и делает значения неоднозначными — что, если само значение содержит запятую?

Пагинация

Offset-пагинация (page + size)

Самый простой вариант: клиент передаёт номер страницы и размер:

GET /orders?page=1&size=20

В ответе возвращается общее количество записей и содержимое страницы:

{
  "content": [...],
  "page": 1,
  "size": 20,
  "totalElements": 157,
  "totalPages": 8
}

Подходит для небольших наборов данных. На больших таблицах offset-пагинация замедляется, потому что база всё равно перебирает пропущенные строки.

Cursor-пагинация

Вместо номера страницы клиент получает непрозрачный токен (cursor) и передаёт его в следующем запросе:

GET /products              → возвращает nextCursor: "eyJpZCI6NTB9"
GET /products?cursor=eyJpZCI6NTB9  → следующая страница

Ключевое слово — непрозрачный. Клиент не знает, что внутри токена (это может быть base64 от ID, timestamp или что угодно). Он просто передаёт его обратно как есть:

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
    }
    writeJSON(w, http.StatusOK, resp)
}

Cursor-пагинация работает стабильно даже при активной записи в базу: страница не «съезжает», если между запросами добавились новые записи.

Сортировка

Параметр sort принимает пары поле,направление, разделённые запятой. Для нескольких полей — повтор параметра:

GET /orders?sort=createdAt,desc
GET /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)

Некорректные значения направления игнорируются — сервер не падает от неожиданного параметра.

Полнотекстовый поиск

Для поиска по тексту используется параметр q:

GET /customers?q=Иванов
func listCustomers(w http.ResponseWriter, r *http.Request) {
    q    := r.URL.Query().Get("q")
    page := 1
    size := 20
    result, err := svc.SearchCustomers(r.Context(), q, page, size)
    // ...
}

Когда нужен POST /search вместо GET

Query-параметры хорошо работают для простой фильтрации. Но когда логика поиска становится сложной — много фильтров, вложенные условия, большие списки ID — GET-запрос с длинной строкой параметров становится неудобным и может упереться в ограничение длины URL.

В таких случаях делают отдельный эндпоинт 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))
}

Ещё один сигнал, что нужен POST /search: когда фильтр содержит логику действия — например, ?action=confirm. Такие вещи оформляются как отдельные эндпоинты (POST /orders/{id}/confirm), а не скрываются в query-параметрах.

Частые ошибки

Имена в snake_case или PascalCase. customer_id или CustomerID — ошибка. Правильно: customerId.

page=0. Нумерация страниц начинается с 1. page=0 — невалидный запрос, возвращайте 400.

Массив через запятую. ?status=NEW,PAID — ошибка. Правильно: ?status=NEW&status=PAID, читается через r.Form["status"].

Попытка разобрать cursor на клиенте. Cursor — непрозрачный токен. Его структура может измениться в любой версии сервера. Клиент просто передаёт его обратно как строку.

Коротко

  • Query-параметры читаются через r.URL.Query().Get() для одиночных значений и r.Form["key"] для массивов (после r.ParseForm()).
  • Имена параметров — camelCase: customerId, pageSize, createdFrom.
  • page начинается с 1; page=0 — невалидный запрос.
  • Массивы передаются повтором параметра: ?status=NEW&status=PAID, не через запятую.
  • Cursor-пагинация предпочтительна для больших наборов; cursor непрозрачен — клиент не парсит его содержимое.
  • Диапазоны: суффиксы From / TocreatedFrom, amountTo.
  • Сложный поиск с несколькими условиями → POST /resources/search с JSON-телом.

Что почитать дальше

  • URL и ресурсы — path-параметры и структура маршрутов в chi.
  • JSON и формат ответов — формат PageResponse с полем content.
  • Ошибки RFC 9457 — как возвращать ошибки валидации параметров.