lib/castor_edc/client.ex

defmodule CastorEDC.Client do
  @moduledoc ~S"""
  Container for API client configuration values.
  """
  alias __MODULE__

  @supported_options [:adapter, :adapter_options, :global_timeout, :endpoint, :user_agent]

  @default_options endpoint: "https://data.castoredc.com/",
                   user_agent: "elixir-ex_castor_edc/" <> Mix.Project.config()[:version],
                   global_timeout: 5_000,
                   adapter: Tesla.Adapter.Hackney,
                   adapter_options: []

  @type url() :: String.t()
  @type uuid() :: String.t()

  @type credentials :: %{client_id: binary, client_secret: binary} | %{access_token: binary}

  @type t() :: %__MODULE__{
          client_id: nil | uuid(),
          client_secret: nil | String.t(),
          grant_type: String.t(),
          scope: String.t(),
          endpoint: url(),
          access_token: nil | String.t(),
          options: []
        }

  defstruct client_id: nil,
            client_secret: nil,
            grant_type: "client_credentials",
            scope: "default",
            endpoint: nil,
            access_token: nil,
            options: []

  @doc """
  By default the client will use https://data.castoredc.com/ as the endpoint

      client = Client.new(%{client_id: "<client id>", client_secret: "<client secret>"})

  You can pass a different endpoint as an option when your studies are located elsewhere e.g.

      client = Client.new(
        %{client_id: "<client id>", client_secret: "<client secret>"},
        endpoint: "https://us.castoredc.com/"
      )

  Alternatively, when you have a long-lived access token you can pass it in directly

      client = Client.new(%{access_token: "<access token>"})

  Additionally it's possible to pass options e.g increasing the global_timeout.

      client = Client.new(
        %{client_id: "<client id>", client_secret: "<client secret>"},
        global_timeout: 30_000
      )

  ## Options
    * `:endpoint` - An alternative endpoint e.g. https://us.castoredc.com/
    * `:global_timeout` - The global_timeout in milliseconds, by default it is 5000
    * `:adapter` - Any module that implements the `Tesla.Adapter` behavior
    * `:adapter_options` - The supported options for the given adapter
  """
  @spec new(credentials(), keyword()) :: Client.t()
  def new(credentials, opts \\ [])

  def new(%{client_id: client_id, client_secret: client_secret}, opts) do
    validate_options!(opts)
    merge_properties(%{client_id: client_id, client_secret: client_secret}, opts)
  end

  def new(%{access_token: access_token}, opts) do
    validate_options!(opts)
    merge_properties(%{access_token: access_token}, opts)
  end

  defp validate_options!(opts) do
    options =
      @supported_options
      |> Enum.map_join(", ", &inspect/1)

    for {option, _value} <- opts do
      if option not in @supported_options do
        raise ArgumentError, "Unknown option, expected one of #{options}, got: #{inspect(option)}"
      end
    end
  end

  defp merge_properties(credentials, opts) do
    opts = merge_options(opts)
    endpoint = endpoint(opts[:endpoint])

    opts =
      opts
      |> Keyword.drop([:endpoint])

    %__MODULE__{endpoint: endpoint, options: opts}
    |> Map.merge(credentials)
  end

  defp merge_options(opts), do: Keyword.merge(@default_options, opts)

  defp endpoint(endpoint) do
    if String.ends_with?(endpoint, "/") do
      endpoint
    else
      endpoint <> "/"
    end
  end

  @doc false
  def update_token(%Client{} = client, access_token),
    do: %__MODULE__{client | access_token: access_token}
end