defmodule Xema.JsonSchema do @moduledoc """ Converts a JSON Schema to Xema source. """ alias Xema.{ JsonSchema.Validator, Schema, SchemaError } @type json_schema :: true | false | map @type opts :: [draft: String.t()] @drafts ~w(draft4 draft6 draft7) @schema ~w( additional_items additional_properties property_names not if then else contains items )a @schemas ~w( all_of any_of one_of items )a @schema_map ~w( definitions pattern_properties properties )a @keywords Schema.keywords() |> Enum.map(&to_string/1) |> ConvCase.to_camel_case() |> List.delete("ref") |> List.delete("schema") |> Enum.concat(["$ref", "$id", "$schema"]) @doc """ This function converts a JSON Schema in Xema schema source. The argument `json_schema` is expected as a decoded JSON Schema. All keys that are not standard JSON Schema keywords have to be known atoms. If the schema has additional keys that are unknown atoms the option `atom: :force` is needed. In this case the atoms will be created. This is not needed for keys expected by JSON Schema (e.g. in properties) Options: * `:draft` specifies the draft to check the given JSON Schema. Possible values are `"draft4"`, `"draft6"`, and `"draft7"`, default is `"draft7"`. If `:draft` not set and the schema contains `$schema` then the value for `$schema` is used for this option. * `:atom` creates atoms for unknown atoms when set to `:force`. This is just needed for additional JSON Schema keywords. ## Examples iex> Xema.JsonSchema.to_xema(%{"type" => "integer", "minimum" => 5}) {:integer, [minimum: 5]} iex> schema = %{ ...> "type" => "object", ...> "properties" => %{"foo" => %{"type" => "integer"}} ...> } iex> Xema.JsonSchema.to_xema(schema) {:map, [keys: :strings, properties: %{"foo" => :integer}]} iex> Xema.JsonSchema.to_xema(%{"type" => "integer", "foo" => "bar"}, atom: :force) {:integer, [foo: "bar"]} """ @spec to_xema(json_schema, opts) :: atom | tuple def to_xema(json_schema, opts \\ []) do draft = draft(json_schema, opts) case Validator.validate(draft, json_schema) do :ok -> do_to_xema(json_schema, opts) {:error, :unknown} -> raise "unknown draft #{inspect(draft)}, has to be one of #{inspect(@drafts)}" {:error, reason} -> raise SchemaError, reason end end defp do_to_xema(json, opts) when is_map(json) do {type, json} = type(json) cond do Enum.empty?(json) -> type type == :map -> {:map, json |> Map.put("keys", :strings) |> schema(opts)} true -> {type, schema(json, opts)} end end defp do_to_xema(json, _) when is_boolean(json), do: json defp type(map) do {type, map} = Map.pop(map, "type", :any) {type_to_atom(type), map} end defp draft(json_schema, opts) when is_map(json_schema) do draft = Map.get(json_schema, "$schema", "draft7") Keyword.get(opts, :draft, draft) end defp draft(_json_schema, opts), do: Keyword.get(opts, :draft, "draft7") defp type_to_atom(list) when is_list(list), do: Enum.map(list, &type_to_atom/1) defp type_to_atom("object"), do: :map defp type_to_atom("array"), do: :list defp type_to_atom("null"), do: nil defp type_to_atom(type) when is_binary(type), do: to_existing_atom(type) defp type_to_atom(type), do: type defp schema(json, opts) do json |> Enum.map(&rule(&1, opts)) |> Keyword.new() end # handles all rules with a regular keyword defp rule({key, value}, opts) when key in @keywords do key |> String.trim_leading("$") |> ConvCase.to_snake_case() |> to_existing_atom(opts) |> rule(value, opts) end # handles all rules without a regular keyword defp rule({key, value}, opts) when is_binary(key) and is_map(value) do value = case schema?(value) do true -> do_to_xema(value, opts) false -> schema(value, opts) end {to_existing_atom(key, opts), value} end defp rule({key, value}, opts), do: {to_existing_atom(key, opts), value} defp rule(:format, value, _) do format = value |> ConvCase.to_snake_case() |> to_existing_atom(maybe: true) case is_atom(format) do true -> {:format, format} false -> {:format, :unsupported} end end defp rule(:dependencies, value, opts) do value = Enum.into(value, %{}, fn {key, value} when is_map(value) -> {key, do_to_xema(value, opts)} {key, value} -> {key, value} end) {:dependencies, value} end defp rule(key, value, opts) when key in @schema_map do {key, Enum.into(value, %{}, fn {key, value} -> {key, do_to_xema(value, opts)} end)} end defp rule(key, value, opts) when key in @schemas and is_list(value) do {key, Enum.map(value, &do_to_xema(&1, opts))} end defp rule(key, value, opts) when key in @schema do {key, do_to_xema(value, opts)} end defp rule(key, value, _), do: {key, value} defp schema?(value) do value |> Map.keys() |> Enum.any?(fn key -> Enum.member?(@keywords, key) end) end defp to_existing_atom(str, opts \\ []) do case Keyword.get(opts, :atom, :force) do :existing -> String.to_existing_atom(str) :force -> String.to_atom(str) end rescue _ -> case Keyword.get(opts, :maybe, false) do true -> str false -> reraise SchemaError, "All additional schema keys must be existing atoms. Missing atom for #{str}", __STACKTRACE__ end end end