lib/stripe/entity.ex

defmodule Stripe.Entity do
  @moduledoc """
  A behaviour implemented by modules which represent Stripe objects.

  Intended for internal use within the library.

  A Stripe Entity is just a struct, optionally containing some logic for
  transforming a raw result from the Stripe API into a final struct. This is
  achieved through the use of the `from_json/2` macro.

  The list of objects which are recognised by the library upon receipt are
  currently static and contained in `Stripe.Converter`.

  When a map containing the `"object"` key is received from the API (even when
  nested inside another map), and the value of that field (for example,
  `"foo_widget"`) is in the list of supported objects, the converter will
  expect `Stripe.FooWidget` to be present and to implement this behaviour.

  To implement this behaviour, simply add `use Stripe.Entity` to the top of
  the entity module and make sure it defines a struct. This will also enable
  the use of the `from_json/2` macro, which allows for changes to the data
  received from Stripe before it is converted to a struct.
  """

  @doc false
  # Not to be directly implemented, use the `from_json/2` macro instead
  @callback __from_json__(data :: map) :: map

  @doc false
  defmacro __using__(_opts) do
    quote do
      require Stripe.Entity
      import Stripe.Entity, only: [from_json: 2]
      @behaviour Stripe.Entity
      def __from_json__(data), do: data
      defoverridable __from_json__: 1
    end
  end

  @doc """
  Specifies logic that transforms data from Stripe to our Stripe object.

  The Stripe API docs specify that:

  > JSON is returned by all API responses, including errors, although our API
  > libraries convert responses to appropriate language-specific objects.

  To this end, sometimes it is desirable to make changes to the raw data
  received from the Stripe API, to aid its conversion into an appropriate
  Elixir data struct.

  One example is the convention of converting `"enum"` values (for example
  `"status"` values of `"succeeded"` or `"failed"`) into atoms instead of
  keeping them as strings.

  This macro is used in modules implementing the `Stripe.Entity` behaviour in
  order to specify this extra logic.

  Its use is optional, and the default is no transformation; i.e. the received
  JSON keys are merely converted to atoms and cast to the struct defined by
  the module.

  The macro is used like this:

  ```
  from_json data do
    data
    |> cast_to_atom([:type, :status])
    |> cast_each(:fee_details, &cast_to_atom(&1, :type))
  end
  ```

  It takes a parameter name to which the data received from Stripe is bound,
  and a `do` block which should return the transformed data. The
  transformation receives the JSON response from Stripe, with all keys
  converted to atoms (apart from keys inside a metadata map, which remain
  binaries) and should return a map which is ready to be cast to the struct
  the module defines.

  The helper `cast_*` functions defined in this module are automatically
  imported into the scope of this macro.

  The helper functions are all `nil`/missing key-safe, meaning that they will
  not magically add fields or error on fields which are missing or unset. You
  should therefore write your transformation assuming all possible data is
  actually present.
  """
  defmacro from_json(param, do: block) do
    quote do
      def __from_json__(unquote(param)) do
        import Stripe.Entity, except: [from_json: 2]
        unquote(block)
      end
    end
  end

  @doc """
  Cast the value of the given key or keys to an atom.

  Provide either a single atom key or a list of atom keys whose values should
  be converted from binaries to atoms. Used commonly to convert `"enum"` values
  (values which belong to a predefined set) in Stripe responses, for example a
  `:status` field.

  If a key is not set or the value is `nil`, no transformation occurs.
  """
  @spec cast_to_atom(map, atom | [atom]) :: map
  def cast_to_atom(%{} = data, keys) when is_list(keys) do
    Enum.reduce(keys, data, fn key, data -> cast_to_atom(data, key) end)
  end

  def cast_to_atom(%{} = data, key) do
    key = List.wrap(key)
    maybe_update_in(data, key, maybe(&String.to_atom/1))
  end

  @doc """
  Applies the given function over a list present in the data.

  Provide either a single atom key or a list of atom keys whose values are
  lists. Each element of such a list will be mapped using the function passed.

  For example, if there is a field `:fee_details` which is a list of maps,
  each containing a `:type` key whose value we want to cast to an atom, then
  we write:

  ```
  data
  |> cast_each(:fee_details, &cast_to_atom(&1, :type))
  ```

  If a key is not set or the value is `nil`, no transformation occurs.
  """
  @spec cast_each(map, atom | [atom], (any -> any)) :: map
  def cast_each(%{} = data, keys, fun) when is_list(keys) and is_function(fun) do
    Enum.reduce(keys, data, fn key, data -> cast_each(data, key, fun) end)
  end

  def cast_each(%{} = data, key, fun) when is_function(fun) do
    key = List.wrap(key)
    maybe_update_in(data, key, maybe(&Enum.map(&1, fun)))
  end

  @doc """
  Applies the given function to a field accessed via the path provided.

  Provide a path (identical to that used by the `Access` protocol) to the
  field to be modified, and a function to be applied to its value. For
  example, if the field `:fraud_details` contains a map whose keys are
  `:user_report` and `:stripe_report` we wish to convert to atoms, then we
  write:

  ```
  data
  |> cast_path([:fraud_details], &cast_to_atom(&1, [:user_report, :stripe_report]))
  ```

  Unlike `Kernel.update_in/2`, if the path to the key does not exist, or if
  the value is `nil`, then no transformation occurs.
  """
  @spec cast_path(map, [atom], (any -> any)) :: map
  def cast_path(%{} = data, path, fun) when is_function(fun) do
    maybe_update_in(data, path, fun)
  end

  defp maybe(fun) do
    fn
      nil -> nil
      arg -> fun.(arg)
    end
  end

  defp maybe_update_in(data, path, fun) do
    case get_in(data, path) do
      nil -> data
      val -> put_in(data, path, fun.(val))
    end
  end
end