src/oidcc_http_util.erl

%%%-------------------------------------------------------------------
%% @doc HTTP Client Utilities
%% @end
%%%-------------------------------------------------------------------
-module(oidcc_http_util).

-feature(maybe_expr, enable).

-export([basic_auth_header/2]).
-export([bearer_auth_header/1]).
-export([request/4]).

-export_type([
    http_header/0, error/0, httpc_error/0, query_params/0, telemetry_opts/0, request_opts/0
]).

-type query_params() :: [{unicode:chardata(), unicode:chardata() | true}].
%% See {@link uri_string:compose_query/1}
-type http_header() :: {Field :: [byte()], Value :: iodata()}.
%% See {@link httpc:request/5}
-type error() ::
    {http_error, StatusCode :: pos_integer(), HttpBodyResult :: binary()}
    | invalid_content_type
    | httpc_error().
-type httpc_error() :: term().
%% See {@link httpc:request/5} for additional errors

-type request_opts() :: #{
    timeout => timeout(),
    ssl => [ssl:tls_option()]
}.
%% See {@link httpc:request/5}
%%
%% <h2>Parameters</h2>
%%
%% <ul>
%%   <li>`timeout' - timeout for request</li>
%%   <li>`ssl' - TLS config</li>
%% </ul>

-type telemetry_opts() :: #{
    topic := [atom()],
    extra_meta => map()
}.

%% @private
-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 = <<UserEnc/binary, <<":">>/binary, SecretEnc/binary>>,
    AuthData = base64:encode(RawAuth),
    {"authorization", [<<"Basic ">>, AuthData]}.

%% @private
-spec bearer_auth_header(Token) -> http_header() when Token :: binary().
bearer_auth_header(Token) ->
    {"authorization", [<<"Bearer ">>, Token]}.

%% @private
-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),

    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}]
                    ),
                {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,
    {error, {http_error, StatusCode, Body}}.

-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/json" ++ _Rest} ->
            json;
        {"content-type", "application/jwt" ++ _Rest} ->
            jwt;
        _Other ->
            unknown
    end.