defmodule Stripe.API do
@moduledoc """
Low-level utilities for interacting with the Stripe API.
Usually the utilities in `Stripe.Request` are a better way to write custom interactions with
the API.
"""
alias Stripe.{Config, Error}
@callback oauth_request(method, String.t(), map) :: {:ok, map}
@type method :: :get | :post | :put | :delete | :patch
@type headers :: %{String.t() => String.t()} | %{}
@type body :: iodata() | {:multipart, list()}
@typep http_success :: {:ok, integer, [{String.t(), String.t()}], String.t()}
@typep http_failure :: {:error, term}
@pool_name __MODULE__
@api_version "2022-11-15"
@idempotency_key_header "Idempotency-Key"
@default_max_attempts 3
@default_base_backoff 500
@default_max_backoff 2_000
@doc """
In config.exs your implicit or explicit configuration is:
config :stripity_stripe,
json_library: Poison # defaults to Jason but can be configured to Poison
"""
@spec json_library() :: module
def json_library() do
Config.resolve(:json_library, Jason)
end
def supervisor_children do
if use_pool?() do
[:hackney_pool.child_spec(@pool_name, get_pool_options())]
else
[]
end
end
@spec get_pool_options() :: Keyword.t()
defp get_pool_options() do
Config.resolve(:pool_options)
end
@spec get_base_url() :: String.t()
defp get_base_url() do
Config.resolve(:api_base_url)
end
@spec get_upload_url() :: String.t()
defp get_upload_url() do
Config.resolve(:api_upload_url)
end
@spec get_default_api_key() :: String.t()
defp get_default_api_key() do
# if no API key is set default to `""` which will raise a Stripe API error
Config.resolve(:api_key, "")
end
@spec get_api_version() :: String.t()
defp get_api_version() do
Config.resolve(:api_version, @api_version)
end
@spec use_pool?() :: boolean
defp use_pool?() do
Config.resolve(:use_connection_pool)
end
@spec http_module() :: module
defp http_module() do
Config.resolve(:http_module, :hackney)
end
@spec retry_config() :: Keyword.t()
defp retry_config() do
Config.resolve(:retries, [])
end
@doc """
Checks if an error is a problem that we should retry on. This includes both
socket errors that may represent an intermittent problem and some special
HTTP statuses.
"""
@spec should_retry?(
http_success | http_failure,
attempts :: non_neg_integer,
config :: Keyword.t()
) :: boolean
def should_retry?(response, attempts \\ 0, config \\ []) do
max_attempts = Keyword.get(config, :max_attempts) || @default_max_attempts
if attempts >= max_attempts do
false
else
retry_response?(response)
end
end
@doc """
A low level utility function to generate a new idempotency key for
`#{@idempotency_key_header}` request header value.
"""
@spec generate_idempotency_key() :: binary
def generate_idempotency_key do
binary = <<
System.system_time(:nanosecond)::64,
:erlang.phash2({node(), self()}, 16_777_216)::24,
System.unique_integer([:positive])::32
>>
Base.hex_encode32(binary, case: :lower, padding: false)
end
@spec add_common_headers(headers) :: headers
defp add_common_headers(existing_headers) do
Map.merge(existing_headers, %{
"Accept" => "application/json; charset=utf8",
"Accept-Encoding" => "gzip",
"Connection" => "keep-alive"
})
end
@spec add_default_headers(headers) :: headers
defp add_default_headers(existing_headers) do
existing_headers = add_common_headers(existing_headers)
case Map.has_key?(existing_headers, "Content-Type") do
false -> existing_headers |> Map.put("Content-Type", "application/x-www-form-urlencoded")
true -> existing_headers
end
end
@spec add_multipart_form_headers(headers) :: headers
defp add_multipart_form_headers(existing_headers) do
existing_headers
|> Map.put("Content-Type", "multipart/form-data")
end
@spec add_idempotency_headers(headers, method) :: headers
defp add_idempotency_headers(existing_headers, method)
when method in [:get, :head, :put, :delete] do
existing_headers
end
defp add_idempotency_headers(existing_headers, _method) do
# By using `Map.put_new/3` instead of `Map.put/3`, we allow users to
# provide their own idempotency key.
existing_headers
|> Map.put_new(@idempotency_key_header, generate_idempotency_key())
end
@spec maybe_add_auth_header_oauth(headers, String.t(), String.t() | nil) :: headers
defp maybe_add_auth_header_oauth(headers, "deauthorize", api_key),
do: add_auth_header(headers, api_key)
defp maybe_add_auth_header_oauth(headers, _endpoint, _api_key), do: headers
@spec add_auth_header(headers, String.t() | nil) :: headers
defp add_auth_header(existing_headers, api_key) do
api_key = fetch_api_key(api_key)
Map.put(existing_headers, "Authorization", "Bearer #{api_key}")
end
@spec fetch_api_key(String.t() | nil) :: String.t()
defp fetch_api_key(api_key) do
case api_key do
key when is_binary(key) -> key
_ -> get_default_api_key()
end
end
@spec add_connect_header(headers, String.t() | nil) :: headers
defp add_connect_header(existing_headers, nil), do: existing_headers
defp add_connect_header(existing_headers, account_id) do
Map.put(existing_headers, "Stripe-Account", account_id)
end
@spec add_api_version(headers, String.t() | nil) :: headers
defp add_api_version(existing_headers, nil),
do: add_api_version(existing_headers, get_api_version())
defp add_api_version(existing_headers, api_version) do
Map.merge(existing_headers, %{
"User-Agent" => "Stripe/v1 stripe-elixir/#{api_version}",
"Stripe-Version" => api_version
})
end
@spec add_default_options(list) :: list
defp add_default_options(opts) do
[:with_body | opts]
end
@spec add_pool_option(list) :: list
defp add_pool_option(opts) do
if use_pool?() do
[{:pool, @pool_name} | opts]
else
opts
end
end
@spec add_options_from_config(list) :: list
defp add_options_from_config(opts) do
if is_list(Stripe.Config.resolve(:hackney_opts)) do
opts ++ Stripe.Config.resolve(:hackney_opts)
else
opts
end
end
@doc """
A low level utility function to make a direct request to the Stripe API
## Setting the api key
request(%{}, :get, "/customers", %{}, api_key: "bogus key")
## Setting api version
The api version defaults to #{@api_version} but a custom version can be passed
in as follows:
request(%{}, :get, "/customers", %{}, api_version: "2018-11-04")
## Connect Accounts
If you'd like to make a request on behalf of another Stripe account
utilizing the Connect program, you can pass the other Stripe account's
ID to the request function as follows:
request(%{}, :get, "/customers", %{}, connect_account: "acc_134151")
"""
@spec request(map, method, String.t(), headers, list) ::
{:ok, map} | {:error, Stripe.Error.t()}
def request(body, :get, endpoint, headers, opts) do
{expansion, opts} = Keyword.pop(opts, :expand)
base_url = get_base_url()
req_url =
body
|> Stripe.Util.map_keys_to_atoms()
|> add_object_expansion(expansion)
|> Stripe.URI.encode_query()
|> prepend_url("#{base_url}#{endpoint}")
perform_request(req_url, :get, "", headers, opts)
end
def request(body, method, endpoint, headers, opts) do
{expansion, opts} = Keyword.pop(opts, :expand)
{idempotency_key, opts} = Keyword.pop(opts, :idempotency_key)
base_url = get_base_url()
req_url = add_object_expansion("#{base_url}#{endpoint}", expansion)
headers = add_idempotency_header(idempotency_key, headers, method)
req_body =
body
|> Stripe.Util.map_keys_to_atoms()
|> Stripe.URI.encode_query()
perform_request(req_url, method, req_body, headers, opts)
end
@doc """
A low level utility function to make a direct request to the files Stripe API
"""
@spec request_file_upload(map, method, String.t(), headers, list) ::
{:ok, map} | {:error, Stripe.Error.t()}
def request_file_upload(body, :post, endpoint, headers, opts) do
base_url = get_upload_url()
req_url = base_url <> endpoint
req_headers =
headers
|> add_multipart_form_headers()
parts =
body
|> Enum.map(fn {key, value} ->
{Stripe.Util.multipart_key(key), value}
end)
perform_request(req_url, :post, {:multipart, parts}, req_headers, opts)
end
def request_file_upload(body, method, endpoint, headers, opts) do
base_url = get_upload_url()
req_url = base_url <> endpoint
req_body =
body
|> Stripe.Util.map_keys_to_atoms()
|> Stripe.URI.encode_query()
perform_request(req_url, method, req_body, headers, opts)
end
@doc """
A low level utility function to make an OAuth request to the Stripe API
"""
@spec oauth_request(method, String.t(), map, String.t() | nil) ::
{:ok, map} | {:error, Stripe.Error.t()}
def oauth_request(method, endpoint, body, api_key \\ nil, opts \\ []) do
base_url = "https://connect.stripe.com/oauth/"
req_url = base_url <> endpoint
req_body = Stripe.URI.encode_query(body)
{api_version, _opts} = Keyword.pop(opts, :api_version)
req_headers =
%{}
|> add_default_headers()
|> maybe_add_auth_header_oauth(endpoint, api_key)
|> add_api_version(api_version)
|> add_idempotency_headers(method)
req_opts =
[]
|> add_default_options()
|> add_pool_option()
|> add_options_from_config()
do_perform_request(method, req_url, req_headers, req_body, req_opts)
end
@spec perform_request(String.t(), method, body, headers, list) ::
{:ok, map} | {:error, Stripe.Error.t()}
defp perform_request(req_url, method, body, headers, opts) do
{connect_account_id, opts} = Keyword.pop(opts, :connect_account)
{api_version, opts} = Keyword.pop(opts, :api_version)
{api_key, opts} = Keyword.pop(opts, :api_key)
req_headers =
headers
|> add_default_headers()
|> add_auth_header(api_key)
|> add_connect_header(connect_account_id)
|> add_api_version(api_version)
|> add_idempotency_headers(method)
req_opts =
opts
|> add_default_options()
|> add_pool_option()
|> add_options_from_config()
do_perform_request(method, req_url, req_headers, body, req_opts)
end
@spec do_perform_request(method, String.t(), headers, body, list) ::
{:ok, map} | {:error, Stripe.Error.t()}
defp do_perform_request(method, url, headers, body, opts) do
do_perform_request_and_retry(method, url, headers, body, opts, {:attempts, 0})
end
@spec do_perform_request_and_retry(
method,
String.t(),
headers,
body,
list,
{:attempts, non_neg_integer} | {:response, http_success | http_failure}
) :: {:ok, map} | {:error, Stripe.Error.t()}
defp do_perform_request_and_retry(_method, _url, _headers, _body, _opts, {:response, response}) do
handle_response(response)
end
defp do_perform_request_and_retry(method, url, headers, body, opts, {:attempts, attempts}) do
response =
:telemetry.span(~w[stripe request]a, %{url: url, method: method}, fn ->
case http_module().request(method, url, Map.to_list(headers), body, opts) do
{:ok, status, _, _} = resp ->
{resp, %{status: status}}
error ->
{error, %{}}
end
end)
do_perform_request_and_retry(
method,
url,
headers,
body,
opts,
add_attempts(headers, response, attempts, retry_config())
)
end
@spec add_attempts(headers, http_success | http_failure, non_neg_integer, Keyword.t()) ::
{:attempts, non_neg_integer} | {:response, http_success | http_failure}
defp add_attempts(%{@idempotency_key_header => _}, response, attempts, retry_config) do
# only retry if allowed Stripe idempotency method
if should_retry?(response, attempts, retry_config) do
attempts
|> backoff(retry_config)
|> :timer.sleep()
{:attempts, attempts + 1}
else
{:response, response}
end
end
defp add_attempts(_headers, response, _attempts, _retry_config) do
{:response, response}
end
@doc """
Returns backoff in milliseconds.
"""
@spec backoff(attempts :: non_neg_integer, config :: Keyword.t()) :: non_neg_integer
def backoff(attempts, config) do
base_backoff = Keyword.get(config, :base_backoff) || @default_base_backoff
max_backoff = Keyword.get(config, :max_backoff) || @default_max_backoff
(base_backoff * :math.pow(2, attempts))
|> min(max_backoff)
|> backoff_jitter()
|> max(base_backoff)
|> trunc()
end
@spec backoff_jitter(float) :: float
defp backoff_jitter(n) do
# Apply some jitter by randomizing the value in the range of (n / 2) to n
n * (0.5 * (1 + :rand.uniform()))
end
@spec retry_response?(http_success | http_failure) :: boolean
# 409 conflict
defp retry_response?({:error, 409, _headers, _body}), do: true
# https://github.com/beam-community/stripity_stripe/issues/686
defp retry_response?({:error, 429, _headers, _body}), do: true
# Destination refused the connection, the connection was reset, or a
# variety of other connection failures. This could occur from a single
# saturated server, so retry in case it's intermittent.
defp retry_response?({:error, :econnrefused}), do: true
# Retry on timeout-related problems (either on open or read).
defp retry_response?({:error, :connect_timeout}), do: true
defp retry_response?({:error, :timeout}), do: true
defp retry_response?(_response), do: false
@spec handle_response(http_success | http_failure) :: {:ok, map} | {:error, Stripe.Error.t()}
defp handle_response({:ok, status, headers, body}) when status >= 200 and status <= 299 do
decoded_body =
body
|> decompress_body(headers)
|> json_library().decode!()
{:ok, decoded_body}
end
defp handle_response({:ok, status, headers, body}) when status >= 300 and status <= 599 do
request_id =
Enum.find_value(headers, fn
{"Request-Id", request_id} -> request_id
_header -> nil
end)
error =
case json_library().decode(body) do
{:ok, %{"error_description" => _} = api_error} ->
Error.from_stripe_error(status, api_error, request_id)
{:ok, %{"error" => api_error}} ->
Error.from_stripe_error(status, api_error, request_id)
{:error, _} ->
# e.g. if the body is empty
Error.from_stripe_error(status, nil, request_id)
end
{:error, error}
end
defp handle_response({:error, reason}) do
error = Error.from_hackney_error(reason)
{:error, error}
end
defp decompress_body(body, headers) do
headers_dict = :hackney_headers.new(headers)
case :hackney_headers.get_value("Content-Encoding", headers_dict) do
"gzip" -> :zlib.gunzip(body)
"deflate" -> :zlib.unzip(body)
_ -> body
end
end
defp prepend_url("", url), do: url
defp prepend_url(query, url), do: "#{url}?#{query}"
defp add_object_expansion(query, expansion) when is_map(query) and is_list(expansion) do
query |> Map.put(:expand, expansion)
end
defp add_object_expansion(url, expansion) when is_list(expansion) do
expansion
|> Enum.map(&"expand[]=#{&1}")
|> Enum.join("&")
|> prepend_url(url)
end
defp add_object_expansion(url, _), do: url
defp add_idempotency_header(nil, headers, _), do: headers
defp add_idempotency_header(idempotency_key, headers, :post) do
Map.put(headers, "Idempotency-Key", idempotency_key)
end
defp add_idempotency_header(_, headers, _), do: headers
end