lib/json_xema.ex

defmodule JsonXema do
  @moduledoc """
  A [JSON Schema](http://json-schema.org) validator.
  """

  use Xema.Behaviour

  import ConvCase

  alias Jason
  alias JsonXema.{SchemaError, SchemaValidator, ValidationError}
  alias Xema.{Format, Schema}

  @type_map %{
    "any" => :any,
    "array" => :list,
    "boolean" => :boolean,
    "integer" => :integer,
    "number" => :number,
    "object" => :map,
    "string" => :string,
    "null" => nil
  }

  @type_map_reverse for {key, value} <- @type_map, into: %{}, do: {value, key}

  @types Map.keys(@type_map)

  defp json_keywords do
    %Schema{}
    |> Map.delete(:data)
    |> Map.delete(:__struct__)
    |> Map.keys()
    |> Enum.map(&Atom.to_string/1)
    |> Enum.map(&to_camel_case/1)
    |> Enum.concat(["$ref"])
    |> MapSet.new()
  end

  @on_load :load_atoms
  @doc false
  def load_atoms do
    Schema.keywords()
    |> Enum.map(&Atom.to_string/1)
    |> Enum.map(&ConvCase.to_camel_case/1)
    |> Enum.each(&String.to_atom/1)

    Code.ensure_loaded(Format)

    :regex

    :ok
  end

  @doc """
  This function creates a new `JsonXema` from the given `schema`.

  Possible options:
  + `:loader` - a loader for remote schemas. This option will overwrite the
                  loader from the config. See
                  [Configure a loader](loader.html) to how to define a
                  loader.

  ## Examples

      iex> ~s({"type": "string"})
      ...> |> Jason.decode!()
      ...> |> JsonXema.new()
      %JsonXema{refs: %{}, schema: %Xema.Schema{type: :string}}
  """
  @spec new(boolean | map, keyword) :: JsonXema.t()
  def new(schema, opts)

  @doc false
  @spec init(boolean | map, keyword) :: Schema.t()
  def init(bool, _) when is_boolean(bool), do: schema(bool)

  def init(map, _) when is_map(map) do
    case validate(map) do
      {:ok, data} ->
        try do
          data
          |> Map.put_new("type", "any")
          |> schema()
        rescue
          error -> reraise SchemaError, error, __STACKTRACE__
        end

      {:error, reason} ->
        raise SchemaError, reason
    end
  end

  defp validate(map) do
    case Map.get(map, "$schema") do
      nil ->
        {:ok, map}

      schema ->
        with :ok <- SchemaValidator.validate(schema, map) do
          {:ok, map}
        end
    end
  end

  # Maps keywords from snake case to camel case.
  @doc false
  @spec on_error(map) :: map
  def on_error(error), do: ValidationError.exception(reason: map_error(error))

  @doc """
  Converts `%JsonXema{}` to `%Xema{}`.
  """
  @spec to_xema(JsonXema.t()) :: Xema.t()
  def to_xema(%JsonXema{} = json_xema) do
    struct!(
      Xema,
      schema: json_xema.schema,
      refs: json_xema.refs
    )
  end

  defp schema(bool) when is_boolean(bool), do: Schema.new(type: bool)

  # Creates a schema for a reference.
  defp schema(%{"$ref" => pointer} = map) do
    map |> Map.delete("$ref") |> Map.put("ref", pointer) |> schema()
  end

  defp schema(map) when is_map(map) do
    map
    |> map_keys(&update_meta/1)
    |> update_data()
    |> map_keys(&update_key/1)
    |> map_keys(&key_to_existing_atom/1)
    |> update_type()
    |> update()
    |> Map.to_list()
    |> Schema.new()
  end

  defp schema(list) when is_list(list), do: Schema.new(type: update_type(list))

  defp update_meta("$" <> key), do: key

  defp update_meta(key), do: key

  defp update_type(map) when is_map(map), do: Map.update(map, :type, :any, &update_type/1)

  defp update_type(type) when is_list(type), do: Enum.map(type, &get_type/1)

  defp update_type(type), do: get_type(type)

  defp get_type("null"), do: nil

  defp get_type(type) when type in @types, do: Map.get(@type_map, type)

  defp get_type(type), do: raise(ArgumentError, message: "unknown type #{inspect(type)}")

  defp update_key(key) when is_atom(key), do: key

  defp update_key(key) when is_binary(key), do: to_snake_case(key)

  defp key_to_existing_atom(string) when is_binary(string),
    do: String.to_existing_atom(string)

  defp key_to_existing_atom(key), do: key

  defp to_existing_atom(string) do
    String.to_existing_atom(string)
  rescue
    _ -> string
  end

  defp update(map),
    do:
      map
      |> Map.update(:additional_items, nil, &bool_or_schema/1)
      |> Map.update(:additional_properties, nil, &bool_or_schema/1)
      |> Map.update(:contains, nil, &schema/1)
      |> Map.update(:all_of, nil, &schemas/1)
      |> Map.update(:any_of, nil, &schemas/1)
      |> Map.update(:definitions, nil, &schemas/1)
      |> Map.update(:dependencies, nil, &dependencies/1)
      |> Map.update(:else, nil, &schema/1)
      |> Map.update(:format, nil, &to_format_attribute/1)
      |> Map.update(:if, nil, &schema/1)
      |> Map.update(:items, nil, &items/1)
      |> Map.update(:not, nil, &schema/1)
      |> Map.update(:one_of, nil, &schemas/1)
      |> Map.update(:pattern_properties, nil, &schemas/1)
      |> Map.update(:properties, nil, &schemas/1)
      |> Map.update(:property_names, nil, &schema/1)
      |> Map.update(:required, nil, &MapSet.new/1)
      |> Map.update(:then, nil, &schema/1)

  defp update_data(keywords) do
    {data, keywords} = do_update_data(keywords)

    case data do
      data when map_size(data) == 0 ->
        Map.put(keywords, :data, nil)

      data ->
        Map.put(keywords, :data, data)
    end
  end

  defp do_update_data(keywords),
    do:
      keywords
      |> diff_keywords()
      |> Enum.reduce({%{}, keywords}, fn key, {data, keywords} ->
        {value, keywords} = Map.pop(keywords, key)
        {Map.put(data, key, maybe_schema(value)), keywords}
      end)

  defp maybe_schema(map) when is_map(map) do
    case has_keyword?(map) do
      true -> schema(map)
      false -> map
    end
  end

  defp maybe_schema(value), do: value

  defp diff_keywords(map),
    do:
      map
      |> Map.keys()
      |> MapSet.new()
      |> MapSet.difference(json_keywords())
      |> MapSet.to_list()

  defp has_keyword?(map),
    do:
      map
      |> Map.keys()
      |> MapSet.new()
      |> MapSet.disjoint?(json_keywords())
      |> Kernel.not()

  defp items(value)
       when is_map(value) or is_boolean(value),
       do: schema(value)

  defp items(list)
       when is_list(list),
       do: schemas(list)

  defp bool_or_schema(map)
       when is_map(map),
       do: schema(map)

  defp bool_or_schema(bool)
       when is_boolean(bool),
       do: bool

  defp schemas(map)
       when is_map(map),
       do:
         map
         |> map_values(&schema/1)
         |> Enum.into(%{})

  defp schemas(list)
       when is_list(list),
       do: Enum.map(list, &schema/1)

  @spec dependencies(map) :: map
  defp dependencies(map),
    do:
      Enum.into(map, %{}, fn
        {key, dep} when is_list(dep) -> {key, dep}
        {key, dep} when is_binary(dep) -> {key, [dep]}
        {key, dep} -> {key, schema(dep)}
      end)

  @spec to_format_attribute(String.t()) :: Format.format()
  defp to_format_attribute(str),
    do:
      str
      |> String.replace("-", "_")
      |> to_existing_atom()

  @spec map_error(any) :: any
  defp map_error(:mixed_map), do: :mixed_map

  defp map_error(%{__struct__: _} = struct), do: struct

  defp map_error(error) when is_map(error) do
    for {key, value} <- error,
        into: %{},
        do: map_error(key, value)
  end

  defp map_error(error) when is_list(error),
    do: Enum.map(error, &map_error/1)

  defp map_error(error) when is_tuple(error),
    do:
      error
      |> Tuple.to_list()
      |> map_error()
      |> List.to_tuple()

  defp map_error(error), do: ConvCase.to_camel_case(error)

  @spec map_error(any, any) :: any
  defp map_error(:properties, value),
    do: {:properties, map_values(value, &map_error/1)}

  defp map_error(:required, value),
    do: {:required, value}

  defp map_error(:enum, value),
    do: {:enum, value}

  defp map_error(:format, value),
    do: {:format, value |> to_string() |> ConvCase.to_kebab_case()}

  defp map_error(:type, value)
       when is_boolean(value),
       do: {:type, value}

  defp map_error(:type, value)
       when is_list(value),
       do:
         {:type,
          value
          |> Enum.map(fn type ->
            @type_map_reverse
            |> Map.get(type)
          end)}

  defp map_error(:type, value),
    do: {:type, Map.get(@type_map_reverse, value)}

  defp map_error(:value, value), do: {:value, value}

  defp map_error(key, value),
    do: {ConvCase.to_camel_case(key), map_error(value)}

  @spec map_keys(map, function) :: map
  defp map_keys(map, fun)
       when is_map(map),
       do: for({k, v} <- map, into: %{}, do: {fun.(k), v})

  @spec map_values(map | struct, function) :: map
  defp map_values(map, fun)
       when is_map(map),
       do: for({k, v} <- map, into: %{}, do: {k, fun.(v)})
end