-module(oidcc_http_util). -feature(maybe_expr, enable). -include("internal/doc.hrl"). ?MODULEDOC("HTTP Client Utilities"). -export([basic_auth_header/2]). -export([bearer_auth_header/1]). -export([headers_to_cache_deadline/2]). -export([request/4]). -export_type([ http_header/0, error/0, httpc_error/0, query_params/0, telemetry_opts/0, request_opts/0 ]). ?DOC("See `uri_string:compose_query/1`."). ?DOC(#{since => <<"3.0.0">>}). -type query_params() :: [{unicode:chardata(), unicode:chardata() | true}]. ?DOC("See `httpc:request/5`."). ?DOC(#{since => <<"3.0.0">>}). -type http_header() :: {Field :: [byte()] | binary(), Value :: iodata()}. ?DOC(#{since => <<"3.0.0">>}). -type error() :: {http_error, StatusCode :: pos_integer(), HttpBodyResult :: binary() | map()} | {use_dpop_nonce, Nonce :: binary(), HttpBodyResult :: binary() | map()} | invalid_content_type | httpc_error(). ?DOC("See `httpc:request/5` for additional errors."). ?DOC(#{since => <<"3.0.0">>}). -type httpc_error() :: term(). ?DOC(""" See `httpc:request/5`. ## Parameters * `timeout` - timeout for request * `ssl` - TLS config """). ?DOC(#{since => <<"3.0.0">>}). -type request_opts() :: #{ timeout => timeout(), ssl => [ssl:tls_option()], httpc_profile => atom() | pid() }. ?DOC(#{since => <<"3.0.0">>}). -type telemetry_opts() :: #{ topic := [atom()], extra_meta => map() }. ?DOC(false). -spec basic_auth_header(User, Secret) -> http_header() when User :: binary(), Secret :: binary(). basic_auth_header(User, Secret) -> UserEnc = uri_string:compose_query([{User, true}]), SecretEnc = uri_string:compose_query([{Secret, true}]), RawAuth = <>/binary, SecretEnc/binary>>, AuthData = base64:encode(RawAuth), {"authorization", [<<"Basic ">>, AuthData]}. ?DOC(false). -spec bearer_auth_header(Token) -> http_header() when Token :: binary(). bearer_auth_header(Token) -> {"authorization", [<<"Bearer ">>, Token]}. ?DOC(false). -spec request(Method, Request, TelemetryOpts, RequestOpts) -> {ok, {{json, term()} | {jwt, binary()}, [http_header()]}} | {error, error()} when Method :: head | get | put | patch | post | trace | options | delete, Request :: {uri_string:uri_string(), [http_header()]} | { uri_string:uri_string(), [http_header()], ContentType :: uri_string:uri_string(), HttpBody }, HttpBody :: iolist() | binary() | { fun((Accumulator :: term()) -> eof | {ok, iolist(), Accumulator :: term()}), Accumulator :: term() } | {chunkify, fun((Accumulator :: term()) -> eof | {ok, iolist(), Accumulator :: term()}), Accumulator :: term()}, TelemetryOpts :: telemetry_opts(), RequestOpts :: request_opts(). request(Method, Request, TelemetryOpts, RequestOpts) -> TelemetryTopic = maps:get(topic, TelemetryOpts), TelemetryExtraMeta = maps:get(extra_meta, TelemetryOpts, #{}), Timeout = maps:get(timeout, RequestOpts, timer:minutes(1)), SslOpts = maps:get(ssl, RequestOpts, undefined), HttpProfile = maps:get(httpc_profile, RequestOpts, default), HttpOpts0 = [{timeout, Timeout}], HttpOpts = case SslOpts of undefined -> HttpOpts0; _Opts -> [{ssl, SslOpts} | HttpOpts0] end, telemetry:span( TelemetryTopic, TelemetryExtraMeta, fun() -> maybe {ok, {_StatusLine, Headers, _Result} = Response} ?= httpc:request( Method, Request, HttpOpts, [{body_format, binary}], HttpProfile ), {ok, BodyAndFormat} ?= extract_successful_response(Response), {{ok, {BodyAndFormat, Headers}}, TelemetryExtraMeta} else {error, Reason} -> {{error, Reason}, maps:put(error, Reason, TelemetryExtraMeta)} end end ). -spec extract_successful_response({StatusLine, [HttpHeader], HttpBodyResult}) -> {ok, {json, term()} | {jwt, binary()}} | {error, error()} when StatusLine :: {HttpVersion, StatusCode, string()}, HttpVersion :: uri_string:uri_string(), StatusCode :: pos_integer(), HttpHeader :: http_header(), HttpBodyResult :: binary(). extract_successful_response({{_HttpVersion, Status, _HttpStatusName}, Headers, HttpBodyResult}) when Status == 200 orelse Status == 201 -> case fetch_content_type(Headers) of json -> {ok, {json, jose:decode(HttpBodyResult)}}; jwt -> {ok, {jwt, HttpBodyResult}}; unknown -> {error, invalid_content_type} end; extract_successful_response({{_HttpVersion, StatusCode, _HttpStatusName}, Headers, HttpBodyResult}) -> Body = case fetch_content_type(Headers) of json -> jose:decode(HttpBodyResult); jwt -> HttpBodyResult; unknown -> HttpBodyResult end, case proplists:lookup("dpop-nonce", Headers) of {"dpop-nonce", DpopNonce} -> {error, {use_dpop_nonce, iolist_to_binary(DpopNonce), Body}}; _ -> {error, {http_error, StatusCode, Body}} end. -spec fetch_content_type(Headers) -> json | jwt | unknown when Headers :: [http_header()]. fetch_content_type(Headers) -> case proplists:lookup("content-type", Headers) of {"content-type", "application/jwk-set+json" ++ _Rest} -> json; {"content-type", "application/json" ++ _Rest} -> json; {"content-type", "application/jwt" ++ _Rest} -> jwt; _Other -> unknown end. -spec headers_to_cache_deadline(Headers, DefaultExpiry) -> pos_integer() when Headers :: [{Header :: binary(), Value :: binary()}], DefaultExpiry :: non_neg_integer(). headers_to_cache_deadline(Headers, DefaultExpiry) -> case proplists:lookup("cache-control", Headers) of {"cache-control", Cache} -> try cache_deadline(Cache, DefaultExpiry) catch _:_ -> DefaultExpiry end; none -> DefaultExpiry end. -spec cache_deadline(Cache :: iodata(), Fallback :: pos_integer()) -> pos_integer(). cache_deadline(Cache, Fallback) -> Entries = binary:split(iolist_to_binary(Cache), [<<",">>, <<"=">>, <<" ">>], [global, trim_all]), MaxAge = fun (<<"0">>, true) -> Fallback; (Entry, true) -> erlang:convert_time_unit(binary_to_integer(Entry), second, millisecond); (<<"max-age">>, _) -> true; (_, Res) -> Res end, lists:foldl(MaxAge, Fallback, Entries).