lib/icon/schema.ex

defmodule Icon.Schema do
  @moduledoc """
  This module defines a schema.

  Schemas serve the purpose of validating both requests and responses. The idea
  is to have a map defining the types and validations for our JSON payloads.

  ## Defining Schemas

  A schema can be either anonymous or not. For non-anonymous schemas, we need to
  `use` this module and define a schema using `defschema/1` e.g. the following is
  an (incomplete) transaction.

  ```elixir
  defmodule Transaction do
    use Icon.Schema

    defschema(%{
      from: {:eoa_address, required: true},
      to: {:address, required: true},
      value: {:loop, default: 0}
    })
  end
  ```

  As seen in the previous example, the values change depending on the types and
  options each key has. The available primitive types are:

  - `:address` (same as `Icon.Schema.Types.Address`).
  - `:any` (does nothing with the data).
  - `:binary_data` (same as `Icon.Schema.Types.BinaryData`).
  - `:boolean` (same as `Icon.Schema.Types.Boolean`).
  - `:eoa_address` (same as `Icon.Schema.Types.EOA`).
  - `:error` (same as `Icon.Schema.Error`).
  - `:event_log` (same as `Icon.Schema.Types.EventLog`).
  - `:hash` (same as `Icon.Schema.Types.Hash`).
  - `:integer` (same as `Icon.Schema.Types.Integer`).
  - `:loop` (same as `Icon.Schema.Types.Loop`).
  - `:pos_integer` (same as `Icon.Schema.Types.PosInteger`).
  - `:score_address` (same as `Icon.Schema.Types.SCORE`).
  - `:signature` (same as `Icon.Schema.Types.Signature`).
  - `:string` (same as `Icon.Schema.Types.String`).
  - `:timestamp` (same as `Icon.Schema.Types.Timestamp`).
  - `enum([atom()])` (same as `{:enum, [atom()]}`).

  Then we have complex types:

  - Anonymous schema: `t()`.
  - A homogeneous list of type `t`: `list(t)`.
  - Any of the types listed in the list: `any([{atom(), t}], atom())`. The first
  atom is the value of the second atom in the params.

  Additionally, we can implement our own primite types and named schemas with
  the `Icon.Schema.Type` behaviour and this behaviour respectively. The
  module name should be used as the actual type.

  The available options are the following:

  - `default` - Default value for the key. It can be a closure for late binding.
  - `required` - Whether the key is required or not.
  - `field` - Name of the key to check to choose the right `any()` type. This
  value should be an `atom()`, so it'll probably come from an `enum()` type.

  > Note: `nil` and `""` are considered empty values. They will be ignored for
  > not mandatory keys and will add errors for mandatory keys.

  ## Variable Keys

  In certain cases, the keys of a map will not be defined in advaced. The
  wildcard key `":$variable"` can be used to catch those variable keys e.g. the
  following schema defines a map of variable keys that map to `:loop` values:

  ```elixir
  %{"$variable": :loop}
  ```

  So, if we get the following:

  ```elixir
  %{
    "0" => "0x2a",
    "1" => "0x54"
  }
  ```

  it will load it as follows:

  ```elixir
  %{
    "0" => 42,
    "1" => 84
  }
  ```

  ## Schema Caching

  When a schema is generated with the function `generate/1`, it is also cached
  as a `:persistent_term` in order to avoid generating the same thing twice.
  This makes the first schema generation slower, but accessing the generated
  schema should be then quite fast.

  ## Schema Struct

  When defining a schema with `use Icon.Schema`, we can use the `apply/2`
  function to put the loaded data into the struct e.g. for the following schema:

  ```elixir
  defmodule Block do
    use Icon.Schema

    defschema(%{
      height: :pos_integer,
      hash: :hash
      transactions: list(Transaction)
    })
  end
  ```

  we can then validate a payload with the following:

  ```elixir
  payload = %{
    "height" => "0x2a",
    "hash" => "c71303ef8543d04b5dc1ba6579132b143087c68db1b2168786408fcbce568238",
    "transactions" => [
      %{
        "from" => "hxbe258ceb872e08851f1f59694dac2558708ece11",
        "to" => "cxb0776ee37f5b45bfaea8cff1d8232fbb6122ec32",
        "value" => "0x2a"
      }
    ]
  }

  %Block{
    height: 42,
    hash: "0xc71303ef8543d04b5dc1ba6579132b143087c68db1b2168786408fcbce568238",
    transactions: [
      %Transaction{
        from: "hxbe258ceb872e08851f1f59694dac2558708ece11",
        to: "cxb0776ee37f5b45bfaea8cff1d8232fbb6122ec32",
        value: 42
      }
    ]
  } =
    Block
    |> Icon.Schema.generate()
    |> Icon.Schema.new(payload)
    |> Icon.Schema.load()
    |> Icon.Schema.apply(into: Block)
  ```
  """
  alias __MODULE__, as: Schema
  alias Icon.Schema.Error
  alias Icon.Schema.{Dumper, Loader}

  @typedoc """
  Schema.
  """
  @type t :: module() | map()

  @typedoc """
  Type.
  """
  @type type ::
          external_type()
          | {external_type(), keyword()}

  @typedoc """
  External types.
  """
  @type external_type ::
          internal_type()
          | {:list, external_type()}
          | {:any, [{atom(), external_type()}], atom()}
          | {:enum, [atom()]}
          | :address
          | :any
          | :binary_data
          | :boolean
          | :eoa_address
          | :error
          | :event_log
          | :hash
          | :integer
          | :loop
          | :pos_integer
          | :score_address
          | :signature
          | :string
          | :timestamp

  @typedoc """
  Internal types.
  """
  @type internal_type ::
          {:list, internal_type()}
          | {:any, [{atom(), internal_type()}], atom()}
          | {:enum, [atom()]}
          | t()
          | Icon.Schema.Error
          | Icon.Schema.Types.Address
          | Icon.Schema.Types.Any
          | Icon.Schema.Types.BinaryData
          | Icon.Schema.Types.Boolean
          | Icon.Schema.Types.EOA
          | Icon.Schema.Types.EventLog
          | Icon.Schema.Types.Hash
          | Icon.Schema.Types.Integer
          | Icon.Schema.Types.Loop
          | Icon.Schema.Types.PosInteger
          | Icon.Schema.Types.SCORE
          | Icon.Schema.Types.Signature
          | Icon.Schema.Types.String
          | Icon.Schema.Types.Timestamp
          | module()

  ##############
  # Schema state

  @doc """
  Schema state.
  """
  defstruct schema: %{},
            params: %{},
            data: %{},
            errors: %{},
            is_valid?: true

  @typedoc """
  Schema state.
  """
  @type state :: %Schema{
          schema: t(),
          params: map(),
          data: map(),
          errors: map(),
          is_valid?: boolean()
        }

  @doc false
  @spec add_data(state(), atom(), any()) :: state()
  def add_data(state, key, value)

  def add_data(%Schema{data: data} = state, key, value) do
    %{state | data: Map.put(data, key, value)}
  end

  @doc false
  @spec add_error(state(), atom(), :is_required | :is_invalid | map()) ::
          state()
  def add_error(state, key, type)

  def add_error(%Schema{errors: errors} = state, key, :is_required) do
    %{state | errors: Map.put(errors, key, "is required"), is_valid?: false}
  end

  def add_error(%Schema{errors: errors} = state, key, :is_invalid) do
    %{state | errors: Map.put(errors, key, "is invalid"), is_valid?: false}
  end

  def add_error(%Schema{errors: errors} = state, key, inner_errors)
      when is_map(inner_errors) do
    %{state | errors: Map.put(errors, key, inner_errors), is_valid?: false}
  end

  @doc false
  @spec get_value(state(), atom()) :: {:found, any()} | :miss
  def get_value(%Schema{data: data}, key) do
    case data[key] do
      nil -> :miss
      value -> {:found, value}
    end
  end

  @doc false
  @spec retrieve(atom(), keyword()) :: (state() -> state())
  def retrieve(key, options)

  def retrieve(:"$variable", _options) do
    fn %Schema{params: params} = state ->
      %{state | data: params}
    end
  end

  def retrieve(key, options) do
    fn %Schema{} = state ->
      required? = options[:required] || false
      nullable? = options[:nullable] || false
      default = options[:default]

      default = if is_function(default, 1), do: default.(state), else: default

      state
      |> get_field(key, default)
      |> case do
        value when value in [nil, ""] and required? ->
          add_error(state, key, :is_required)

        value when value in [nil, ""] and not nullable? ->
          state

        value ->
          add_data(state, key, value)
      end
    end
  end

  @spec get_field(state(), binary() | atom(), any()) :: any()
  defp get_field(state, key, default)

  defp get_field(%Schema{params: params}, key, default)
       when is_map_key(params, key) do
    value = params[key]

    if value in [nil, ""], do: default, else: value
  end

  defp get_field(%Schema{} = state, key, default) when is_atom(key) do
    get_field(state, "#{key}", default)
  end

  defp get_field(%Schema{}, _key, default) do
    default
  end

  ############
  # Public API

  @doc """
  Callback for defining a schema.
  """
  @callback init() :: t()

  @doc """
  Uses the schema behaviour.
  """
  @spec __using__(any()) :: Macro.t()
  defmacro __using__(_) do
    quote do
      @behaviour Schema
      import Schema, only: [list: 1, any: 2, enum: 1, defschema: 1]

      @spec __schema__() :: Schema.t()
      def __schema__ do
        __MODULE__.init()
        |> Schema.generate()
      end
    end
  end

  @doc """
  Generates a schema and its struct.
  """
  @spec defschema(map()) :: Macro.t()
  defmacro defschema(map) do
    quote do
      @behaviour Access

      @keys Map.keys(unquote(map))

      defstruct @keys

      @type t :: %__MODULE__{}

      @impl Schema
      def init do
        unquote(map)
      end

      @impl Access
      defdelegate fetch(v, key), to: Map

      @impl Access
      defdelegate get_and_update(v, key, func), to: Map

      @impl Access
      defdelegate pop(v, key), to: Map
    end
  end

  @doc """
  Generates a new schema state.
  """
  @spec new(t(), map() | keyword() | any()) :: state()
  def new(schema, params)

  def new(%{"$__SCHEMA__": _} = schema, params) do
    %Schema{schema: schema, params: %{"$__SCHEMA__": params}}
  end

  def new(schema, params) when is_map(params) do
    %Schema{schema: schema, params: params}
  end

  def new(schema, params) when is_list(params) do
    new(schema, Map.new(params))
  end

  @doc """
  Loads data from a schema state.
  """
  @spec load(state()) :: state()
  def load(%Schema{schema: schema} = state) do
    schema
    |> Stream.map(fn {key, %{loader: loader}} -> {key, loader} end)
    |> Enum.reduce(state, fn {_, validator}, state ->
      validator.(state)
    end)
  end

  @doc """
  Dumps data from a schema state.
  """
  @spec dump(state()) :: state()
  def dump(%Schema{schema: schema} = state) do
    schema
    |> Stream.map(fn {key, %{dumper: dumper}} -> {key, dumper} end)
    |> Enum.reduce(state, fn {_, validator}, state ->
      validator.(state)
    end)
  end

  @doc """
  Generates a full `type_or_schema`, given a schema definition. It caches the
  generated schema, to avoid regenarating the same every time.
  """
  @spec generate(type() | t()) :: t() | no_return()
  def generate(type_or_schema)

  def generate({:any, _, _}) do
    raise ArgumentError, message: "any/3 cannot be used as primitive schema"
  end

  def generate({:list, _} = list), do: generate(%{"$__SCHEMA__": list})
  def generate({:enum, _} = enum), do: generate(%{"$__SCHEMA__": enum})
  def generate({_, _} = type), do: generate(%{"$__SCHEMA__": type})

  def generate(primitive)
      when primitive in [
             :address,
             :any,
             :binary_data,
             :boolean,
             :eoa_address,
             :error,
             :event_log,
             :hash,
             :integer,
             :loop,
             :pos_integer,
             :score_address,
             :signature,
             :string,
             :timestamp
           ] do
    generate(%{"$__SCHEMA__": primitive})
  end

  def generate(module)
      when module in [
             Icon.Schema.Error,
             Icon.Schema.Types.Address,
             Icon.Schema.Types.Any,
             Icon.Schema.Types.BinaryData,
             Icon.Schema.Types.Boolean,
             Icon.Schema.Types.EOA,
             Icon.Schema.Types.EventLog,
             Icon.Schema.Types.Hash,
             Icon.Schema.Types.Integer,
             Icon.Schema.Types.Loop,
             Icon.Schema.Types.PosInteger,
             Icon.Schema.Types.SCORE,
             Icon.Schema.Types.Signature,
             Icon.Schema.Types.String,
             Icon.Schema.Types.Timestamp
           ] do
    generate(%{"$__SCHEMA__": module})
  end

  def generate(schema) when is_atom(schema) do
    with {:module, module} <- Code.ensure_compiled(schema),
         true <- function_exported?(module, :__schema__, 0),
         true <- function_exported?(module, :init, 0) do
      module.init()
      |> generate()
    else
      _ ->
        raise ArgumentError, message: "Schema #{schema} not found"
    end
  end

  def generate(schema) when is_map(schema) do
    key = {__MODULE__, :erlang.phash2(schema)}

    with :miss <- :persistent_term.get(key, :miss) do
      generated =
        schema
        |> Stream.map(fn {key, type} -> expand(key, type) end)
        |> Map.new()

      :persistent_term.put(key, generated)

      generated
    end
  end

  @doc """
  Applies schema `state`.
  """
  @spec apply(state()) ::
          {:ok, any()}
          | {:error, Error.t()}
  @spec apply(state(), keyword()) ::
          {:ok, any()}
          | {:error, Error.t()}
  def apply(state, options \\ [])

  def apply(%Schema{is_valid?: true, data: %{"$__SCHEMA__": data}}, _options) do
    {:ok, data}
  end

  def apply(%Schema{is_valid?: true, data: data}, options) do
    case options[:into] do
      nil ->
        {:ok, data}

      module ->
        {:ok, into(data, module)}
    end
  end

  def apply(%Schema{is_valid?: false} = state, _options) do
    {:error, Error.new(state)}
  end

  ################
  # Public helpers

  @doc """
  Generates a list of types.
  """
  @spec list(external_type()) :: {:list, external_type()}
  def list(type), do: {:list, type}

  @doc """
  Generates a union of types.
  """
  @spec any([{atom(), external_type()}], atom()) ::
          {:any, [{atom(), external_type()}], atom()}
  def any(types, field), do: {:any, types, field}

  @doc """
  Generates an enum type.
  """
  @spec enum([atom()]) :: {:enum, [atom()]}
  def enum(values), do: {:enum, values}

  ##################
  # Schema expansion

  @spec expand(atom(), type()) ::
          {atom(), %{type: internal_type(), loader: loader, dumper: dumper}}
        when loader: (state() -> state()),
             dumper: (state() -> state())
  defp expand(key, type)

  defp expand(key, {:list, _type} = type), do: expand(key, {type, []})
  defp expand(key, {:enum, _values} = type), do: expand(key, {type, []})
  defp expand(key, {:any, _types, _field} = type), do: expand(key, {type, []})
  defp expand(key, schema) when is_map(schema), do: expand(key, {schema, []})
  defp expand(key, type) when is_atom(type), do: expand(key, {type, []})

  defp expand(key, {type, options}) do
    type = expand_type(key, type)

    expand(key, type, options)
  end

  @spec expand(atom(), internal_type(), keyword()) ::
          {atom(), %{type: internal_type(), loader: loader, dumper: dumper}}
        when loader: (state() -> state()),
             dumper: (state() -> state())
  defp expand(key, type, options) do
    {
      key,
      %{
        type: type,
        loader: Loader.loader(key, type, options),
        dumper: Dumper.dumper(key, type, options)
      }
    }
  end

  @spec expand_type(atom(), external_type()) :: internal_type() | no_return()
  defp expand_type(key, external_type)

  defp expand_type(_key, :address), do: Icon.Schema.Types.Address
  defp expand_type(_key, :any), do: Icon.Schema.Types.Any
  defp expand_type(_key, :binary_data), do: Icon.Schema.Types.BinaryData
  defp expand_type(_key, :boolean), do: Icon.Schema.Types.Boolean
  defp expand_type(_key, :eoa_address), do: Icon.Schema.Types.EOA
  defp expand_type(_key, :error), do: Icon.Schema.Error
  defp expand_type(_key, :event_log), do: Icon.Schema.Types.EventLog
  defp expand_type(_key, :hash), do: Icon.Schema.Types.Hash
  defp expand_type(_key, :integer), do: Icon.Schema.Types.Integer
  defp expand_type(_key, :loop), do: Icon.Schema.Types.Loop
  defp expand_type(_key, :pos_integer), do: Icon.Schema.Types.PosInteger
  defp expand_type(_key, :score_address), do: Icon.Schema.Types.SCORE
  defp expand_type(_key, :signature), do: Icon.Schema.Types.Signature
  defp expand_type(_key, :string), do: Icon.Schema.Types.String
  defp expand_type(_key, :timestamp), do: Icon.Schema.Types.Timestamp

  defp expand_type(key, {:enum, values} = type) when is_list(values) do
    if Enum.all?(values, &is_atom/1) do
      type
    else
      raise ArgumentError, message: "#{key} values need to be atoms"
    end
  end

  defp expand_type(_key, schema) when is_map(schema) do
    generate(schema)
  end

  defp expand_type(key, {:list, type}) do
    case expand_type(key, type) do
      {:any, _, _} ->
        raise ArgumentError, message: "Lists do not support any/3 as type"

      expanded_type ->
        {:list, expanded_type}
    end
  end

  defp expand_type(key, {:any, types, field})
       when is_list(types) and is_atom(field) do
    types =
      Enum.map(types, fn {value, type} ->
        {value, expand_type(key, type)}
      end)

    {:any, types, field}
  end

  defp expand_type(key, module) when is_atom(module) do
    case Code.ensure_compiled(module) do
      {:module, module} ->
        module

      _ ->
        raise ArgumentError,
          message: "#{key}'s type (#{module}) is not a valid schema or type"
    end
  end

  @spec into(map(), any()) :: map() | struct() | no_return()
  defp into(data, value)

  defp into(data, value) when is_atom(value) do
    with data when is_map(data) <- data,
         {:module, module} <- Code.ensure_compiled(value),
         true <- function_exported?(module, :__schema__, 0),
         true <- function_exported?(module, :init, 0),
         true <- function_exported?(module, :__struct__, 1) do
      module.init()
      |> Enum.map(fn
        {key, {:list, type}} when is_atom(type) ->
          {key, Enum.map(data[key] || [], fn value -> into(value, type) end)}

        {key, {{:list, type}, _}} when is_atom(type) ->
          {key, Enum.map(data[key] || [], fn value -> into(value, type) end)}

        {key, type} when is_atom(type) ->
          {key, into(data[key], type)}

        {key, {type, _}} when is_atom(type) ->
          {key, into(data[key], type)}

        {key, _type} ->
          {key, data[key]}
      end)
      |> module.__struct__()
    else
      _ ->
        data
    end
  end
end