-module(livery_req). -moduledoc """ Request accessors and builders. Requests flow as immutable `#livery_req{}` values. Adapters build the initial value from protocol-specific events; middleware and handlers read it via the helpers in this module and can derive a new value for the next stage using the setters. """. -include("livery.hrl"). -export([ new/1, protocol/1, method/1, scheme/1, authority/1, path/1, query/1, peer/1, tls/1, adapter/1, stream/1, engine_pid/1, req_id/1, started_at/1, headers/1, header/2, header/3, headers_all/2, has_header/2, set_header/3, append_header/3, delete_header/2, bindings/1, binding/2, binding/3, set_bindings/2, body/1, set_body/2, meta/1, meta/2, meta/3, set_meta/3, config/1, config/2, config/3, set_req_id/2, on_disconnect/2, disconnect_tag/0 ]). -export_type([req/0]). -type req() :: #livery_req{}. -type header_name() :: binary(). -type header_value() :: binary(). -doc """ Construct a request from a map of fields. Intended for adapters. Unspecified fields fall back to defaults on the record. """. -spec new(map()) -> req(). new(Fields) when is_map(Fields) -> maps:fold(fun set_field/3, #livery_req{}, Fields). -spec set_field(atom(), term(), req()) -> req(). set_field(protocol, V, R) -> R#livery_req{protocol = V}; set_field(method, V, R) -> R#livery_req{method = V}; set_field(scheme, V, R) -> R#livery_req{scheme = V}; set_field(authority, V, R) -> R#livery_req{authority = V}; set_field(path, V, R) -> R#livery_req{path = V}; set_field(raw_query, V, R) -> R#livery_req{raw_query = V}; set_field(bindings, V, R) -> R#livery_req{bindings = V}; set_field(headers, V, R) -> R#livery_req{headers = normalize_headers(V)}; set_field(peer, V, R) -> R#livery_req{peer = V}; set_field(tls, V, R) -> R#livery_req{tls = V}; set_field(body, V, R) -> R#livery_req{body = V}; set_field(adapter, V, R) -> R#livery_req{adapter = V}; set_field(stream, V, R) -> R#livery_req{stream = V}; set_field(engine_pid, V, R) -> R#livery_req{engine_pid = V}; set_field(notifier_pid, V, R) -> R#livery_req{notifier_pid = V}; set_field(disc_ref, V, R) -> R#livery_req{disc_ref = V}; set_field(req_id, V, R) -> R#livery_req{req_id = V}; set_field(started_at, V, R) -> R#livery_req{started_at = V}; set_field(meta, V, R) -> R#livery_req{meta = V}; set_field(config, V, R) -> R#livery_req{config = V}; set_field(Key, _V, _R) -> error({badarg, Key}). %%==================================================================== %% Basic accessors %%==================================================================== -doc "Wire protocol of the request: `h1`, `h2`, or `h3`.". -spec protocol(req()) -> h1 | h2 | h3. protocol(#livery_req{protocol = V}) -> V. -doc "HTTP method, uppercase binary (e.g. `<<\"GET\">>`).". -spec method(req()) -> binary(). method(#livery_req{method = V}) -> V. -doc "URL scheme: typically `<<\"http\">>` or `<<\"https\">>`.". -spec scheme(req()) -> binary(). scheme(#livery_req{scheme = V}) -> V. -doc "Authority (host:port), as the client sent it.". -spec authority(req()) -> binary(). authority(#livery_req{authority = V}) -> V. -doc "Decoded path portion of the request URI.". -spec path(req()) -> binary(). path(#livery_req{path = V}) -> V. -doc "Raw query string (everything after `?`, no leading `?`).". -spec query(req()) -> binary(). query(#livery_req{raw_query = V}) -> V. -doc "Peer address and port if the adapter knows it.". -spec peer(req()) -> {inet:ip_address(), inet:port_number()} | undefined. peer(#livery_req{peer = V}) -> V. -doc "TLS info map for HTTPS requests; `undefined` for plain HTTP.". -spec tls(req()) -> undefined | map(). tls(#livery_req{tls = V}) -> V. -doc "Adapter module that produced this request.". -spec adapter(req()) -> module() | undefined. adapter(#livery_req{adapter = V}) -> V. -doc "Adapter-specific stream handle.". -spec stream(req()) -> term(). stream(#livery_req{stream = V}) -> V. -doc "Engine pid for this connection (adapter-specific).". -spec engine_pid(req()) -> pid() | undefined. engine_pid(#livery_req{engine_pid = V}) -> V. -doc "Request id set by `livery_request_id` or user code.". -spec req_id(req()) -> binary(). req_id(#livery_req{req_id = V}) -> V. -doc "Monotonic-time timestamp set by `livery_req_proc` on entry.". -spec started_at(req()) -> integer() | undefined. started_at(#livery_req{started_at = V}) -> V. %%==================================================================== %% Headers %%==================================================================== -doc "All request headers in wire order, names lowercased.". -spec headers(req()) -> [{header_name(), header_value()}]. headers(#livery_req{headers = V}) -> V. -doc "First value for a header, or `undefined`.". -spec header(header_name(), req()) -> header_value() | undefined. header(Name, Req) -> header(Name, Req, undefined). -doc "First value for a header, falling back to `Default`.". -spec header(header_name(), req(), Default) -> header_value() | Default. header(Name, #livery_req{headers = Hs}, Default) -> LName = lowercase(Name), case lists:keyfind(LName, 1, Hs) of {_, V} -> V; false -> Default end. -doc """ Return all values associated with a header name. HTTP header names may appear more than once in H1 and H2; this returns them in wire order. """. -spec headers_all(header_name(), req()) -> [header_value()]. headers_all(Name, #livery_req{headers = Hs}) -> LName = lowercase(Name), [V || {N, V} <- Hs, N =:= LName]. -doc "True if the header is present at least once.". -spec has_header(header_name(), req()) -> boolean(). has_header(Name, #livery_req{headers = Hs}) -> LName = lowercase(Name), lists:keyfind(LName, 1, Hs) /= false. -doc "Replace (or insert) a header.". -spec set_header(header_name(), header_value(), req()) -> req(). set_header(Name, Value, #livery_req{headers = Hs} = Req) -> LName = lowercase(Name), Hs1 = lists:keystore(LName, 1, delete_all(LName, Hs), {LName, Value}), Req#livery_req{headers = Hs1}. -doc "Append a header even if one with this name exists.". -spec append_header(header_name(), header_value(), req()) -> req(). append_header(Name, Value, #livery_req{headers = Hs} = Req) -> Req#livery_req{headers = Hs ++ [{lowercase(Name), Value}]}. -doc "Remove every header with this name.". -spec delete_header(header_name(), req()) -> req(). delete_header(Name, #livery_req{headers = Hs} = Req) -> Req#livery_req{headers = delete_all(lowercase(Name), Hs)}. %%==================================================================== %% Router bindings %%==================================================================== -doc "Map of path parameters captured by the router.". -spec bindings(req()) -> #{binary() => binary()}. bindings(#livery_req{bindings = V}) -> V. -doc "Look up a binding, or `undefined`.". -spec binding(binary(), req()) -> binary() | undefined. binding(Name, Req) -> binding(Name, Req, undefined). -doc "Look up a binding, falling back to `Default`.". -spec binding(binary(), req(), Default) -> binary() | Default. binding(Name, #livery_req{bindings = Bs}, Default) -> maps:get(Name, Bs, Default). -doc "Replace the bindings map (used by the router).". -spec set_bindings(#{binary() => binary()}, req()) -> req(). set_bindings(Bs, Req) when is_map(Bs) -> Req#livery_req{bindings = Bs}. %%==================================================================== %% Body %%==================================================================== -doc """ Body shape: `empty`, `{buffered, IoData}`, or `{stream, Reader}`. The `Reader` is opaque; drain it with `livery_body:read/2`. """. -spec body(req()) -> empty | {buffered, iodata()} | {stream, term()}. body(#livery_req{body = V}) -> V. -doc "Replace the body field on the request.". -spec set_body(empty | {buffered, iodata()} | {stream, term()}, req()) -> req(). set_body(Body, Req) -> Req#livery_req{body = Body}. %%==================================================================== %% Middleware metadata %%==================================================================== -doc "Full meta map.". -spec meta(req()) -> map(). meta(#livery_req{meta = V}) -> V. -doc "Look up a meta key, or `undefined`.". -spec meta(term(), req()) -> term() | undefined. meta(Key, Req) -> meta(Key, Req, undefined). -doc "Look up a meta key, falling back to `Default`.". -spec meta(term(), req(), Default) -> term() | Default. meta(Key, #livery_req{meta = M}, Default) -> maps:get(Key, M, Default). -doc "Set a meta key. Used by middleware to thread state to handlers.". -spec set_meta(term(), term(), req()) -> req(). set_meta(Key, Value, #livery_req{meta = M} = Req) -> Req#livery_req{meta = maps:put(Key, Value, M)}. %%==================================================================== %% Service config %%==================================================================== -doc """ The service config: the value passed once at listener or service start (`config => ...`), the same for every request. Use it for shared handles and settings (a DB pool, a cache, a config map). `undefined` if none was set. Unlike `meta`, it is read-only and not per-request. """. -spec config(req()) -> term(). config(#livery_req{config = C}) -> C. -doc "Look up a key in a map config, or `undefined`.". -spec config(term(), req()) -> term() | undefined. config(Key, Req) -> config(Key, Req, undefined). -doc "Look up a key in a map config, falling back to `Default`.". -spec config(term(), req(), Default) -> term() | Default. config(Key, #livery_req{config = C}, Default) when is_map(C) -> maps:get(Key, C, Default); config(_Key, #livery_req{}, Default) -> Default. -doc "Set the request id field. Used by `livery_request_id`.". -spec set_req_id(binary(), req()) -> req(). set_req_id(Id, Req) when is_binary(Id) -> Req#livery_req{req_id = Id}. %%==================================================================== %% Client disconnect %%==================================================================== -doc """ Register a cancel callback to run when the client disconnects. `Fun` is a 0-arity function (e.g. `fun() -> my_llm:cancel(Ref) end`). If the client resets the stream or closes the connection before the request finishes, `Fun` is run exactly once in a fresh process, even if the handler is blocked in a NIF. It never runs on normal completion. Returns `ok` immediately; a no-op on adapters without a real connection (e.g. the test adapter). A handler that runs its own `receive` loop can instead match the `{livery_disconnect, _Ref, _Reason}` message delivered to it; see `disconnect_tag/0`. """. -spec on_disconnect(req(), fun(() -> term())) -> ok. on_disconnect(#livery_req{notifier_pid = undefined}, _Fun) -> ok; on_disconnect(#livery_req{notifier_pid = Pid, disc_ref = Ref}, Fun) when is_pid(Pid), is_reference(Ref), is_function(Fun, 0) -> Pid ! {livery_on_disconnect, Ref, Fun}, ok; on_disconnect(#livery_req{}, _Fun) -> ok. -doc """ The tag of the disconnect message delivered to a request worker. A handler in a `receive` loop matches `{livery_disconnect, _Ref, _Reason}`. This helper returns the tag atom (`livery_disconnect`) for guard-style matching. """. -spec disconnect_tag() -> livery_disconnect. disconnect_tag() -> livery_disconnect. %%==================================================================== %% Helpers %%==================================================================== -spec normalize_headers([{header_name(), header_value()}]) -> [{header_name(), header_value()}]. normalize_headers(Hs) -> [{lowercase(N), V} || {N, V} <- Hs]. -spec lowercase(binary()) -> binary(). lowercase(Bin) when is_binary(Bin) -> %% Fast path: ASCII-only header names. Fall back to string:lowercase %% for the rare cases where a header name carries non-ASCII bytes. case is_lower_ascii(Bin) of true -> Bin; false -> iolist_to_binary(string:lowercase(Bin)) end. -spec is_lower_ascii(binary()) -> boolean(). is_lower_ascii(<<>>) -> true; is_lower_ascii(<>) when C >= $a, C =< $z -> is_lower_ascii(Rest); is_lower_ascii(<>) when C >= $0, C =< $9 -> is_lower_ascii(Rest); is_lower_ascii(<<$-, Rest/binary>>) -> is_lower_ascii(Rest); is_lower_ascii(<<$:, Rest/binary>>) -> is_lower_ascii(Rest); is_lower_ascii(_) -> false. -spec delete_all(binary(), [{binary(), term()}]) -> [{binary(), term()}]. delete_all(Key, L) -> [KV || {K, _} = KV <- L, K =/= Key].