-module(erqwest).
-export([ feature/1
, make_client/0
, make_client/1
, close_client/1
, start_client/1
, start_client/2
, stop_client/1
, get_client/1
, req/2
, send/2
, finish_send/1
, read/1
, read/2
, cancel/1
, get/2
, get/3
, post/3
, put/3
, delete/3
, patch/3
]).
-export_type([ client/0
, method/0
, req_opts/0
, read_opts/0
, resp/0
, err/0
, handle/0
]).
-opaque client() :: erlang:nif_resource().
-record(handle, { inner :: erlang:nif_resource()
, ref :: reference()
, owner :: pid()
}).
-opaque handle() :: #handle{}.
%% rules are applied in order, see https://docs.rs/reqwest/0.11.4/reqwest/struct.Proxy.html
-type proxy_config() :: [{http | https | all, proxy_spec()}].
-type proxy_spec() :: #{ url := binary()
, basic_auth => {Username::binary(), Password::binary()}
}.
-type timeout_ms() :: non_neg_integer() | infinity.
-type client_opts() :: #{ identity => {Pkcs12Der::binary(), Password::binary()}
, follow_redirects => boolean() | non_neg_integer() %% default true
, additional_root_certs => [CertDer::binary()]
, use_built_in_root_certs => boolean() %% default true
, danger_accept_invalid_hostnames => boolean() %% default false
, danger_accept_invalid_certs => boolean() %% default false
, proxy => system | no_proxy | proxy_config() %% default system
, connect_timeout => timeout_ms()
, timeout => timeout_ms()
, pool_idle_timeout => timeout_ms()
, pool_max_idle_per_host => non_neg_integer()
, https_only => boolean() %% default false
, cookie_store => boolean() %% default false
, gzip => boolean() %% default false
}.
-type method() :: options | get | post | put | delete | head | trace | connect | patch.
-type header() :: {binary(), binary()}.
-type req_opts() :: #{ url := binary()
, method := method()
, headers => [header()]
, body => iodata() | stream %% default empty
, response_body => complete | stream %% default complete
, timeout => timeout_ms()
}.
-type req_opts_optional() :: #{ headers => [header()]
, body => iodata() | stream %% default empty
, timeout => timeout_ms()
, body => iodata() | stream %% default empty
, response_body => complete | stream %% default complete
}.
-type read_opts() :: #{ period => timeout_ms()
, length => pos_integer()
}.
-type resp() :: #{ status := 100..599
, body := binary() | handle()
, headers := [header()]
}.
-type err() :: #{ code := timeout | redirect | url | connect | request | body | cancelled | unknown
, reason := binary()
}.
-type feature() :: cookies | gzip.
-include_lib("stdlib/include/assert.hrl").
%% Because the pid that messages are sent to is fixed, handle() can't be passed
%% between processes.
-define(assertOwner(Handle),
?assertEqual(self(), Handle#handle.owner,
"handle() may only be used by one process")).
%% @doc Determines whether a compile-time feature is available. Enable features
%% by adding them to the environment variable `ERQWEST_FEATURES' (comma
%% separated list) at build time.
-spec feature(feature()) -> boolean().
feature(Feature) ->
erqwest_nif:feature(Feature).
%% @equiv make_client(#{})
-spec make_client() -> client().
make_client() ->
make_client(#{}).
%% @doc Make a new client with its own connection pool. See also {@link start_client/2}.
-spec make_client(client_opts()) -> client().
make_client(Opts) ->
erqwest_nif:make_client(erqwest_runtime:get(), Opts).
%% @doc Close a client and idle connections in its pool. Returns immediately,
%% but the connection pool will not be cleaned up until all in-flight requests
%% for this client have returned.
%%
%% You do not have to call this function, since
%% the client will automatically be cleaned up when it is garbage collected by
%% the VM.
%%
%% Fails with reason badarg if the client has already been closed.
-spec close_client(client()) -> ok.
close_client(Client) ->
erqwest_nif:close_client(Client).
%% @equiv start_client(Name, #{})
-spec start_client(atom()) -> ok.
start_client(Name) ->
start_client(Name, #{}).
%% @doc Start a client registered under `Name'. The implementation uses
%% `persistent_term' and is not intended for clients that will be frequently
%% started and stopped. For such uses see {@link make_client/1}.
-spec start_client(atom(), client_opts()) -> ok.
start_client(Name, Opts) ->
Client = make_client(Opts),
persistent_term:put({?MODULE, Name}, Client).
%% @doc Unregisters and calls {@link close_client/1} on a named client. This is
%% potentially expensive and should not be called frequently, see {@link
%% start_client/2} for more details.
-spec stop_client(atom()) -> ok.
stop_client(Name) ->
Client = persistent_term:get({?MODULE, Name}),
persistent_term:erase({?MODULE, Name}),
close_client(Client).
%% @private
get_client(Client) when is_atom(Client) ->
persistent_term:get({?MODULE, Client});
get_client(Client) ->
Client.
%% @doc Make a synchronous request.
%%
%% Fails with reason badarg if any argument is invalid or if the client has
%% already been closed. If you set `body' to `stream', you will get back
%% `{handle, handle()}', which you need to pass to {@link send/2} and {@link
%% finish_send/1} to stream the request body. If you set `response_body' to
%% `stream', the `body' key in `resp()' be a `handle()' that you need to pass to
%% `read' to consume the response body. If you decide not to consume the
%% response body, call {@link cancel/1}.
-spec req(client() | atom(), req_opts()) ->
{ok, resp()} | {handle, handle()} | {error, err()}.
req(Client, #{body := stream}=Req) ->
Inner = erqwest_nif:req(get_client(Client), self(), Ref=make_ref(), Req),
receive
{erqwest_response, Ref, next} ->
{handle, #handle{inner=Inner, ref=Ref, owner=self()}};
{erqwest_response, Ref, error, Resp} ->
{error, Resp}
end;
req(Client, Req) ->
Inner = erqwest_nif:req(get_client(Client), self(), Ref=make_ref(), Req),
receive
{erqwest_response, Ref, reply, Resp} ->
case Req of
#{response_body := stream} ->
{ok, Resp#{body => #handle{inner=Inner, ref=Ref, owner=self()}}};
#{} ->
{ok, Resp}
end;
{erqwest_response, Ref, error, Resp} ->
{error, Resp}
end.
%% @doc Stream a chunk of the request body. Returns `ok' once the chunk has
%% successfully been queued for transmission. Note that due to buffering this
%% does not mean that the chunk has actually been sent. Blocks once the internal
%% buffer is full. Call {@link finish_send/1} once the body is complete.
%% `{reply, resp()}' is returned if the server chooses to reply before the
%% request body is complete.
-spec send(handle(), iodata()) ->
ok | {reply, resp()} | {error, err()}.
send(#handle{inner=Inner, ref=Ref}=Handle, Data) ->
?assertOwner(Handle),
ok = erqwest_nif:send(Inner, Data),
receive
{erqwest_response, Ref, next} -> ok;
{erqwest_response, Ref, reply, Resp} -> {reply, maybe_stream(Handle, Resp)};
{erqwest_response, Ref, error, Err} -> {error, Err}
end.
%% @doc Complete sending the request body. Awaits the server's reply. Return
%% values are as described for {@link req/2}.
-spec finish_send(handle()) -> {ok, resp()} | {error, err()}.
finish_send(#handle{inner=Inner, ref=Ref}=Handle) ->
?assertOwner(Handle),
erqwest_nif:finish_send(Inner),
receive
{erqwest_response, Ref, reply, Resp} -> {ok, maybe_stream(Handle, Resp)};
{erqwest_response, Ref, error, Err} -> {error, Err}
end.
%% @equiv read(Handle, #{})
-spec read(handle()) -> {more, binary()} | {ok, binary()} | {error, err()}.
read(Handle) ->
read(Handle, #{}).
%% @doc Read a chunk of the response body, waiting for at most `period' ms or
%% until at least `length' bytes have been read. `length' defaults to 8 MB if
%% omitted, and `period' to `infinity'. Note that more than `length' bytes can
%% be returned. Returns `{more, binary()}' if there is more data to be read, and
%% `{ok, binary()}' once the body is complete.
-spec read(handle(), map() | cancel) ->
{more, binary()} | {ok, binary()} | {error, err()}.
read(#handle{inner=Inner, ref=Ref}=Handle, Opts) ->
?assertOwner(Handle),
ok = erqwest_nif:read(Inner, Opts),
receive
{erqwest_response, Ref, chunk, Data} -> {more, Data};
{erqwest_response, Ref, fin, Data} -> {ok, Data};
{erqwest_response, Ref, error, Err} -> {error, Err}
end.
%% @doc Used to cancel streaming of a request or response body.
-spec cancel(handle()) -> ok.
cancel(#handle{inner=Inner}=Handle) ->
?assertOwner(Handle),
erqwest_nif:cancel_stream(Inner).
%% @doc Convenience wrapper for {@link req/2}.
-spec get(client() | atom(), binary()) ->
{ok, resp()} | {handle, handle()} | {error, err()}.
get(Client, Url) ->
get(Client, Url, #{}).
%% @doc Convenience wrapper for {@link req/2}.
-spec get(client() | atom(), binary(), req_opts_optional()) ->
{ok, resp()} | {handle, handle()} | {error, err()}.
get(Client, Url, Opts) ->
req(Client, Opts#{url => Url, method => get}).
%% @doc Convenience wrapper for {@link req/2}.
-spec post(client() | atom(), binary(), req_opts_optional()) ->
{ok, resp()} | {handle, handle()} | {error, err()}.
post(Client, Url, Opts) ->
req(Client, Opts#{url => Url, method => post}).
%% @doc Convenience wrapper for {@link req/2}.
-spec put(client() | atom(), binary(), req_opts_optional()) ->
{ok, resp()} | {handle, handle()} | {error, err()}.
put(Client, Url, Opts) ->
req(Client, Opts#{url => Url, method => put}).
%% @doc Convenience wrapper for {@link req/2}.
-spec delete(client() | atom(), binary(), req_opts_optional()) ->
{ok, resp()} | {handle, handle()} | {error, err()}.
delete(Client, Url, Opts) ->
req(Client, Opts#{url => Url, method => delete}).
%% @doc Convenience wrapper for {@link req/2}.
-spec patch(client() | atom(), binary(), req_opts_optional()) ->
{ok, resp()} | {handle, handle()} | {error, err()}.
patch(Client, Url, Opts) ->
req(Client, Opts#{url => Url, method => patch}).
%% internal functions
maybe_stream(_Handle, Resp) when is_map_key(body, Resp) ->
Resp;
maybe_stream(Handle, Resp) ->
Resp#{body => Handle}.