lib/app_identity/app.ex

defmodule AppIdentity.App do
  @moduledoc """
  The structure used by the App Identity proof generation and verification
  algorithms. This should be constructed from a provided map or struct, such as
  a static configuration file or a database record.

  The original structure or map is stored in the `source` attribute.
  """

  import AppIdentity.Validation, only: [validate: 2]

  @typedoc """
  An optional configuration value for validation of an `AppIdentity` proof.

  If not provided, the default value when required is `{fuzz: 600}`, specifying
  that the timestamp may not differ from the current time by more than ±600
  seconds (±10 minutes). Depending on the nature of the app being verified and
  the expected network conditions, a shorter time period than 600 seconds is
  recommended.

  The `AppIdentity` version 1 algorithm does not use `config`.
  """
  @type config ::
          nil
          | %{optional(:fuzz) => pos_integer(), optional(atom()) => term}
          | %{optional(String.t()) => term}

  @typedoc """
  A map struct that can be converted into an App struct. If the map uses string
  keys, they are required to match the same definitions.
  """
  @type input ::
          %{
            required(:id) => term,
            required(:secret) => term,
            required(:version) => term,
            optional(:config) => term,
            optional(atom) => term
          }
          | %{required(binary()) => term}

  @typedoc """
  A 0-arity loader function that returns a map or struct that can be converted
  into an App struct.
  """
  @type loader :: (() -> input | t)

  @typedoc """
  A finder function accepting a Proof struct parameter that returns a map or
  struct that can be converted into an App struct.
  """
  @type finder :: (AppIdentity.Proof.t() -> input | t)

  @typedoc """
  A representation of an AppIdentity app used for proof generation and
  verification.

  The `t:input/0` value used in the construction of the App is stored in
  `source`.

  The `verified` flag value indicates whether the app was used in the successful
  verification of a proof.
  """
  @type t :: %__MODULE__{
          id: AppIdentity.id(),
          secret: (() -> AppIdentity.secret()),
          version: AppIdentity.version(),
          config: config(),
          source: nil | term(),
          verified: boolean()
        }

  @derive {Inspect, only: [:config, :id, :verified, :version]}

  @enforce_keys [:id, :secret, :version]
  defstruct [:id, :secret, :version, config: nil, source: nil, verified: false]

  @doc """
  Converts the provided `t:input/0` value into an App struct (`t:t/0`). May be
  provided a `t:loader/0` function that will return an `t:input/0` value for
  validation and conversion.

  If provided an App struct (`t:t/0`), returns the provided app struct without
  validation.

  The result is `{:ok, app}` or `{:error, reason}`.

  ### Examples

      iex> App.new(%{})
      {:error, "id must not be nil"} = App.new(%{id: nil})

      iex> {:ok, app} = App.new(%{id: 1, secret: "secret", version: 1})
      iex> app.source
      %{id: 1, secret: "secret", version: 1}
      iex> app.secret.()
      "secret"

      iex> {:ok, app} = App.new(%{id: 1, secret: "secret", version: 1})
      iex> {:ok, app_copy} = App.new(app)
      iex> app == app_copy
      true
  """
  @spec new(input :: input | loader | t) ::
          {:ok, app :: t} | {:error, reason :: String.t()}
  def new(%__MODULE__{} = app) do
    {:ok, app}
  end

  def new(input) when is_function(input, 0) do
    new(input.())
  end

  def new(input) when is_map(input) do
    with {:ok, id} <- get(input, :id),
         {:ok, secret} <- get(input, :secret),
         {:ok, version} <- get(input, :version),
         {:ok, config} <- get(input, :config) do
      secret =
        if is_function(secret, 0) do
          secret
        else
          fn -> secret end
        end

      {:ok,
       %__MODULE__{
         id: id,
         secret: secret,
         version: version,
         config: config,
         source: input
       }}
    end
  end

  def new(_input) do
    {:error, "app can only be created from a map or struct"}
  end

  @doc false
  @spec new!(input :: input | loader | t) :: t
  def new!(input) do
    case new(input) do
      {:ok, value} -> value
      {:error, reason} -> raise reason
    end
  end

  @doc false
  @spec __to_map(app :: t) :: map
  def __to_map(%__MODULE__{} = app) do
    app
    |> Map.take([:config, :id, :version])
    |> Map.put(:secret, app.secret.())
  end

  defp get(map, key) do
    validate(key, Map.get(map, key) || Map.get(map, to_string(key)))
  end
end