Current section

Files

Jump to
oaspec src oaspec@transport.erl
Raw

src/oaspec@transport.erl

-module(oaspec@transport).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/oaspec/transport.gleam").
-export([method_to_wire/1, method_from_string/1, from_callback/1, resolve/1, run/2, map/2, await/2, map_try/2, try_await/2, credentials/0, with_api_key/3, with_bearer_token/3, with_basic_auth/3, with_digest_auth/3, with_base_url/2, with_default_header/3, with_default_headers/2, with_security/2]).
-export_type([method/0, method_error/0, body/0, security_requirement/0, security_alternative/0, request/0, response/0, transport_error/0, async/1, credentials/0, credential_entry/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Pure, runtime-agnostic transport contract for generated OpenAPI clients.\n"
"\n"
" Generated client code depends on this module instead of any concrete\n"
" HTTP runtime. Adapters (e.g. `oaspec/httpc`, `oaspec/fetch`) bridge\n"
" `Send` / `AsyncSend` to a real runtime; tests can plug in arbitrary\n"
" fake transport values via `oaspec/mock`.\n"
).
-type method() :: get |
post |
put |
delete |
patch |
head |
options |
trace |
connect |
{other, binary()}.
-type method_error() :: {invalid_method, binary()}.
-type body() :: empty_body | {text_body, binary()} | {bytes_body, bitstring()}.
-type security_requirement() :: {api_key_header, binary(), binary()} |
{api_key_query, binary(), binary()} |
{api_key_cookie, binary(), binary()} |
{http_authorization, binary(), binary()}.
-type security_alternative() :: {security_alternative,
list(security_requirement())}.
-type request() :: {request,
method(),
gleam@option:option(binary()),
binary(),
list({binary(), binary()}),
list({binary(), binary()}),
body(),
list(security_alternative())}.
-type response() :: {response, integer(), list({binary(), binary()}), body()}.
-type transport_error() :: {connection_failed, binary()} |
timeout |
{invalid_base_url, binary()} |
{tls_failure, binary()} |
{unsupported, binary()}.
-opaque async(ABHR) :: {async, fun((fun((ABHR) -> nil)) -> nil)}.
-opaque credentials() :: {credentials, list(credential_entry())}.
-type credential_entry() :: {cred_api_key, binary(), binary()} |
{cred_bearer, binary(), binary()} |
{cred_basic, binary(), binary()} |
{cred_digest, binary(), binary()}.
-file("src/oaspec/transport.gleam", 54).
?DOC(
" Convert a `Method` to its on-wire string. Well-known variants\n"
" produce their canonical RFC 9110 spelling (`Get` → `\"GET\"`);\n"
" `Other(s)` returns `s` verbatim — pre-normalise via\n"
" `method_from_string` if the source is in a non-canonical case.\n"
).
-spec method_to_wire(method()) -> binary().
method_to_wire(Method) ->
case Method of
get ->
<<"GET"/utf8>>;
post ->
<<"POST"/utf8>>;
put ->
<<"PUT"/utf8>>;
delete ->
<<"DELETE"/utf8>>;
patch ->
<<"PATCH"/utf8>>;
head ->
<<"HEAD"/utf8>>;
options ->
<<"OPTIONS"/utf8>>;
trace ->
<<"TRACE"/utf8>>;
connect ->
<<"CONNECT"/utf8>>;
{other, S} ->
S
end.
-file("src/oaspec/transport.gleam", 113).
-spec is_tchar(integer()) -> boolean().
is_tchar(Cp) ->
Codepoint = gleam_stdlib:identity(Cp),
case Codepoint of
16#21 ->
true;
16#23 ->
true;
16#24 ->
true;
16#25 ->
true;
16#26 ->
true;
16#27 ->
true;
16#2A ->
true;
16#2B ->
true;
16#2D ->
true;
16#2E ->
true;
16#5E ->
true;
16#5F ->
true;
16#60 ->
true;
16#7C ->
true;
16#7E ->
true;
_ ->
(((Codepoint >= 16#30) andalso (Codepoint =< 16#39)) orelse ((Codepoint
>= 16#41)
andalso (Codepoint =< 16#5A)))
orelse ((Codepoint >= 16#61) andalso (Codepoint =< 16#7A))
end.
-file("src/oaspec/transport.gleam", 104).
-spec is_valid_token(binary()) -> boolean().
is_valid_token(S) ->
_pipe = gleam@string:to_utf_codepoints(S),
gleam@list:all(_pipe, fun is_tchar/1).
-file("src/oaspec/transport.gleam", 78).
?DOC(
" Smart constructor for `Method`. Routes case-insensitively to the\n"
" nine RFC 9110 §9 variants for known names; for everything else,\n"
" validates the input against the `tchar` charset (RFC 9110 §5.6.2)\n"
" and uppercases the result before wrapping in `Other` so the wire\n"
" representation is canonical.\n"
"\n"
" Empty input or a byte outside `tchar` (control bytes, whitespace,\n"
" or separators like `(`, `)`, `,`, `;`, `:`, `/`, `[`, etc.) returns\n"
" `Error(InvalidMethod(detail))`.\n"
).
-spec method_from_string(binary()) -> {ok, method()} | {error, method_error()}.
method_from_string(S) ->
case string:lowercase(S) of
<<"get"/utf8>> ->
{ok, get};
<<"post"/utf8>> ->
{ok, post};
<<"put"/utf8>> ->
{ok, put};
<<"delete"/utf8>> ->
{ok, delete};
<<"patch"/utf8>> ->
{ok, patch};
<<"head"/utf8>> ->
{ok, head};
<<"options"/utf8>> ->
{ok, options};
<<"trace"/utf8>> ->
{ok, trace};
<<"connect"/utf8>> ->
{ok, connect};
<<""/utf8>> ->
{error,
{invalid_method,
<<"method string is empty (RFC 9110 §5.6.2)"/utf8>>}};
_ ->
case is_valid_token(S) of
true ->
{ok, {other, string:uppercase(S)}};
false ->
{error,
{invalid_method,
<<<<"method `"/utf8, S/binary>>/binary,
"` contains a byte outside the tchar charset (RFC 9110 §5.6.2)"/utf8>>}}
end
end.
-file("src/oaspec/transport.gleam", 215).
-spec from_callback(fun((fun((ABHZ) -> nil)) -> nil)) -> async(ABHZ).
from_callback(Register) ->
{async, Register}.
-file("src/oaspec/transport.gleam", 221).
-spec resolve(ABIB) -> async(ABIB).
resolve(Value) ->
{async, fun(Done) -> Done(Value) end}.
-file("src/oaspec/transport.gleam", 225).
-spec run(async(ABID), fun((ABID) -> nil)) -> nil.
run(Async, Done) ->
{async, Register} = Async,
Register(Done).
-file("src/oaspec/transport.gleam", 230).
-spec map(async(ABIF), fun((ABIF) -> ABIH)) -> async(ABIH).
map(Async, With_) ->
{async, fun(Done) -> run(Async, fun(Value) -> Done(With_(Value)) end) end}.
-file("src/oaspec/transport.gleam", 234).
-spec await(async(ABIJ), fun((ABIJ) -> async(ABIL))) -> async(ABIL).
await(Async, Next) ->
{async,
fun(Done) -> run(Async, fun(Value) -> run(Next(Value), Done) end) end}.
-file("src/oaspec/transport.gleam", 240).
-spec map_try(
async({ok, ABIO} | {error, ABIP}),
fun((ABIO) -> {ok, ABIT} | {error, ABIP})
) -> async({ok, ABIT} | {error, ABIP}).
map_try(Async, With_) ->
_pipe = Async,
map(_pipe, fun(Result) -> case Result of
{ok, Value} ->
With_(Value);
{error, Error} ->
{error, Error}
end end).
-file("src/oaspec/transport.gleam", 253).
-spec try_await(
async({ok, ABIZ} | {error, ABJA}),
fun((ABIZ) -> async({ok, ABJE} | {error, ABJA}))
) -> async({ok, ABJE} | {error, ABJA}).
try_await(Async, Next) ->
_pipe = Async,
await(_pipe, fun(Result) -> case Result of
{ok, Value} ->
Next(Value);
{error, Error} ->
resolve({error, Error})
end end).
-file("src/oaspec/transport.gleam", 284).
-spec credentials() -> credentials().
credentials() ->
{credentials, []}.
-file("src/oaspec/transport.gleam", 320).
-spec add_credential(credentials(), credential_entry()) -> credentials().
add_credential(Creds, Entry) ->
{credentials, Entries} = Creds,
{credentials, lists:append(Entries, [Entry])}.
-file("src/oaspec/transport.gleam", 288).
-spec with_api_key(credentials(), binary(), binary()) -> credentials().
with_api_key(Creds, Scheme_name, Value) ->
add_credential(Creds, {cred_api_key, Scheme_name, Value}).
-file("src/oaspec/transport.gleam", 296).
-spec with_bearer_token(credentials(), binary(), binary()) -> credentials().
with_bearer_token(Creds, Scheme_name, Token) ->
add_credential(Creds, {cred_bearer, Scheme_name, Token}).
-file("src/oaspec/transport.gleam", 304).
-spec with_basic_auth(credentials(), binary(), binary()) -> credentials().
with_basic_auth(Creds, Scheme_name, Value) ->
add_credential(Creds, {cred_basic, Scheme_name, Value}).
-file("src/oaspec/transport.gleam", 312).
-spec with_digest_auth(credentials(), binary(), binary()) -> credentials().
with_digest_auth(Creds, Scheme_name, Value) ->
add_credential(Creds, {cred_digest, Scheme_name, Value}).
-file("src/oaspec/transport.gleam", 349).
-spec with_base_url(fun((request()) -> ABJL), binary()) -> fun((request()) -> ABJL).
with_base_url(Send, Base_url) ->
fun(Req) ->
Send(
{request,
erlang:element(2, Req),
{some, Base_url},
erlang:element(4, Req),
erlang:element(5, Req),
erlang:element(6, Req),
erlang:element(7, Req),
erlang:element(8, Req)}
)
end.
-file("src/oaspec/transport.gleam", 491).
-spec has_header(list({binary(), binary()}), binary()) -> boolean().
has_header(Headers, Name) ->
Lowered = string:lowercase(Name),
gleam@list:any(
Headers,
fun(H) ->
{K, _} = H,
string:lowercase(K) =:= Lowered
end
).
-file("src/oaspec/transport.gleam", 542).
-spec forbidden_byte(binary()) -> gleam@option:option(binary()).
forbidden_byte(S) ->
gleam@bool:guard(
gleam_stdlib:contains_string(S, <<"\r"/utf8>>),
{some, <<"CR (\\r)"/utf8>>},
fun() ->
gleam@bool:guard(
gleam_stdlib:contains_string(S, <<"\n"/utf8>>),
{some, <<"LF (\\n)"/utf8>>},
fun() ->
gleam@bool:guard(
gleam_stdlib:contains_string(S, <<"\x{0000}"/utf8>>),
{some, <<"NUL (\\u{0000})"/utf8>>},
fun() -> none end
)
end
)
end
).
-file("src/oaspec/transport.gleam", 505).
-spec validate_header_name(binary(), binary()) -> nil.
validate_header_name(Api_name, Name) ->
case forbidden_byte(Name) of
none ->
nil;
{some, Byte_label} ->
erlang:error(#{gleam_error => panic,
message => (<<<<<<Api_name/binary,
": header name contains forbidden control byte "/utf8>>/binary,
Byte_label/binary>>/binary,
" — header names must not include CR, LF, or NUL"/utf8>>),
file => <<?FILEPATH/utf8>>,
module => <<"oaspec/transport"/utf8>>,
function => <<"validate_header_name"/utf8>>,
line => 509})
end.
-file("src/oaspec/transport.gleam", 522).
-spec validate_header_value(binary(), binary(), binary()) -> nil.
validate_header_value(Api_name, Name, Value) ->
case forbidden_byte(Value) of
none ->
nil;
{some, Byte_label} ->
erlang:error(#{gleam_error => panic,
message => (<<<<<<<<<<<<Api_name/binary,
": header value for `"/utf8>>/binary,
Name/binary>>/binary,
"` contains forbidden control byte "/utf8>>/binary,
Byte_label/binary>>/binary,
" (CR/LF/NUL enable header injection per RFC 9112 §2.2);"/utf8>>/binary,
" pre-encode binary values via Base64 or RFC 8187"/utf8>>),
file => <<?FILEPATH/utf8>>,
module => <<"oaspec/transport"/utf8>>,
function => <<"validate_header_value"/utf8>>,
line => 530})
end.
-file("src/oaspec/transport.gleam", 382).
?DOC(
" Inject a single default header when the request does not already\n"
" declare it. Header-name comparison is case-insensitive (per RFC 7230),\n"
" so a request that already carries `x-trace-id` blocks a default\n"
" `X-Trace-Id`. Explicit request headers always win — middleware never\n"
" clobbers them — and the helper works with both sync and async send\n"
" functions.\n"
"\n"
" **Validation.** Both `name` and `value` are checked at construction\n"
" time for the absence of CR (`\\r`), LF (`\\n`), and NUL (`\\u{0000}`)\n"
" bytes. Those bytes enable HTTP response-splitting / header\n"
" injection if they reach the wire — see RFC 9112 §2.2. A value that\n"
" contains any of them panics with a structured message naming the\n"
" offending byte and the recommendation: pre-encode binary values\n"
" via Base64 or RFC 8187 before passing them in. The check fires at\n"
" the outer call (i.e. when the wrapper is built), so a static\n"
" misconfiguration surfaces immediately at startup rather than\n"
" per-request.\n"
"\n"
" **Composition order.** Each call wraps the previous send. When two\n"
" `with_default_header` wrappers target the same name (case-insensitive),\n"
" the **outermost** wrapper (the one most recently piped in) wins,\n"
" because the request reaches it first and inserts before the inner\n"
" check runs. This is the *opposite* of the list form below — see\n"
" `with_default_headers` for the in-list rule. Reach for the\n"
" `with_default_headers([...])` shape if you want a single source of\n"
" truth for the dedup ordering.\n"
).
-spec with_default_header(fun((request()) -> ABJM), binary(), binary()) -> fun((request()) -> ABJM).
with_default_header(Send, Name, Value) ->
validate_header_name(<<"oaspec.transport.with_default_header"/utf8>>, Name),
validate_header_value(
<<"oaspec.transport.with_default_header"/utf8>>,
Name,
Value
),
fun(Req) -> case has_header(erlang:element(6, Req), Name) of
true ->
Send(Req);
false ->
Send(
{request,
erlang:element(2, Req),
erlang:element(3, Req),
erlang:element(4, Req),
erlang:element(5, Req),
lists:append(erlang:element(6, Req), [{Name, Value}]),
erlang:element(7, Req),
erlang:element(8, Req)}
)
end end.
-file("src/oaspec/transport.gleam", 433).
?DOC(
" Inject a list of default headers when the request does not already\n"
" declare them. Iteration order is preserved so callers get\n"
" deterministic ordering on the wire, and the helper works with both\n"
" sync and async send functions.\n"
"\n"
" **Validation.** Every `name` and every `value` in `headers` is\n"
" checked at construction time for the absence of CR, LF, and NUL\n"
" bytes (see `with_default_header` for the rationale). The first\n"
" invalid entry panics with a structured message naming the\n"
" offending byte; the check fires at the outer call, so a static\n"
" misconfiguration surfaces immediately rather than per-request.\n"
"\n"
" **Duplicate names within `headers`.** Header-name comparison is\n"
" case-insensitive (per RFC 7230). When the supplied list contains the\n"
" same name twice (e.g. `[#(\"X-Env\", \"staging\"), #(\"X-Env\", \"prod\")]`),\n"
" the **first occurrence is kept** and subsequent entries with the\n"
" same name are silently dropped. Headers already present on the\n"
" inbound request always win over every entry in `headers` regardless\n"
" of position.\n"
"\n"
" This is the *opposite* of the wrapper form's composition rule (see\n"
" `with_default_header`, where the outermost wrapper wins). The two\n"
" rules are each correct in isolation: the list form picks the first\n"
" caller-supplied entry; the wrapper form picks the most recently\n"
" piped wrapper. Pick one shape per code path and stick to it to\n"
" avoid surprises.\n"
).
-spec with_default_headers(fun((request()) -> ABJN), list({binary(), binary()})) -> fun((request()) -> ABJN).
with_default_headers(Send, Headers) ->
gleam@list:each(
Headers,
fun(Kv) ->
{Name, Value} = Kv,
validate_header_name(
<<"oaspec.transport.with_default_headers"/utf8>>,
Name
),
validate_header_value(
<<"oaspec.transport.with_default_headers"/utf8>>,
Name,
Value
)
end
),
fun(Req) ->
Merged_rev = gleam@list:fold(
Headers,
lists:reverse(erlang:element(6, Req)),
fun(Acc_rev, Kv@1) ->
{Name@1, Value@1} = Kv@1,
case has_header(Acc_rev, Name@1) of
true ->
Acc_rev;
false ->
[{Name@1, Value@1} | Acc_rev]
end
end
),
Send(
{request,
erlang:element(2, Req),
erlang:element(3, Req),
erlang:element(4, Req),
erlang:element(5, Req),
lists:reverse(Merged_rev),
erlang:element(7, Req),
erlang:element(8, Req)}
)
end.
-file("src/oaspec/transport.gleam", 576).
-spec credential_matches(credential_entry(), security_requirement()) -> boolean().
credential_matches(Cred, Req) ->
case {Cred, Req} of
{{cred_api_key, S, _}, {api_key_header, T, _}} when S =:= T ->
true;
{{cred_api_key, S@1, _}, {api_key_query, T@1, _}} when S@1 =:= T@1 ->
true;
{{cred_api_key, S@2, _}, {api_key_cookie, T@2, _}} when S@2 =:= T@2 ->
true;
{{cred_bearer, S@3, _}, {http_authorization, T@3, Prefix}} when S@3 =:= T@3 ->
string:lowercase(Prefix) =:= <<"bearer"/utf8>>;
{{cred_basic, S@4, _}, {http_authorization, T@4, Prefix@1}} when S@4 =:= T@4 ->
string:lowercase(Prefix@1) =:= <<"basic"/utf8>>;
{{cred_digest, S@5, _}, {http_authorization, T@5, Prefix@2}} when S@5 =:= T@5 ->
string:lowercase(Prefix@2) =:= <<"digest"/utf8>>;
{_, _} ->
false
end.
-file("src/oaspec/transport.gleam", 567).
-spec find_credential(credentials(), security_requirement()) -> gleam@option:option(credential_entry()).
find_credential(Creds, Req) ->
{credentials, Entries} = Creds,
_pipe = gleam@list:find(Entries, fun(C) -> credential_matches(C, Req) end),
gleam@option:from_result(_pipe).
-file("src/oaspec/transport.gleam", 563).
-spec satisfiable(security_alternative(), credentials()) -> boolean().
satisfiable(Alt, Creds) ->
gleam@list:all(
erlang:element(2, Alt),
fun(Req) -> find_credential(Creds, Req) /= none end
).
-file("src/oaspec/transport.gleam", 549).
-spec pick_alternative(list(security_alternative()), credentials()) -> gleam@option:option(security_alternative()).
pick_alternative(Alts, Creds) ->
case Alts of
[] ->
none;
[Alt | Rest] ->
case satisfiable(Alt, Creds) of
true ->
{some, Alt};
false ->
pick_alternative(Rest, Creds)
end
end.
-file("src/oaspec/transport.gleam", 632).
-spec set_authorization(request(), binary(), binary()) -> request().
set_authorization(Req, Prefix, Value) ->
Header_value = <<<<Prefix/binary, " "/utf8>>/binary, Value/binary>>,
{request,
erlang:element(2, Req),
erlang:element(3, Req),
erlang:element(4, Req),
erlang:element(5, Req),
lists:append(
erlang:element(6, Req),
[{<<"authorization"/utf8>>, Header_value}]
),
erlang:element(7, Req),
erlang:element(8, Req)}.
-file("src/oaspec/transport.gleam", 644).
-spec merge_cookie(request(), binary(), binary()) -> request().
merge_cookie(Req, Cookie_name, Value) ->
Pair = <<<<Cookie_name/binary, "="/utf8>>/binary, Value/binary>>,
Lowered = <<"cookie"/utf8>>,
{Existing, Others} = gleam@list:partition(
erlang:element(6, Req),
fun(H) ->
{K, _} = H,
string:lowercase(K) =:= Lowered
end
),
Merged_value = case Existing of
[] ->
Pair;
[_ | _] ->
_pipe = gleam@list:map(
Existing,
fun(H@1) ->
{_, V} = H@1,
V
end
),
_pipe@1 = lists:append(_pipe, [Pair]),
gleam@string:join(_pipe@1, <<"; "/utf8>>)
end,
{request,
erlang:element(2, Req),
erlang:element(3, Req),
erlang:element(4, Req),
erlang:element(5, Req),
lists:append(Others, [{<<"Cookie"/utf8>>, Merged_value}]),
erlang:element(7, Req),
erlang:element(8, Req)}.
-file("src/oaspec/transport.gleam", 604).
-spec apply_one(request(), security_requirement(), credential_entry()) -> request().
apply_one(Req, Requirement, Cred) ->
case {Requirement, Cred} of
{{api_key_header, _, Header_name}, {cred_api_key, _, Value}} ->
{request,
erlang:element(2, Req),
erlang:element(3, Req),
erlang:element(4, Req),
erlang:element(5, Req),
lists:append(erlang:element(6, Req), [{Header_name, Value}]),
erlang:element(7, Req),
erlang:element(8, Req)};
{{api_key_query, _, Query_name}, {cred_api_key, _, Value@1}} ->
{request,
erlang:element(2, Req),
erlang:element(3, Req),
erlang:element(4, Req),
lists:append(erlang:element(5, Req), [{Query_name, Value@1}]),
erlang:element(6, Req),
erlang:element(7, Req),
erlang:element(8, Req)};
{{api_key_cookie, _, Cookie_name}, {cred_api_key, _, Value@2}} ->
merge_cookie(Req, Cookie_name, Value@2);
{{http_authorization, _, Prefix}, {cred_bearer, _, Token}} ->
set_authorization(Req, Prefix, Token);
{{http_authorization, _, Prefix@1}, {cred_basic, _, Value@3}} ->
set_authorization(Req, Prefix@1, Value@3);
{{http_authorization, _, Prefix@2}, {cred_digest, _, Value@4}} ->
set_authorization(Req, Prefix@2, Value@4);
{_, _} ->
Req
end.
-file("src/oaspec/transport.gleam", 591).
-spec apply_alternative(request(), security_alternative(), credentials()) -> request().
apply_alternative(Req, Alt, Creds) ->
gleam@list:fold(
erlang:element(2, Alt),
Req,
fun(Acc, Requirement) -> case find_credential(Creds, Requirement) of
{some, Cred} ->
apply_one(Acc, Requirement, Cred);
none ->
Acc
end end
).
-file("src/oaspec/transport.gleam", 474).
-spec with_security(fun((request()) -> ABJP), credentials()) -> fun((request()) -> ABJP).
with_security(Send, Creds) ->
fun(Req) ->
Prepared = case pick_alternative(erlang:element(8, Req), Creds) of
{some, Alt} ->
apply_alternative(Req, Alt, Creds);
none ->
Req
end,
Send(Prepared)
end.