Когда клиент делает запрос 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 вернёт пустую строку. Всё просто.
Важный момент с именами: параметры пишутся в camelCase — customerId, 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/To—createdFrom,amountTo. - Сложный поиск с несколькими условиями →
POST /resources/searchс JSON-телом.
Что почитать дальше
- URL и ресурсы — path-параметры и структура маршрутов в chi.
- JSON и формат ответов — формат
PageResponseс полемcontent. - Ошибки RFC 9457 — как возвращать ошибки валидации параметров.