lib/simple_schema.ex

defmodule SimpleSchema do
  @moduledoc """
  #{File.read!("README.md")}
  """

  @type simple_schema :: SimpleSchema.Schema.simple_schema()

  @callback schema(opts :: Keyword.t()) :: simple_schema
  @callback from_json(schema :: simple_schema, json :: any, opts :: Keyword.t()) ::
              {:ok, any} | {:error, any}
  @callback to_json(schema :: simple_schema, value :: any, opts :: Keyword.t()) ::
              {:ok, any} | {:error, any}

  @undefined_default :simple_schema_default_undefined

  defp get_default({_type, opts}) do
    Keyword.get(opts, :default, @undefined_default)
  end

  defp get_default(_type) do
    @undefined_default
  end

  @doc ~S"""
  Generate a struct and implement SimpleSchema behaviour by the specified schema.

  ```
  defmodule MySchema do
    defschema [
      username: {:string, min_length: 4},
      email: {:string, default: "", optional: true, format: :email},
    ]
  end
  ```

  is converted to:

  ```
  defmodule MySchema do
    @enforce_keys [:username]
    defstruct [:username, email: ""]

    @behaviour SimpleSchema

    @simple_schema %{
      username: {:string, min_length: 4},
      email: {:string, default: "", optional: true, format: :email},
    }

    @impl SimpleSchema
    def schema(opts) do
      {@simple_schema, opts}
    end

    @impl SimpleSchema
    def from_json(schema, value, _opts) do
      SimpleSchema.Type.json_to_struct(__MODULE__, schema, value)
    end

    @impl SimpleSchema
    def to_json(schema, value, _opts) do
      SimpleSchema.Type.struct_to_json(__MODULE__, schema, value)
    end
  end
  ```
  """
  defmacro defschema(schema, options \\ []) do
    enforce_keys =
      schema
      |> Enum.filter(fn {_key, value} ->
        get_default(value) == @undefined_default
      end)
      |> Enum.map(fn {key, _} -> key end)

    structs =
      schema
      |> Enum.map(fn {key, value} ->
        default = get_default(value)

        if default == @undefined_default do
          key
        else
          {key, default}
        end
      end)

    simple_schema = schema

    quote do
      @enforce_keys unquote(enforce_keys)
      defstruct unquote(structs)

      @behaviour SimpleSchema

      @simple_schema Enum.into(unquote(simple_schema), %{})

      @impl SimpleSchema
      def schema(opts) do
        {@simple_schema, unquote(options) ++ opts}
      end

      @impl SimpleSchema
      def from_json(schema, value, _opts) do
        SimpleSchema.Type.json_to_struct(__MODULE__, schema, value)
      end

      @impl SimpleSchema
      def to_json(schema, value, _opts) do
        SimpleSchema.Type.struct_to_json(__MODULE__, schema, value)
      end
    end
  end

  @doc """
  Convert JSON value to a simple schema value.

  JSON value is validated before it is converted to a simple schema value.

  If `optimistic: true` is specified in `opts`, JSON value is not validated before it is converted.
  """
  def from_json(schema, json, opts \\ []) do
    optimistic = Keyword.get(opts, :optimistic, false)

    if optimistic do
      SimpleSchema.Schema.from_json(schema, json)
    else
      cache_key = Keyword.get(opts, :cache_key, schema)
      get_json_schema = Keyword.get(opts, :get_json_schema, fn schema, opts -> SimpleSchema.Schema.to_json_schema(schema, opts) end)

      case SimpleSchema.Validator.validate(get_json_schema, schema, opts, json, cache_key) do
        {:error, reason} ->
          {:error, reason}

        :ok ->
          SimpleSchema.Schema.from_json(schema, json)
      end
    end
  end

  def from_json!(schema, json, opts \\ []) do
    case from_json(schema, json, opts) do
      {:ok, value} ->
        value

      {:error, reason} ->
        raise "failed from_json/2: #{inspect(reason)}"
    end
  end

  @doc """
  Convert a simple schema value to JSON value.

  If `optimistic: true` is specified in `opts`, JSON value is not validated after it is converted.
  Otherwise, JSON value is validated after it is converted.
  """
  def to_json(schema, value, opts \\ []) do
    optimistic = Keyword.get(opts, :optimistic, false)

    case SimpleSchema.Schema.to_json(schema, value) do
      {:error, reason} ->
        {:error, reason}

      {:ok, json} ->
        if optimistic do
          {:ok, json}
        else
          cache_key = Keyword.get(opts, :cache_key, schema)
          get_json_schema = Keyword.get(opts, :get_json_schema, fn schema, opts -> SimpleSchema.Schema.to_json_schema(schema, opts) end)

          case SimpleSchema.Validator.validate(get_json_schema, schema, opts, json, cache_key) do
            {:error, reason} ->
              {:error, reason}

            :ok ->
              {:ok, json}
          end
        end
    end
  end

  def to_json!(schema, value, opts \\ []) do
    case to_json(schema, value, opts) do
      {:ok, json} ->
        json

      {:error, reason} ->
        raise "failed to_json/3: #{inspect(reason)}"
    end
  end
end