defmodule NExJsonSchema.Schema do
@moduledoc false
alias NExJsonSchema.Schema.Draft4
alias NExJsonSchema.Schema.Root
defmodule UnsupportedSchemaVersionError do
defexception message: "unsupported schema version, only draft 4 is supported"
end
defmodule InvalidSchemaError do
defexception message: "invalid schema"
end
defmodule UndefinedRemoteSchemaResolverError do
defexception message: "trying to resolve a remote schema but no remote schema resolver function is defined"
end
@type resolved :: %{String.t() => NExJsonSchema.json_value() | (Root.t() -> {Root.t(), resolved})}
@current_draft_schema_url "http://json-schema.org/schema"
@draft4_schema_url "http://json-schema.org/draft-04/schema"
@spec resolve(Root.t()) :: Root.t() | no_return
def resolve(root = %Root{}), do: resolve_root(root)
@spec resolve(NExJsonSchema.json()) :: Root.t() | no_return
def resolve(schema = %{}), do: resolve_root(%Root{schema: schema})
@spec get_ref_schema(Root.t(), [:root | String.t()]) :: NExJsonSchema.json()
def get_ref_schema(root = %Root{}, ref = [:root | path]) do
get_ref_schema_with_schema(root.schema, path, ref)
end
def get_ref_schema(root = %Root{}, ref = [url | path]) when is_binary(url) do
get_ref_schema_with_schema(root.refs[url], path, ref)
end
defp resolve_root(root) do
assert_supported_schema_version(Map.get(root.schema, "$schema", @current_draft_schema_url <> "#"))
assert_valid_schema(root.schema)
{root, schema} = resolve_with_root(root, root.schema)
%{root | schema: schema}
end
defp assert_supported_schema_version(version) do
unless supported_schema_version?(version), do: raise(UnsupportedSchemaVersionError)
end
defp assert_valid_schema(schema) do
unless meta?(schema) do
case NExJsonSchema.Validator.validate(resolve(Draft4.schema()), schema) do
{:error, errors} ->
raise InvalidSchemaError,
message: "schema did not pass validation against its meta-schema:#{format_error_messages(errors)}"
_ ->
nil
end
end
end
defp format_error_messages([]), do: ""
defp format_error_messages([{%{description: description}, path} | errors]) do
"\n* #{description} (at #{path})" <> format_error_messages(errors)
end
defp supported_schema_version?(version) do
case version do
@current_draft_schema_url <> _ -> true
@draft4_schema_url <> _ -> true
_ -> false
end
end
defp resolve_with_root(root, schema, scope \\ "")
defp resolve_with_root(root, schema = %{"id" => id}, scope) when is_binary(id),
do: do_resolve(root, schema, scope <> id)
defp resolve_with_root(root, schema = %{}, scope), do: do_resolve(root, schema, scope)
defp resolve_with_root(root, non_schema, _scope), do: {root, non_schema}
defp do_resolve(root, schema, scope) do
{root, schema} =
Enum.reduce(schema, {root, %{}}, fn property, {root, schema} ->
{root, {k, v}} = resolve_property(root, property, scope)
{root, Map.put(schema, k, v)}
end)
{root, schema |> sanitize_properties_attribute |> sanitize_additional_items_attribute}
end
defp resolve_property(root, {key, value}, scope) when is_map(value) do
{root, resolved} = resolve_with_root(root, value, scope)
{root, {key, resolved}}
end
defp resolve_property(root, {key, values}, scope) when is_list(values) do
{root, values} =
Enum.reduce(values, {root, []}, fn value, {root, values} ->
{root, resolved} = resolve_with_root(root, value, scope)
{root, [resolved | values]}
end)
{root, {key, Enum.reverse(values)}}
end
defp resolve_property(root, {"$ref", ref}, scope) do
scoped_ref =
case ref do
"http://" <> _ -> ref
"https://" <> _ -> ref
_else -> String.replace(scope <> ref, "##", "#")
end
{root, path} = resolve_ref(root, scoped_ref)
{root, {"$ref", path}}
end
defp resolve_property(root, tuple, _), do: {root, tuple}
defp resolve_ref(root, "#") do
{root, [root.location]}
end
defp resolve_ref(root, ref) do
[url | fragments] = String.split(ref, "#")
fragment = get_fragment(fragments, ref)
{root, path} = root_and_path_for_url(root, fragment, url)
assert_reference_valid(path, root, ref)
{root, path}
end
defp get_fragment([], _), do: nil
defp get_fragment([""], _), do: nil
defp get_fragment([fragment = "/" <> _], _), do: fragment
defp get_fragment(_, ref), do: raise(InvalidSchemaError, message: "invalid reference #{ref}")
defp root_and_path_for_url(root, fragment, "") do
{root, [root.location | relative_path(fragment)]}
end
defp root_and_path_for_url(root, fragment, url) do
root = resolve_and_cache_remote_schema(root, url)
{root, [url | relative_path(fragment)]}
end
defp relative_path(nil), do: []
defp relative_path(fragment), do: relative_ref_path(fragment)
defp relative_ref_path(ref) do
["" | keys] = unescaped_ref_segments(ref)
Enum.map(keys, fn key ->
case key =~ ~r/^\d+$/ do
true ->
String.to_integer(key)
false ->
key
end
end)
end
defp resolve_and_cache_remote_schema(root, url) do
if root.refs[url], do: root, else: fetch_and_resolve_remote_schema(root, url)
end
defp fetch_and_resolve_remote_schema(root, url)
when url == @current_draft_schema_url or url == @draft4_schema_url do
resolve_remote_schema(root, url, Draft4.schema())
end
defp fetch_and_resolve_remote_schema(root, url) do
resolve_remote_schema(root, url, fetch_remote_schema(url))
end
defp resolve_remote_schema(root, url, remote_schema) do
root = root_with_ref(root, url, remote_schema)
resolved_root = resolve_root(%{root | schema: remote_schema, location: url})
root = %{root | refs: resolved_root.refs}
root_with_ref(root, url, resolved_root.schema)
end
defp root_with_ref(root, url, ref) do
%{root | refs: Map.put(root.refs, url, ref)}
end
defp fetch_remote_schema(url) do
case remote_schema_resolver() do
fun when is_function(fun) -> fun.(url)
{mod, fun_name} -> apply(mod, fun_name, [url])
end
end
defp remote_schema_resolver do
Application.get_env(:nex_json_schema, :remote_schema_resolver) ||
fn _url -> raise UndefinedRemoteSchemaResolverError end
end
defp assert_reference_valid(path, root, _ref) do
get_ref_schema(root, path)
end
defp sanitize_properties_attribute(schema) do
if needs_properties_attribute?(schema), do: Map.put(schema, "properties", %{}), else: schema
end
defp needs_properties_attribute?(schema) do
Enum.any?(~w(patternProperties additionalProperties), &Map.has_key?(schema, &1)) and
not Map.has_key?(schema, "properties")
end
defp sanitize_additional_items_attribute(schema) do
if needs_additional_items_attribute?(schema), do: Map.put(schema, "additionalItems", true), else: schema
end
defp needs_additional_items_attribute?(schema) do
Map.has_key?(schema, "items") and not Map.has_key?(schema, "additionalItems")
end
defp unescaped_ref_segments(ref) do
ref
|> String.split("/")
|> Enum.map(fn segment ->
segment
|> String.replace("~0", "~")
|> String.replace("~1", "/")
|> URI.decode()
end)
end
defp meta?(schema) do
String.starts_with?(Map.get(schema, "id", ""), @draft4_schema_url)
end
defp get_ref_schema_with_schema(nil, _, ref) do
raise InvalidSchemaError, message: "reference #{ref_to_string(ref)} could not be resolved"
end
defp get_ref_schema_with_schema(schema, [], _) do
schema
end
defp get_ref_schema_with_schema(schema, [key | path], ref) when is_binary(key) do
get_ref_schema_with_schema(Map.get(schema, key), path, ref)
end
defp get_ref_schema_with_schema(schema, [idx | path], ref) when is_integer(idx) do
get_ref_schema_with_schema(:lists.nth(idx + 1, schema), path, ref)
catch
:error, :function_clause ->
raise InvalidSchemaError, message: "reference #{ref_to_string(ref)} could not be resolved"
end
defp ref_to_string([:root | path]), do: Enum.join(["$" | path], ".")
defp ref_to_string([url | path]), do: Enum.join([url <> "#" | path], "/")
end