Skip to main content

src/livery_ext.erl

-module(livery_ext).
-moduledoc """
Request extractors.

Axum-style helpers that pull typed values out of a request. Each
extractor either returns the value directly or a
`{ok, _} | {error, _}` result. Extractors are pure functions over
`#livery_req{}`; they never block on the wire. Body-shaped
extractors require the body to have been read into a buffer first
(the per-request process does that before invoking the handler
when the route is configured for buffered intake).
""".

-include("livery.hrl").

-export([
    json/1,
    form/1,
    read_form/1,
    read_form/2,
    path_param/2,
    query/2,
    header/2,
    bearer_token/1,
    cookie/2,
    user/1,
    user/2,
    session/1,
    session/2
]).

-export_type([json_error/0, form_error/0, read_form_error/0]).

-type json_error() :: no_body | invalid_json | not_buffered.
-type form_error() :: no_body | not_buffered.
-type read_form_error() ::
    not_form
    | no_body
    | {limit, max_size}
    | {client_reset, term()}
    | timeout.

%%====================================================================
%% Body extractors
%%====================================================================

-doc """
Decode the request body as JSON using the OTP `json` module.

The body must have been buffered. Streaming bodies must be drained
via `livery_body:read_all/1` before calling this.
""".
-spec json(livery_req:req()) -> {ok, term()} | {error, json_error()}.
json(#livery_req{body = empty}) ->
    {error, no_body};
json(#livery_req{body = {buffered, IoData}}) ->
    Bin = iolist_to_binary(IoData),
    try
        {ok, json:decode(Bin)}
    catch
        _:_ -> {error, invalid_json}
    end;
json(#livery_req{body = {stream, _}}) ->
    {error, not_buffered}.

-doc """
Decode an `application/x-www-form-urlencoded` body into a list of
key/value pairs.
""".
-spec form(livery_req:req()) ->
    {ok, [{binary(), binary()}]} | {error, form_error()}.
form(#livery_req{body = empty}) ->
    {error, no_body};
form(#livery_req{body = {buffered, IoData}}) ->
    {ok, decode_form(iolist_to_binary(IoData))};
form(#livery_req{body = {stream, _}}) ->
    {error, not_buffered}.

-doc """
Decode an `application/x-www-form-urlencoded` body, draining the stream.

Unlike `form/1` (which requires an already-buffered body), this reads a
streaming body to completion (bounded by `max_size`, default 1 MiB) and
decodes it. A buffered body is decoded directly. The Content-Type check
is case-insensitive and parameter-tolerant. Malformed percent escapes
are preserved verbatim (same decoder as `form/1`).
""".
-spec read_form(livery_req:req()) ->
    {ok, [{binary(), binary()}]} | {error, read_form_error()}.
read_form(Req) ->
    read_form(Req, #{}).

-doc "`read_form/1` with `#{max_size => Bytes, timeout => Ms}` options.".
-spec read_form(livery_req:req(), map()) ->
    {ok, [{binary(), binary()}]} | {error, read_form_error()}.
read_form(Req, Opts) ->
    case is_urlencoded(Req) of
        false ->
            {error, not_form};
        true ->
            MaxSize = maps:get(max_size, Opts, 1048576),
            Timeout = maps:get(timeout, Opts, 5000),
            read_form_body(livery_req:body(Req), MaxSize, Timeout)
    end.

-spec read_form_body(
    empty | {buffered, iodata()} | {stream, term()},
    pos_integer(),
    timeout()
) -> {ok, [{binary(), binary()}]} | {error, read_form_error()}.
read_form_body(empty, _MaxSize, _Timeout) ->
    {error, no_body};
read_form_body({buffered, IoData}, MaxSize, _Timeout) ->
    Bin = iolist_to_binary(IoData),
    case byte_size(Bin) > MaxSize of
        true -> {error, {limit, max_size}};
        false -> {ok, decode_form(Bin)}
    end;
read_form_body({stream, Reader}, MaxSize, Timeout) ->
    case drain(Reader, Timeout, MaxSize, []) of
        {ok, Bin} -> {ok, decode_form(Bin)};
        {error, Reason} -> {error, Reason}
    end.

-spec is_urlencoded(livery_req:req()) -> boolean().
is_urlencoded(Req) ->
    case livery_req:header(<<"content-type">>, Req) of
        undefined -> false;
        Value -> normalize_ct(Value) =:= <<"application/x-www-form-urlencoded">>
    end.

-spec normalize_ct(binary()) -> binary().
normalize_ct(Value) ->
    Lower = iolist_to_binary(string:lowercase(Value)),
    [Main | _] = binary:split(Lower, <<";">>),
    iolist_to_binary(string:trim(Main)).

-spec drain(term(), timeout(), pos_integer(), [iodata()]) ->
    {ok, binary()} | {error, read_form_error()}.
drain(Reader, Timeout, MaxSize, Acc) ->
    case livery_body:read(Reader, Timeout) of
        {ok, Chunk, Reader1} ->
            Acc1 = [Chunk | Acc],
            case iolist_size(Acc1) > MaxSize of
                true -> {error, {limit, max_size}};
                false -> drain(Reader1, Timeout, MaxSize, Acc1)
            end;
        {done, _Reader1} ->
            {ok, iolist_to_binary(lists:reverse(Acc))};
        {error, Reason, _Reader1} ->
            {error, Reason}
    end.

%%====================================================================
%% Path, query, header, auth
%%====================================================================

-doc "Look up a path parameter (e.g. `:name` in `/users/:name`).".
-spec path_param(binary(), livery_req:req()) -> binary() | undefined.
path_param(Name, Req) ->
    livery_req:binding(Name, Req).

-doc """
Look up a single query string parameter.

Returns the first value if the key appears more than once.
""".
-spec query(binary(), livery_req:req()) -> binary() | undefined.
query(Name, Req) ->
    Raw = livery_req:query(Req),
    case lists:keyfind(Name, 1, decode_form(Raw)) of
        {_, V} -> V;
        false -> undefined
    end.

-doc "Look up a header by name. Names are matched case-insensitively.".
-spec header(binary(), livery_req:req()) -> binary() | undefined.
header(Name, Req) ->
    livery_req:header(Name, Req).

-doc """
Extract a bearer token from the `Authorization` header.

Accepts `Bearer `, `bearer `, and `BEARER ` prefixes (RFC 6750
ยง2.1 makes the scheme case-insensitive).
""".
-spec bearer_token(livery_req:req()) -> binary() | undefined.
bearer_token(Req) ->
    case livery_req:header(<<"authorization">>, Req) of
        undefined ->
            undefined;
        Value ->
            case parse_bearer(Value) of
                {ok, Token} -> Token;
                error -> undefined
            end
    end.

-spec parse_bearer(binary()) -> {ok, binary()} | error.
parse_bearer(<<"Bearer ", T/binary>>) -> {ok, T};
parse_bearer(<<"bearer ", T/binary>>) -> {ok, T};
parse_bearer(<<"BEARER ", T/binary>>) -> {ok, T};
parse_bearer(_) -> error.

-doc """
Return the value of a request cookie by name.

Parses the `Cookie` header (RFC 6265 `name=value` pairs separated
by `; `). Returns `undefined` when the header or the named cookie
is absent.
""".
-spec cookie(binary(), livery_req:req()) -> binary() | undefined.
cookie(Name, Req) ->
    case livery_req:header(<<"cookie">>, Req) of
        undefined -> undefined;
        Value -> find_cookie(Name, Value)
    end.

-spec find_cookie(binary(), binary()) -> binary() | undefined.
find_cookie(Name, Header) ->
    Pairs = binary:split(Header, <<";">>, [global]),
    lists:foldl(
        fun
            (_Pair, Found) when Found =/= undefined -> Found;
            (Pair, undefined) ->
                case binary:split(string:trim(Pair), <<"=">>) of
                    [Name, Value] -> Value;
                    _ -> undefined
                end
        end,
        undefined,
        Pairs
    ).

-doc """
Return the authenticated principal stored on the request.

`livery_auth_bearer` (and other auth middlewares) place the
verified claims under `meta(user, _)`. Returns `undefined` when no
auth middleware ran or authentication was optional and absent.
""".
-spec user(livery_req:req()) -> term() | undefined.
user(Req) ->
    livery_req:meta(user, Req).

-doc "`user/1` with a fallback default.".
-spec user(livery_req:req(), Default) -> term() | Default.
user(Req, Default) ->
    livery_req:meta(user, Req, Default).

-doc """
Return the session map stored on the request.

`livery_auth_session` places the verified session payload under
`meta(session, _)`. Returns `undefined` when no session middleware
ran or no valid session cookie was present.
""".
-spec session(livery_req:req()) -> term() | undefined.
session(Req) ->
    livery_req:meta(session, Req).

-doc "`session/1` with a fallback default.".
-spec session(livery_req:req(), Default) -> term() | Default.
session(Req, Default) ->
    livery_req:meta(session, Req, Default).

%%====================================================================
%% URL-encoded form decoding
%%====================================================================

-spec decode_form(binary()) -> [{binary(), binary()}].
decode_form(<<>>) -> [];
decode_form(Bin) -> [decode_pair(P) || P <- binary:split(Bin, <<"&">>, [global]), P =/= <<>>].

-spec decode_pair(binary()) -> {binary(), binary()}.
decode_pair(P) ->
    case binary:split(P, <<"=">>) of
        [K, V] -> {url_decode(K), url_decode(V)};
        [K] -> {url_decode(K), <<>>}
    end.

-spec url_decode(binary()) -> binary().
url_decode(B) -> url_decode(B, <<>>).

-spec url_decode(binary(), binary()) -> binary().
url_decode(<<>>, Acc) ->
    Acc;
url_decode(<<$+, R/binary>>, Acc) ->
    url_decode(R, <<Acc/binary, $\s>>);
url_decode(<<$%, H1, H2, R/binary>>, Acc) ->
    case unhex(H1, H2) of
        {ok, C} -> url_decode(R, <<Acc/binary, C>>);
        error -> url_decode(R, <<Acc/binary, $%, H1, H2>>)
    end;
url_decode(<<C, R/binary>>, Acc) ->
    url_decode(R, <<Acc/binary, C>>).

-spec unhex(byte(), byte()) -> {ok, byte()} | error.
unhex(H1, H2) ->
    case {hex(H1), hex(H2)} of
        {{ok, X}, {ok, Y}} -> {ok, X * 16 + Y};
        _ -> error
    end.

-spec hex(byte()) -> {ok, 0..15} | error.
hex(C) when C >= $0, C =< $9 -> {ok, C - $0};
hex(C) when C >= $a, C =< $f -> {ok, C - $a + 10};
hex(C) when C >= $A, C =< $F -> {ok, C - $A + 10};
hex(_) -> error.