%% Copyright (c) 2017-2021 Guilherme Andrade %% %% 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). -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 %% ------------------------------------------------------------------ -define(REQUEST_ID_SIZE, 16). % in bytes; before being encoded using base64 -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(<>)}, {?OPAQUE_BINARY(<<"accept-encoding">>), ?OPAQUE_BINARY(<<"gzip">>)}, {?OPAQUE_BINARY(<<"content-type">>), ?OPAQUE_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(<>) -> String = binary_to_list(Binary), % what about unicode? 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) -> case inet:ntoa(IpAddress) of Host when is_list(Host) -> base_url({Host, Port}) end; 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.