Skip to content

APIs

Pagination and query conventions

Offset and cursor envelopes, and allowlisted filter, sort, fields and include parameters that answer 400 for anything unknown.

Last updated:

Query conventions#

app/controllers/apiv1/products.go
func (p *Products) Index(c *web.Ctx) error {
	req, err := api.Query(c, orm.QueryOf[models.Product](p.db),
		api.Filters("category", "in_stock"),        // ?filter[category]=books,toys&filter[in_stock]=true
		api.Sorts("name", "price", "-created_at"),  // ?sort=-price,name  (default: last listed)
		api.Fields("id", "name", "price"),          // ?fields=id,name
		api.Includes("reviews"),                    // ?include=reviews → req.Includes
		api.PerPage(20, 100),                       // ?page=2&per_page=50 (capped at 100)
	)
	if err != nil {
		return err // 400 problem+json
	}

	page, err := req.Paginate(c.Context())
	if err != nil {
		return err
	}

	return api.Page(c, page, resources.Product)
}
  • Every column is an allowlist. An unknown filter, sort, field or include answers 400 listing the allowed values; input never chooses a column freely.
  • ?page is capped by api.MaxPage(n) (default 1000) and each filter[col] list by api.MaxFilterValues(n) (default 100); above either, 400. Use ?cursor for deep lists.
  • Filter values are always bound parameters. Comma-separated values become IN; true/false become booleans.
  • req.Includes lists the requested relations; pass them to Query.With (see relations).

Offset pages#

JSON
{
  "data": [{ "id": 1, "name": "Keyboard" }],
  "meta": { "page": 1, "per_page": 20, "total": 134, "last_page": 7 },
  "links": { "self": "/api/v1/products?page=1", "next": "/api/v1/products?page=2", "prev": null }
}

api.Page(c, page, fn) builds it from orm.Page[T] (query.Paginate(ctx, page, perPage)); fn is the resource transformer.

Cursor pages#

Keyset pagination needs no COUNT and stays stable while rows are inserted:

Go
page, err := req.Query.CursorPaginate(c.Context(), req.Cursor, req.PerPage, orm.Asc)
if err != nil {
	return err // a tampered cursor is a 400
}

return api.Cursor(c, page, resources.Product)
JSON
{
  "data": [{ "id": 21, "name": "Mouse" }],
  "meta": { "per_page": 20, "next_cursor": "MjE" },
  "links": { "self": "/api/v1/products", "next": "/api/v1/products?cursor=MjE", "prev": null }
}

The cursor orders by the primary key, so the query must not have its own OrderBy. links.next is null on the last page.

Plain collections#

api.Collection(c, items, fn) answers {"data": [...]} without meta or links. Single resources have no envelope.