Current section

Files

Jump to
httpc_aws src httpc_aws.erl
Raw

src/httpc_aws.erl

%% ====================================================================
%% @author Gavin M. Roy <gavinmroy@gmail.com>
%% @copyright 2016, Gavin M. Roy
%% @doc httpc_aws client library
%% @end
%% ====================================================================
-module(httpc_aws).
-behavior(gen_server).
%% API exports
-export([delete/3,
get/2,
get/3,
post/4,
refresh_credentials/0,
request/5, request/6, request/7,
set_credentials/2,
set_region/1]).
%% gen-server exports
-export([start_link/0,
init/1,
terminate/2,
code_change/3,
handle_call/3,
handle_cast/2,
handle_info/2]).
%% Export all for unit tests
-ifdef(TEST).
-compile(export_all).
-endif.
-include("httpc_aws.hrl").
%%====================================================================
%% exported wrapper functions
%%====================================================================
-spec delete(Service :: string(),
Path :: path(),
Headers :: headers()) -> result().
%% @doc Perform a HTTP DELETE request to the AWS API for the specified service. The
%% response will automatically be decoded if it is either in JSON or XML
%% format.
%% @end
delete(Service, Path, Headers) ->
request(Service, delete, Path, "", Headers).
-spec get(Service :: string(),
Path :: path()) -> result().
%% @doc Perform a HTTP GET request to the AWS API for the specified service. The
%% response will automatically be decoded if it is either in JSON or XML
%% format.
%% @end
get(Service, Path) ->
get(Service, Path, []).
-spec get(Service :: string(),
Path :: path(),
Headers :: headers()) -> result().
%% @doc Perform a HTTP GET request to the AWS API for the specified service. The
%% response will automatically be decoded if it is either in JSON or XML
%% format.
%% @end
get(Service, Path, Headers) ->
request(Service, get, Path, "", Headers).
-spec post(Service :: string(),
Path :: path(),
Body :: body(),
Headers :: headers()) -> result().
%% @doc Perform a HTTP Post request to the AWS API for the specified service. The
%% response will automatically be decoded if it is either in JSON or XML
%% format.
%% @end
post(Service, Path, Body, Headers) ->
request(Service, post, Path, Body, Headers).
-spec refresh_credentials() -> ok | error.
%% @doc Manually refresh the credentials from the environment, filesystem or EC2
%% Instance metadata service.
%% @end
refresh_credentials() ->
wpool:call(httpc_aws, refresh_credentials).
-spec request(Service :: string(),
Method :: method(),
Path :: path(),
Body :: body(),
Headers :: headers()) -> result().
%% @doc Perform a HTTP request to the AWS API for the specified service. The
%% response will automatically be decoded if it is either in JSON or XML
%% format.
%% @end
request(Service, Method, Path, Body, Headers) ->
wpool:call(httpc_aws, {request, Service, Method, Headers, Path, Body, [], undefined}, available_worker, infinite).
-spec request(Service :: string(),
Method :: method(),
Path :: path(),
Body :: body(),
Headers :: headers(),
HTTPOptions :: http_options()) -> result().
%% @doc Perform a HTTP request to the AWS API for the specified service. The
%% response will automatically be decoded if it is either in JSON or XML
%% format.
%% @end
request(Service, Method, Path, Body, Headers, HTTPOptions) ->
wpool:call(httpc_aws, {request, Service, Method, Headers, Path, Body, HTTPOptions, undefined}, available_worker, infinite).
-spec request(Service :: string(),
Method :: method(),
Path :: path(),
Body :: body(),
Headers :: headers(),
HTTPOptions :: http_options(),
Endpoint :: host()) -> result().
%% @doc Perform a HTTP request to the AWS API for the specified service, overriding
%% the endpoint URL to use when invoking the API. This is useful for local testing
%% of services such as DynamoDB. The response will automatically be decoded
%% if it is either in JSON or XML format.
%% @end
request(Service, Method, Path, Body, Headers, HTTPOptions, Endpoint) ->
wpool:call(httpc_aws, {request, Service, Method, Headers, Path, Body, HTTPOptions, Endpoint}, available_worker, infinite).
-spec set_credentials(access_key(), secret_access_key()) -> ok.
%% @doc Manually set the access credentials for requests. This should
%% be used in cases where the client application wants to control
%% the credentials instead of automatically discovering them from
%% configuration or the AWS Instance Metadata service.
%% @end
set_credentials(AccessKey, SecretAccessKey) ->
wpool:call(httpc_aws, {set_credentials, AccessKey, SecretAccessKey}, available_worker).
-spec set_region(Region :: string()) -> ok.
%% @doc Manually set the AWS region to perform API requests to.
%% @end
set_region(Region) ->
wpool:call(httpc_aws, {set_region, Region}, available_worker).
%%====================================================================
%% gen_server functions
%%====================================================================
start_link() ->
wpool:start_pool(?MODULE, [{worker, {?MODULE, []}}]).
-spec init(list()) -> {ok, state()}.
init([]) ->
{ok, Region} = httpc_aws_config:region(),
{_, State} = load_credentials(#state{region = Region}),
{ok, State}.
terminate(_, _) ->
ok.
code_change(_, _, State) ->
{ok, State}.
handle_call({request, Service, Method, Headers, Path, Body, Options, Host}, _From, State) ->
{Response, NewState} = perform_request(State, Service, Method, Headers, Path, Body, Options, Host),
{reply, Response, NewState};
handle_call(get_state, _, State) ->
{reply, {ok, State}, State};
handle_call(refresh_credentials, _, State) ->
{Reply, NewState} = load_credentials(State),
{reply, Reply, NewState};
handle_call({set_credentials, AccessKey, SecretAccessKey}, _, State) ->
{reply, ok, State#state{access_key = AccessKey,
secret_access_key = SecretAccessKey,
security_token = undefined,
expiration = undefined,
error = undefined,
region = State#state.region}};
handle_call({set_region, Region}, _, State) ->
{reply, ok, State#state{access_key = State#state.access_key,
secret_access_key = State#state.secret_access_key,
security_token = State#state.security_token,
expiration = State#state.expiration,
error = State#state.error,
region = Region}};
handle_call(_Request, _From, State) ->
{noreply, State}.
handle_cast(_Request, State) ->
{noreply, State}.
handle_info(_Info, State) ->
{noreply, State}.
%%====================================================================
%% Internal functions
%%====================================================================
-spec endpoint(State :: state(), Host :: string(),
Service :: string(), Path :: string()) -> string().
%% @doc Return the endpoint URL, either by constructing it with the service
%% information passed in or by using the passed in Host value.
%% @ednd
endpoint(#state{region = Region}, undefined, Service, Path) ->
lists:flatten(["https://", endpoint_host(Region, Service), Path]);
endpoint(_, Host, _, Path) ->
lists:flatten(["https://", Host, Path]).
-spec endpoint_host(Region :: region(), Service :: string()) -> host().
%% @doc Construct the endpoint hostname for the request based upon the service
%% and region.
%% @end
endpoint_host(Region, Service) ->
lists:flatten(string:join([Service, Region, endpoint_tld(Region)], ".")).
-spec endpoint_tld(Region :: region()) -> host().
%% @doc Construct the endpoint hostname TLD for the request based upon the region.
%% See https://docs.aws.amazon.com/general/latest/gr/rande.html#ec2_region for details.
%% @end
endpoint_tld("cn-north-1") ->
"amazonaws.com.cn";
endpoint_tld("cn-northwest-1") ->
"amazonaws.com.cn";
endpoint_tld(_Other) ->
"amazonaws.com".
-spec format_response(Response :: httpc_result()) -> result().
%% @doc Format the httpc response result, returning the request result data
%% structure. The response body will attempt to be decoded by invoking the
%% maybe_decode_body/2 method.
%% @end
format_response({ok, {{_Version, 200, _Message}, Headers, Body}}) ->
{ok, {Headers, maybe_decode_body(get_content_type(Headers), Body)}};
format_response({ok, {{_Version, 204, _Message}, Headers, _Body}}) ->
{ok, {Headers, []}};
format_response({ok, {{_Version, StatusCode, Message}, Headers, Body}}) when StatusCode >= 400 ->
{error, Message, {Headers, maybe_decode_body(get_content_type(Headers), Body)}};
format_response({error, Reason}) ->
{error, Reason, undefined}.
-spec get_content_type(Headers :: headers()) -> {Type :: string(), Subtype :: string()}.
%% @doc Fetch the content type from the headers and return it as a tuple of
%% {Type, Subtype}.
%% @end
get_content_type(Headers) ->
Value = case proplists:get_value("content-type", Headers, undefined) of
undefined ->
proplists:get_value("Content-Type", Headers, "text/xml");
Other -> Other
end,
parse_content_type(Value).
-spec has_credentials(state()) -> true | false.
%% @doc check to see if there are credentials made available in the current state
%% returning false if not or if they have expired.
%% @end
has_credentials(#state{error = Error}) when Error /= undefined -> false;
has_credentials(#state{access_key = Key}) when Key /= undefined -> true;
has_credentials(_) -> false.
-spec expired_credentials(Expiration :: expiration() | undefined) -> true | false.
%% @doc Indicates if the date that is passed in has expired.
%% end
expired_credentials(undefined) -> false;
expired_credentials(Expiration) ->
Now = calendar:datetime_to_gregorian_seconds(local_time()),
Expires = calendar:datetime_to_gregorian_seconds(Expiration),
Now >= Expires.
-spec load_credentials(State :: state()) -> {ok | error, state()}.
%% @doc Load the credentials using the following order of configuration precedence:
%% - Environment variables
%% - Credentials file
%% - EC2 Instance Metadata Service
%% @end
load_credentials(#state{region = Region}) ->
load_credentials(httpc_aws_config:credentials(), Region).
-spec load_credentials(security_credentials() | sc_error(), Region :: region() | undefined) -> {ok | error, state()}.
load_credentials({ok, AccessKey, SecretAccessKey, Expiration, SecurityToken}, Region) ->
{ok, #state{region = Region,
error = undefined,
access_key = AccessKey,
secret_access_key = SecretAccessKey,
expiration = Expiration,
security_token = SecurityToken}};
load_credentials({error, Reason}, Region) ->
error_logger:error_msg("Could not load AWS credentials from environment variables, AWS_CONFIG_FILE, AWS_SHARED_CREDENTIALS_FILE or EC2 metadata endpoint: ~p.~n", [Reason]),
{error, #state{region = Region,
error = Reason,
access_key = undefined,
secret_access_key = undefined,
expiration = undefined,
security_token = undefined}}.
-spec local_time() -> calendar:datetime().
%% @doc Return the current local time.
%% @end
local_time() ->
[Value] = calendar:local_time_to_universal_time_dst(calendar:local_time()),
Value.
-spec maybe_decode_body(ContentType :: {nonempty_string(), nonempty_string()}, Body :: body()) -> list() | body().
%% @doc Attempt to decode the response body based upon the mime type that is
%% presented.
%% @end.
maybe_decode_body({"application", "x-amz-json-1.0"}, Body) ->
httpc_aws_json:decode(Body);
maybe_decode_body({"application", "json"}, Body) ->
httpc_aws_json:decode(Body);
maybe_decode_body({_, "xml"}, Body) ->
httpc_aws_xml:parse(Body);
maybe_decode_body(_ContentType, Body) ->
Body.
-spec parse_content_type(ContentType :: string()) -> {Type :: string(), Subtype :: string()}.
%% @doc parse a content type string returning a tuple of type/subtype
%% @end
parse_content_type(ContentType) ->
Parts = string:tokens(ContentType, ";"),
[Type, Subtype] = string:tokens(lists:nth(1, Parts), "/"),
{Type, Subtype}.
-spec perform_request(State :: state(),
Service :: string(),
Method :: method(),
Headers :: headers(),
Path :: path(),
Body :: body(),
Options :: http_options(),
Host :: host())
-> {Result :: result(), NewState :: state()}.
%% @doc Make the API request and return the formatted response.
%% @end
perform_request(State, Service, Method, Headers, Path, Body, Options, Host) ->
perform_request_has_creds(has_credentials(State), State, Service, Method,
Headers, Path, Body, Options, Host).
-spec perform_request_has_creds(true | false,
State :: state(),
Service :: string(),
Method :: method(),
Headers :: headers(),
Path :: path(),
Body :: body(),
Options :: http_options(),
Host :: host())
-> {Result :: result(), NewState :: state()}.
%% @doc Invoked after checking to see if there are credentials. If there are,
%% validate they have not or will not expire, performing the request if not,
%% otherwise return an error result.
%% @end
perform_request_has_creds(true, State, Service, Method, Headers, Path, Body, Options, Host) ->
perform_request_creds_expired(expired_credentials(State#state.expiration), State,
Service, Method, Headers, Path, Body, Options, Host);
perform_request_has_creds(false, State, _, _, _, _, _, _, _) ->
perform_request_creds_error(State).
-spec perform_request_creds_expired(true | false,
State :: state(),
Service :: string(),
Method :: method(),
Headers :: headers(),
Path :: path(),
Body :: body(),
Options :: http_options(),
Host :: host())
-> {Result :: result(), NewState :: state()}.
%% @doc Invoked after checking to see if the current credentials have expired.
%% If they haven't, perform the request, otherwise try and refresh the
%% credentials before performing the request.
%% @end
perform_request_creds_expired(false, State, Service, Method, Headers, Path, Body, Options, Host) ->
perform_request_with_creds(State, Service, Method, Headers, Path, Body, Options, Host);
perform_request_creds_expired(true, State, Service, Method, Headers, Path, Body, Options, Host) ->
perform_request_creds_refreshed(load_credentials(State), Service, Method, Headers, Path, Body, Options, Host).
-spec perform_request_creds_refreshed({ok | error, State :: state()},
Service :: string(),
Method :: method(),
Headers :: headers(),
Path :: path(),
Body :: body(),
Options :: http_options(),
Host :: host())
-> {Result :: result(), NewState :: state()}.
%% @doc If it's been determined that there are credentials but they have expired,
%% check to see if the credentials could be loaded and either make the request
%% or return an error.
%% @end
perform_request_creds_refreshed({ok, State}, Service, Method, Headers, Path, Body, Options, Host) ->
perform_request_with_creds(State, Service, Method, Headers, Path, Body, Options, Host);
perform_request_creds_refreshed({error, State}, _, _, _, _, _, _, _) ->
perform_request_creds_error(State).
-spec perform_request_with_creds(State :: state(),
Service :: string(),
Method :: method(),
Headers :: headers(),
Path :: path(),
Body :: body(),
Options :: http_options(),
Host :: host())
-> {Result :: result(), NewState :: state()}.
%% @doc Once it is validated that there are credentials to try and that they have not
%% expired, perform the request and return the response.
%% @end
perform_request_with_creds(State, Service, Method, Headers, Path, Body, Options, Host) ->
URI = endpoint(State, Host, Service, Path),
SignedHeaders = sign_headers(State, Service, Method, URI, Headers, Body),
ContentType = proplists:get_value("content-type", SignedHeaders, undefined),
perform_request_with_creds(State, Method, URI, SignedHeaders, ContentType, Body, Options).
-spec perform_request_with_creds(State :: state(),
Method :: method(),
URI :: string(),
Headers :: headers(),
ContentType :: string() | undefined,
Body :: body(),
Options :: http_options())
-> {Result :: result(), NewState :: state()}.
%% @doc Once it is validated that there are credentials to try and that they have not
%% expired, perform the request and return the response.
%% @end
perform_request_with_creds(State, Method, URI, Headers, undefined, "", Options) ->
Response = httpc:request(Method, {URI, Headers}, Options, []),
{format_response(Response), State};
perform_request_with_creds(State, Method, URI, Headers, ContentType, Body, Options) ->
Response = httpc:request(Method, {URI, Headers, ContentType, Body}, Options, []),
{format_response(Response), State}.
-spec perform_request_creds_error(State :: state()) -> {{error, {credentials, any()}}, NewState :: state()}.
%% @doc Return the error response when there are not any credentials to use with
%% the request.
%% @end
perform_request_creds_error(State) ->
{{error, {credentials, State#state.error}}, State}.
-spec sign_headers(State :: state(),
Service :: string(),
Method :: method(),
URI :: string(),
Headers :: headers(),
Body :: body()) -> headers().
%% @doc Build the signed headers for the API request.
%% @end
sign_headers(#state{access_key = AccessKey,
secret_access_key = SecretKey,
security_token = SecurityToken,
region = Region}, Service, Method, URI, Headers, Body) ->
httpc_aws_sign:headers(#request{access_key = AccessKey,
secret_access_key = SecretKey,
security_token = SecurityToken,
region = Region,
service = Service,
method = Method,
uri = URI,
headers = Headers,
body = Body}).