lib/ept_sdk.ex

defmodule EPTSDK do
  @default_query_options %{
    filter: %{},
    include: [],
    sort: [],
    page: [],
    fields: %{}
  }
  @enforce_keys [
    :authorization,
    :user_agent,
    :host,
    :json_decoder,
    :json_encoder,
    :namespace,
    :finch_options
  ]
  @default_headers []

  defstruct authorization: nil,
            user_agent: nil,
            host: "api.tryedge.com",
            json_decoder: &Jason.decode/1,
            json_encoder: &Jason.encode/1,
            namespace: EPTSDK.Finch,
            finch_options: [],
            response: nil,
            links: nil,
            included: nil,
            meta: nil

  def client(%{token: token, user_agent: user_agent} = properties) when is_map(properties) do
    struct(
      __MODULE__,
      %{
        authorization: "Bearer #{token}",
        user_agent: user_agent
      }
      |> Map.merge(properties)
    )
  end

  def get(%EPTSDK{} = client, path, query \\ [])
      when is_binary(path) do
    Finch.build(
      :get,
      encode_uri(client.host, path, with_query_defaults(query)),
      default_headers(client, [
        {"Accept", "application/vnd.api+json"}
      ])
    )
    |> request(client)
    |> response(client)
  end

  def options(%EPTSDK{} = client, path, query \\ [])
      when is_binary(path) do
    Finch.build(
      :options,
      encode_uri(client.host, path, with_query_defaults(query)),
      default_headers(client)
    )
    |> request(client)
    |> response(client)
  end

  def delete(%EPTSDK{} = client, path, query \\ [])
      when is_binary(path) do
    Finch.build(
      :delete,
      encode_uri(client.host, path, with_query_defaults(query)),
      default_headers(client)
    )
    |> request(client)
    |> response(client)
  end

  def post(%EPTSDK{} = client, path, data, query \\ [])
      when is_binary(path) and is_map(data) do
    data
    |> client.json_encoder.()
    |> case do
      {:ok, payload} ->
        Finch.build(
          :post,
          encode_uri(client.host, path, with_query_defaults(query)),
          default_headers(client, [
            {"Content-Type", "application/vnd.api+json"},
            {"Accept", "application/vnd.api+json"}
          ]),
          payload
        )
        |> request(client)
        |> response(client)

      error ->
        error
    end
  end

  def patch(%EPTSDK{} = client, path, data, query \\ [])
      when is_binary(path) and is_map(data) do
    data
    |> client.json_encoder.()
    |> case do
      {:ok, payload} ->
        Finch.build(
          :patch,
          encode_uri(client.host, path, with_query_defaults(query)),
          default_headers(client, [
            {"Content-Type", "application/vnd.api+json"},
            {"Accept", "application/vnd.api+json"}
          ]),
          payload
        )
        |> request(client)
        |> response(client)

      error ->
        error
    end
  end

  def put(%EPTSDK{} = client, path, data, query \\ [])
      when is_binary(path) and is_map(data) do
    data
    |> client.json_encoder.()
    |> case do
      {:ok, payload} ->
        Finch.build(
          :put,
          encode_uri(client.host, path, with_query_defaults(query)),
          default_headers(client, [
            {"Content-Type", "application/vnd.api+json"},
            {"Accept", "application/vnd.api+json"}
          ]),
          payload
        )
        |> request(client)
        |> response(client)

      error ->
        error
    end
  end

  defp encode_uri(host, path, nil)
       when is_binary(host) and is_binary(path),
       do:
         encode_uri(
           URI.parse("https://#{host}"),
           URI.parse(path),
           nil
         )

  defp encode_uri(host, path, nil)
       when is_struct(host, URI) and is_struct(path, URI),
       do: URI.parse("#{host}#{path}")

  defp encode_uri(host, path, query)
       when is_binary(host) and is_binary(path) and is_map(query),
       do:
         encode_uri(
           URI.parse("https://#{host}"),
           URI.parse(path),
           Plug.Conn.Query.encode(query)
         )

  defp encode_uri(host, path, query)
       when is_struct(host, URI) and is_struct(path, URI) and is_binary(query),
       do: URI.parse("#{host}#{path}?#{query}")

  defp default_headers(
         %EPTSDK{user_agent: user_agent, authorization: authorization},
         custom_headers \\ []
       )
       when is_binary(user_agent) and is_list(custom_headers) do
    @default_headers
    |> Enum.concat([
      {"User-Agent", Enum.join(["Edge Payment Client/1.0.0", user_agent], " ")},
      {"Authorization", authorization}
    ])
    |> Enum.concat(custom_headers)
  end

  defp request(finch_client, edge_client),
    do: finch_client |> Finch.request(edge_client.namespace, edge_client.finch_options)

  defp response({:ok, %Finch.Response{status: 422, body: body} = response}, edge_client) do
    edge_client.json_decoder.(body)
    |> case do
      {:ok, payload} -> {:unprocessable_content, payload, response}
      {:error, decoding_error} -> {:decoding_error, decoding_error, response}
    end
  end

  defp response({:ok, %Finch.Response{status: status} = response}, _edge_client)
       when status in 400..499 do
    {:error, response}
  end

  defp response({:ok, %Finch.Response{body: ""} = response}, _edge_client) do
    {:ok, nil, response}
  end

  defp response({:ok, %Finch.Response{body: body} = response}, edge_client) do
    edge_client.json_decoder.(body)
    |> case do
      {:ok, payload} -> {:ok, payload, response}
      {:error, decoding_error} -> {:decoding_error, decoding_error, response}
    end
  end

  defp response({:error, error}, _edge_client) do
    {:error, error}
  end

  defp with_query_defaults(nil), do: nil
  defp with_query_defaults([]), do: nil

  defp with_query_defaults(query) when is_list(query) do
    %{
      filter: query |> Keyword.get(:filter, @default_query_options[:filter]),
      fields:
        query
        |> Keyword.get(:fields, @default_query_options[:fields])
        |> encode_fields_query(),
      sort: query |> Keyword.get(:sort, @default_query_options[:sort]) |> Enum.join(","),
      page: query |> Keyword.get(:page, @default_query_options[:page]),
      include: query |> Keyword.get(:include, @default_query_options[:include]) |> Enum.join(",")
    }
    |> Enum.filter(fn
      {_key, ""} -> false
      {_key, nil} -> false
      {_key, _value} -> true
    end)
    |> Map.new()
  end

  defp with_query_defaults(%{}), do: %{}

  defp encode_fields_query(fields) when is_map(fields) do
    fields
    |> Enum.map(fn {resource, fields} when is_list(fields) ->
      {resource, Enum.join(fields, ",")}
    end)
    |> Map.new()
  end

  def update_client_from_request(
        {:ok, payload, %Finch.Response{} = response},
        %EPTSDK{} = client
      )
      when is_map(payload) do
    {:ok, payload,
     %__MODULE__{
       client
       | response: response,
         included: Enum.map(payload["included"] || [], &EPTSDK.Entity.to_struct(&1, nil)),
         links: payload["links"],
         meta: payload["meta"]
     }}
  end

  def update_client_from_request({:error, _exception} = error, _client), do: error

  def update_client_from_request(
        {:unprocessable_content, _exception, _response} = error,
        _client
      ),
      do: error

  def update_client_from_request({:decoding_error, _exception, _response} = error, _client),
    do: error

  def update_client_from_request({:error, _exception, _response} = error, _client), do: error
end