Current section

Files

Jump to
backwater src backwater_request.erl
Raw

src/backwater_request.erl

%% Copyright (c) 2017-2026 Guilherme Andrade <backwater@gandrade.net>
%%
%% Permission is hereby granted, free of charge, to any person obtaining a
%% copy of this software and associated documentation files (the "Software"),
%% to deal in the Software without restriction, including without limitation
%% the rights to use, copy, modify, merge, publish, distribute, sublicense,
%% and/or sell copies of the Software, and to permit persons to whom the
%% Software is furnished to do so, subject to the following conditions:
%%
%% The above copyright notice and this permission notice shall be included in
%% all copies or substantial portions of the Software.
%%
%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
%% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
%% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
%% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
%% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
%% DEALINGS IN THE SOFTWARE.
-module(backwater_request).
-ifdef(E48).
-moduledoc false.
-endif.
-include_lib("hackney/include/hackney_lib.hrl").
-include("backwater_api.hrl").
-include("backwater_common.hrl").
-include("backwater_default_tweaks.hrl").
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
%% ------------------------------------------------------------------
%% API Function Exports
%% ------------------------------------------------------------------
-export([encode/4]).
-ignore_xref({encode, 4}).
-export([encode/5]).
%% ------------------------------------------------------------------
%% Macro Definitions
%% ------------------------------------------------------------------
% in bytes; before being encoded using base64
-define(REQUEST_ID_SIZE, 16).
-ifdef(TEST).
-define(OVERRIDE_HACK(Key, Value), override_hack((Key), (Value))).
-else.
-define(OVERRIDE_HACK(Key, Value), (Value)).
-endif.
%% ------------------------------------------------------------------
%% Type Definitions
%% ------------------------------------------------------------------
-type nonempty_headers() :: [{nonempty_binary(), binary()}, ...].
-export_type([nonempty_headers/0]).
-type endpoint() :: {location(), secret()}.
-export_type([endpoint/0]).
-type location() ::
(nonempty_binary()
| nonempty_string()
| {nonempty_string(), inet:port_number()}
| inet:ip_address()
| {inet:ip_address(), inet:port_number()}).
-export_type([location/0]).
-type secret() :: binary().
-export_type([secret/0]).
-type options() ::
#{compression_threshold => non_neg_integer()}.
-export_type([options/0]).
-type state() :: #{signed_request_msg := backwater_signatures:signed_message()}.
-export_type([state/0]).
-type t() ::
#{
conn_params := conn_params(),
http_params := http_params(),
full_url := nonempty_binary()
}.
-export_type([t/0]).
-type conn_params() ::
#{
transport := transport(),
host := nonempty_string(),
port := inet:port_number()
}.
-export_type([conn_params/0]).
-type transport() :: hackney_tcp | hackney_ssl.
-export_type([transport/0]).
-type http_params() ::
#{
method := nonempty_binary(),
path := nonempty_binary(),
headers := nonempty_headers(),
body := binary()
}.
-export_type([http_params/0]).
%% ------------------------------------------------------------------
%% API Function Definitions
%% ------------------------------------------------------------------
-spec encode(Endpoint, Module, Function, Args) -> {Request, RequestState} when
Endpoint :: endpoint(),
Module :: module(),
Function :: atom(),
Args :: [term()],
Request :: t(),
RequestState :: state().
encode(Endpoint, Module, Function, Args) ->
encode(Endpoint, Module, Function, Args, #{}).
-spec encode(Endpoint, Module, Function, Args, Options) -> {Request, RequestState} when
Endpoint :: endpoint(),
Module :: module(),
Function :: atom(),
Args :: [term()],
Options :: options(),
Request :: t(),
RequestState :: state().
encode(Endpoint, Module, Function, Args, Options) ->
{Location, Secret} = Endpoint,
Method = ?OVERRIDE_HACK(update_method_with, ?OPAQUE_BINARY(<<"POST">>)),
MediaType = ?OPAQUE_BINARY(<<"application/x-erlang-etf">>),
Headers =
?OVERRIDE_HACK(
{update_headers_with, before_compression},
[
{?OPAQUE_BINARY(<<"accept">>), ?OPAQUE_BINARY(<<MediaType/binary>>)},
{?OPAQUE_BINARY(<<"accept-encoding">>), ?OPAQUE_BINARY(<<"gzip">>)},
{?OPAQUE_BINARY(<<"content-type">>), ?OPAQUE_BINARY(<<MediaType/binary>>)}
]
),
Body =
?OVERRIDE_HACK(
{update_body_with, before_compression},
backwater_media_etf:encode(Args)
),
Arity = ?OVERRIDE_HACK(update_arity_with, length(Args)),
CompressionThreshold =
maps:get(compression_threshold, Options, ?DEFAULT_OPT_COMPRESSION_THRESHOLD),
Request = base_request(Location, Method, Module, Function, Arity, Headers, Body),
HttpParams = maps:get(http_params, Request),
{UpdatedHttpParams, State} = maybe_compress(HttpParams, Secret, CompressionThreshold),
UpdatedRequest = Request#{http_params := UpdatedHttpParams},
{UpdatedRequest, State}.
%% ------------------------------------------------------------------
%% Internal Function Definitions
%% ------------------------------------------------------------------
-spec base_request(
location(),
nonempty_binary(),
module(),
atom(),
arity(),
nonempty_headers(),
nonempty_binary()
) ->
t().
base_request(Location, Method, Module, Function, Arity, Headers, Body) ->
% encode full URL
BaseURL = base_url(Location),
AllPathComponents =
lists:map(fun list_to_binary/1, backwater:base_cowboy_route_parts()) ++
[
hackney_url:urlencode(atom_to_binary(Module, utf8)),
hackney_url:urlencode(atom_to_binary(Function, utf8)),
integer_to_binary(Arity)
],
QueryString = <<>>,
FullUrl =
?OVERRIDE_HACK(
update_url_with,
hackney_url:make_url(BaseURL, AllPathComponents, QueryString)
),
% decode full URL back into its components
HackneyUrl = hackney_url:parse_url(FullUrl),
ConnParams =
#{
transport => HackneyUrl#hackney_url.transport,
host => HackneyUrl#hackney_url.host,
port => HackneyUrl#hackney_url.port
},
HttpParams =
#{
method => Method,
path => HackneyUrl#hackney_url.path,
headers => Headers,
body => Body
},
#{
conn_params => ConnParams,
http_params => HttpParams,
full_url => FullUrl
}.
-spec base_url(location()) -> nonempty_binary().
base_url(<<"http://", _>> = Binary) ->
Binary;
base_url(<<"https://", _>> = Binary) ->
Binary;
base_url(<<Binary/binary>>) ->
% what about unicode?
String = binary_to_list(Binary),
base_url(String);
base_url("http://" ++ _ = BaseURL) ->
list_to_binary(BaseURL);
base_url("https://" ++ _ = BaseURL) ->
list_to_binary(BaseURL);
base_url(Host) when is_list(Host) ->
base_url({Host, ?DEFAULT_CLEAR_PORT});
base_url({Host, Port}) when is_list(Host), is_integer(Port) ->
PortStr = integer_to_list(Port),
case is_probably_clear_port(Port) orelse {tls, is_probably_tls_port(Port)} of
true ->
list_to_binary("http://" ++ Host ++ ":" ++ PortStr ++ "/");
{tls, true} ->
list_to_binary("https://" ++ Host ++ ":" ++ PortStr ++ "/");
{tls, false} ->
% well, assume it's HTTP
list_to_binary("http://" ++ Host ++ ":" ++ PortStr ++ "/")
end;
base_url({IpAddress, Port}) when is_tuple(IpAddress), is_integer(Port) ->
Host = inet:ntoa(IpAddress),
true = is_list(Host),
base_url({Host, Port});
base_url(IpAddress) when is_tuple(IpAddress) ->
base_url({IpAddress, ?DEFAULT_CLEAR_PORT}).
is_probably_clear_port(Port) ->
Port =:= ?DEFAULT_CLEAR_PORT orelse
Port =:= 80.
is_probably_tls_port(Port) ->
Port =:= ?DEFAULT_TLS_PORT orelse
Port =:= 443.
-spec maybe_compress(http_params(), binary(), non_neg_integer()) ->
{http_params(), state()}.
maybe_compress(#{body := Body} = HttpParams, Secret, CompressionThreshold) when
byte_size(Body) >= CompressionThreshold
->
CompressedBody =
?OVERRIDE_HACK(
{update_body_with, before_authentication},
backwater_encoding_gzip:encode(Body)
),
ContentLengthHeader = content_length_header(CompressedBody),
ContentEncodingHeader = {<<"content-encoding">>, <<"gzip">>},
#{headers := Headers} = HttpParams,
UpdatedHeaders =
?OVERRIDE_HACK(
{update_headers_with, before_authentication},
[ContentLengthHeader, ContentEncodingHeader | Headers]
),
UpdatedHttpParams = HttpParams#{body := CompressedBody, headers := UpdatedHeaders},
authenticate(UpdatedHttpParams, Secret);
maybe_compress(#{body := Body} = HttpParams, Secret, _CompressionThreshold) ->
UpdatedBody = ?OVERRIDE_HACK({update_body_with, before_authentication}, Body),
ContentLengthHeader = content_length_header(UpdatedBody),
#{headers := Headers} = HttpParams,
UpdatedHeaders =
?OVERRIDE_HACK(
{update_headers_with, before_authentication},
[ContentLengthHeader | Headers]
),
UpdatedHttpParams = HttpParams#{headers := UpdatedHeaders, body := UpdatedBody},
authenticate(UpdatedHttpParams, Secret).
-spec authenticate(http_params(), binary()) ->
{http_params(), state()}.
authenticate(HttpParams, Secret) ->
#{method := Method, path := Path, headers := Headers, body := Body} = HttpParams,
EncodedPath = hackney_url:pathencode(Path),
SignaturesConfig = backwater_signatures:config(Secret),
RequestMsg = backwater_signatures:new_request_msg(Method, EncodedPath, Headers),
RequestId = base64:encode(crypto:strong_rand_bytes(?REQUEST_ID_SIZE)),
SignedRequestMsg = backwater_signatures:sign_request(
SignaturesConfig, RequestMsg, Body, RequestId
),
UpdatedHeaders =
?OVERRIDE_HACK(
{update_headers_with, final},
backwater_signatures:list_real_msg_headers(SignedRequestMsg)
),
UpdatedBody =
?OVERRIDE_HACK({update_body_with, final}, Body),
UpdatedHttpParams = HttpParams#{headers := UpdatedHeaders, body := UpdatedBody},
State = #{signed_request_msg => SignedRequestMsg},
{UpdatedHttpParams, State}.
content_length_header(Data) ->
Size = byte_size(Data),
{<<"content-length">>, integer_to_binary(Size)}.
%% ------------------------------------------------------------------
%% Common Test Helper Definitions
%% ------------------------------------------------------------------
-ifdef(TEST).
override_hack(Key, Value) ->
case get(override) of
#{} = Override ->
OverrideFun = maps:get(Key, Override, fun(V) -> V end),
OverrideFun(Value);
undefined ->
Value
end.
-endif.
%% ------------------------------------------------------------------
%% Unit Tests
%% ------------------------------------------------------------------
-ifdef(TEST).
-spec location_test() -> ok.
location_test() ->
?assertEqual(<<"http://example.com/">>, base_url(<<"http://example.com/">>)),
?assertEqual(<<"https://example.com/">>, base_url(<<"https://example.com/">>)),
?assertEqual(<<"http://example.com/">>, base_url("http://example.com/")),
?assertEqual(<<"https://example.com/">>, base_url("https://example.com/")),
?assertEqual(<<"http://example.com:8080/">>, base_url("example.com")),
?assertEqual(<<"http://example.com:8080/">>, base_url({"example.com", 8080})),
?assertEqual(<<"http://example.com:80/">>, base_url({"example.com", 80})),
?assertEqual(<<"https://example.com:8443/">>, base_url({"example.com", 8443})),
?assertEqual(<<"https://example.com:443/">>, base_url({"example.com", 443})),
?assertEqual(<<"http://example.com:12345/">>, base_url({"example.com", 12345})),
?assertEqual(<<"http://127.0.0.1:8080/">>, base_url({127, 0, 0, 1})),
?assertEqual(<<"http://127.0.0.1:8080/">>, base_url({{127, 0, 0, 1}, 8080})),
?assertEqual(<<"http://127.0.0.1:80/">>, base_url({{127, 0, 0, 1}, 80})),
?assertEqual(<<"https://127.0.0.1:8443/">>, base_url({{127, 0, 0, 1}, 8443})),
?assertEqual(<<"https://127.0.0.1:443/">>, base_url({{127, 0, 0, 1}, 443})),
?assertEqual(<<"http://127.0.0.1:12345/">>, base_url({{127, 0, 0, 1}, 12345})).
-endif.