asobi_ops_params (asobi v0.84.0)

View Source

Request-input parsing for the ops read plane: query string, and the id in a bound path segment.

Three rules, each of them load-bearing:

  • Numbers are parsed safely and clamped. ?limit=abc must never reach binary_to_integer/1 (a badarg is a 500 and a free log-flood), and ?limit=10000000 must never reach the database. Parsing goes through asobi_qs:integer/5, which already fails soft to a default.
  • order_by is built from a per-endpoint allowlist of atoms and nothing else. A user-supplied string reaching order_by is SQL injection, so an unknown sort field is rejected rather than ignored - silently falling back to a default would hide the mistake from the caller.
  • Every sort ends on a unique column. Offset pagination over a non-unique key can return one row twice and skip another between pages, so sort/3 appends the primary key as a tie-breaker, and sort/4 takes the unique column for a row set that is not keyed on id.

?page=N and ?offset=N both select a window. page wins when both are given. The offset returned is the one the query actually used - see page/1.

Summary

Functions

Read a boolean filter parameter.

Decode an opaque ?cursor= token back to its keyset value.

Encode a keyset value as the opaque cursor token cursor/1 accepts.

Read an exact-match filter parameter.

Build an ILIKE pattern from the search parameter search/2 accepts.

Window for a list endpoint: a clamped limit and a clamped offset.

Read a free-text search parameter.

Resolve ?sort= and ?order= against a per-endpoint allowlist.

sort/3 for a row set whose unique key is not id.

Whether Id is a canonical lowercase hyphenated uuid.

Types

page_spec()

-type page_spec() :: #{limit := pos_integer(), offset := non_neg_integer()}.

params()

-type params() :: #{binary() => binary() | true}.

sort_allowlist()

-type sort_allowlist() :: [{binary(), atom()}].

sort_spec()

-type sort_spec() :: [{atom(), asc | desc}].

tie_break()

-type tie_break() :: {atom(), asc | desc}.

Functions

boolean(Params, Key)

-spec boolean(params(), binary()) -> {ok, boolean()} | none.

Read a boolean filter parameter.

Only true and false are values. ?active=1 is a typo, and reading it as false would answer with the exact opposite of what was asked for, so anything else is none and the filter does not apply.

cursor(Params)

-spec cursor(params()) -> {ok, binary()} | none | {error, invalid_cursor}.

Decode an opaque ?cursor= token back to its keyset value.

Cursors are base64url without padding so they survive a query string intact and carry no meaning a caller can hand-craft. Anything that is not a valid token is an error, never a silent fall back to the first page.

encode_cursor(Value)

-spec encode_cursor(binary()) -> binary().

Encode a keyset value as the opaque cursor token cursor/1 accepts.

filter(Params, Key)

-spec filter(params(), binary()) -> {ok, binary()} | none.

Read an exact-match filter parameter.

none when the parameter is absent, empty, valueless, or longer than 64 bytes - the same soft drop search/2 applies, and for the same reason: a value no column of this size can hold is not worth a query.

like_pattern(Params, Key)

-spec like_pattern(params(), binary()) -> {ok, binary()} | none.

Build an ILIKE pattern from the search parameter search/2 accepts.

page(Params)

-spec page(params()) -> page_spec().

Window for a list endpoint: a clamped limit and a clamped offset.

limit defaults to 50 and is clamped to [1, 200]. offset is clamped to [0, 100000] - a deeper offset is a sequential scan, not a page.

The offset is then snapped down to a whole multiple of the limit, because kura_paginator:paginate/3 is page-based and can only express offsets that land on a page boundary. Snapping keeps the offset reported in the envelope equal to the offset the query ran with, rather than echoing one the caller asked for and did not get.

search(Params, Key)

-spec search(params(), binary()) -> {ok, binary()} | none.

Read a free-text search parameter.

Returns none when the parameter is absent, empty, valueless, or longer than 64 bytes. The one place the length rule lives, so a search that reaches the database and a search matched in memory accept the same input.

sort(Params, Allowed, Default)

-spec sort(params(), sort_allowlist(), sort_spec()) ->
              {ok, sort_spec()} | {error, {unknown_sort, binary()} | {unknown_order, binary()}}.

Resolve ?sort= and ?order= against a per-endpoint allowlist.

Allowed maps the wire name to the column atom; only atoms already in that list can reach the query. An unknown field or direction is an error, so the caller answers 400 instead of quietly returning differently-ordered rows.

Default is used when no sort is requested. Either way the result is completed with {id, desc} so the ordering is total.

sort(Params, Allowed, Default, TieBreak)

-spec sort(params(), sort_allowlist(), sort_spec(), tie_break()) ->
              {ok, sort_spec()} | {error, {unknown_sort, binary()} | {unknown_order, binary()}}.

sort/3 for a row set whose unique key is not id.

A board's entries are unique on player_id within the board, a queue has one row per mode: those are the columns the order has to end on there. Passing the wrong one is not a syntax error, it is a page that can repeat a row, so the tie-breaker is named by the endpoint rather than assumed.

uuid/1

-spec uuid(term()) -> boolean().

Whether Id is a canonical lowercase hyphenated uuid.

Every primary key in this plane is a uuid column. Postgres raises on a value that is not one, so an id checked only by the database turns a malformed request into a 500 and a log line. Checking the shape first makes it the 400 it is, and costs no query.