Packages
stripity_stripe
2.17.3
3.3.2
3.3.1
3.2.0
3.1.1
3.1.0
3.0.0
2.17.3
2.17.2
2.17.1
2.17.0
2.16.0
2.15.1
2.15.0
2.14.1
2.14.0
2.13.0
2.12.1
2.12.0
2.11.0
2.10.0
2.9.0
2.8.0
2.7.2
2.7.1
2.7.0
2.6.0
2.5.0
2.4.0
2.3.0
2.2.3
2.2.2
2.2.1
2.2.0
2.1.0
2.0.1
2.0.0
2.0.0-alpha.11
2.0.0-alpha.10
2.0.0-alpha.9
2.0.0-alpha.8
2.0.0-alpha.7
2.0.0-alpha.6
2.0.0-alpha.5
2.0.0-alpha.4
2.0.0-alpha.3
2.0.0-alpha.2
2.0.0-alpha.1
1.6.2
1.6.1
1.6.0
1.4.0
1.3.0
1.2.0
1.1.0
0.5.0
0.4.0
0.3.0
0.2.0
A Stripe client for Elixir.
Current section
Files
Jump to
Current section
Files
lib/stripe/api.ex
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 "2020-08-27"
@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 = http_module().request(method, url, headers |> Map.to_list(), body, opts)
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 = headers |> List.keyfind("Request-Id", 0)
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