defmodule NExJsonSchema.Validator do @moduledoc false alias NExJsonSchema.Schema alias NExJsonSchema.Schema.Root alias NExJsonSchema.Validator.Dependencies alias NExJsonSchema.Validator.Format alias NExJsonSchema.Validator.Items alias NExJsonSchema.Validator.Properties alias NExJsonSchema.Validator.Type @type errors :: [{String.t(), String.t()}] | [] @type errors_with_list_paths :: [{String.t(), [String.t() | integer]}] | [] @spec validate(Root.t(), NExJsonSchema.data()) :: :ok | {:error, errors} def validate(root = %Root{}, data) do errors = root |> validate(root.schema, data, ["$"]) |> errors_with_string_paths case Enum.empty?(errors) do true -> :ok false -> {:error, errors} end end @spec validate(NExJsonSchema.json(), NExJsonSchema.data()) :: :ok | {:error, errors} def validate(schema = %{}, data) do validate(Schema.resolve(schema), data) end @spec validate(Root.t(), Schema.resolved(), NExJsonSchema.data(), [String.t() | integer]) :: errors_with_list_paths def validate(root, schema, data, path \\ []) do schema |> Enum.flat_map(&validate_aspect(root, schema, &1, data)) |> Enum.map(fn {%{} = rules, p} -> {rules, path ++ p} end) end @spec valid?(Root.t(), NExJsonSchema.data()) :: boolean def valid?(root = %Root{}, data), do: valid?(root, root.schema, data) @spec valid?(NExJsonSchema.json(), NExJsonSchema.data()) :: boolean def valid?(schema = %{}, data), do: valid?(Schema.resolve(schema), data) @spec valid?(Root.t(), Schema.resolved(), NExJsonSchema.data()) :: boolean def valid?(root, schema, data), do: root |> validate(schema, data) |> Enum.empty?() defp errors_with_string_paths(errors) do Enum.map(errors, fn {msg, path} -> {msg, Enum.join(path, ".")} end) end def format_error(rule, raw_description, params \\ []) do %{ raw_description: raw_description, description: format_description(raw_description, params), rule: rule, params: Map.new(params) } end defp validate_aspect(root, _, {"$ref", path}, data) do schema = Schema.get_ref_schema(root, path) validate(root, schema, data) end defp validate_aspect(root, _, {"allOf", all_of}, data) do invalid_indexes = validation_result_indexes(root, all_of, data, &(!elem(&1, 0))) case Enum.empty?(invalid_indexes) do true -> [] false -> [ {format_error( :schemata, "expected all of the schemata to match, but the schemata at the following indexes did not: %{indexes}", indexes: invalid_indexes ), []} ] end end defp validate_aspect(root, _, {"anyOf", any_of}, data) do case Enum.any?(any_of, &valid?(root, &1, data)) do true -> [] false -> [{format_error(:schemata, "expected any of the schemata to match but none did"), []}] end end defp validate_aspect(root, _, {"oneOf", one_of}, data) do valid_indexes = validation_result_indexes(root, one_of, data, &elem(&1, 0)) case Enum.empty?(valid_indexes) do true -> [{format_error(:schemata, "expected exactly one of the schemata to match, but none of them did"), []}] false -> if Enum.count(valid_indexes) == 1 do [] else [ {format_error( :schemata, "expected exactly one of the schemata to match, but the schemata at the following indexes did: %{indexes}", indexes: valid_indexes ), []} ] end end end defp validate_aspect(root, _, {"not", not_schema}, data) do case valid?(root, not_schema, data) do true -> [ {format_error( :schema, "expected schema not to match but it did" ), []} ] false -> [] end end defp validate_aspect(_, _, {"type", type}, data) do Type.validate(type, data) end defp validate_aspect(root, schema, {"properties", _}, data = %{}) do Properties.validate(root, schema, data) end defp validate_aspect(_, _, {"minProperties", min_properties}, data) when is_map(data) do case map_size(data) >= min_properties do true -> [] false -> [ {format_error( :length, "expected a minimum of %{min} properties but got %{actual}", min: min_properties, actual: map_size(data) ), []} ] end end defp validate_aspect(_, _, {"maxProperties", max_properties}, data) when is_map(data) do case map_size(data) <= max_properties do true -> [] false -> [ {format_error( :length, "expected a maximum of %{max} properties but got %{actual}", max: max_properties, actual: map_size(data) ), []} ] end end defp validate_aspect(_, _, {"required", required}, data = %{}) do Enum.flat_map(List.wrap(required), fn property -> case Map.has_key?(data, property) do true -> [] false -> [ {format_error( :required, "required property %{property} was not present", property: property ), [property]} ] end end) end defp validate_aspect(root, _, {"dependencies", dependencies}, data) do Dependencies.validate(root, dependencies, data) end defp validate_aspect(root, schema, {"items", _}, items) do Items.validate(root, schema, items) end defp validate_aspect(_, _, {"minItems", min_items}, items) when is_list(items) do case (count = Enum.count(items)) >= min_items do true -> [] false -> [ {format_error( :length, "expected a minimum of %{min} items but got %{actual}", min: min_items, actual: count ), []} ] end end defp validate_aspect(_, _, {"maxItems", max_items}, items) when is_list(items) do case (count = Enum.count(items)) <= max_items do true -> [] false -> [ {format_error( :length, "expected a maximum of %{max} items but got %{actual}", max: max_items, actual: count ), []} ] end end defp validate_aspect(_, _, {"uniqueItems", true}, items) when is_list(items) do case Enum.uniq(items) == items do true -> [] false -> [ {format_error( :unique, "expected items to be unique but they were not" ), []} ] end end defp validate_aspect(_, _, {"enum", enum}, data) do case Enum.any?(enum, &(&1 === data)) do true -> [] false -> [ {format_error( :inclusion, "value is not allowed in enum", values: enum ), []} ] end end defp validate_aspect(_, schema, {"minimum", minimum}, data) when is_number(data) do exclusive? = schema["exclusiveMinimum"] fun = if exclusive?, do: &Kernel.>/2, else: &Kernel.>=/2 case fun.(data, minimum) do true -> [] false -> [ {format_error( :number, "expected the value to be #{if exclusive?, do: "> %{greater_than}", else: ">= %{greater_than_or_equal_to}"}", get_number_validation_params(:minimum, minimum, exclusive?) ), []} ] end end defp validate_aspect(_, schema, {"maximum", maximum}, data) when is_number(data) do exclusive? = schema["exclusiveMaximum"] fun = if exclusive?, do: &Kernel. [] false -> [ {format_error( :number, "expected the value to be #{if exclusive?, do: "< %{less_than}", else: "<= %{less_than_or_equal_to}"}", get_number_validation_params(:maximum, maximum, exclusive?) ), []} ] end end defp validate_aspect(_, _, {"multipleOf", multiple_of}, data) when is_number(data) do zero = Decimal.new(0) dec_multiple_of = dec(multiple_of) dec_data = dec(data) cond do dec_multiple_of == zero -> [multiple_of_error(multiple_of, data)] dec_data == zero -> [] Decimal.integer?(Decimal.div(dec_data, dec_multiple_of)) -> [] true -> [multiple_of_error(multiple_of, data)] end end defp validate_aspect(_, _, {"minLength", min_length}, data) when is_binary(data) do case (length = String.length(data)) >= min_length do true -> [] false -> [ {format_error( :length, "expected value to have a minimum length of %{min} but was %{actual}", min: min_length, actual: length ), []} ] end end defp validate_aspect(_, _, {"maxLength", max_length}, data) when is_binary(data) do case (length = String.length(data)) <= max_length do true -> [] false -> [ {format_error( :length, "expected value to have a maximum length of %{max} but was %{actual}", max: max_length, actual: length ), []} ] end end defp validate_aspect(_, _, {"pattern", pattern}, data) when is_binary(data) do case pattern |> Regex.compile!("u") |> Regex.match?(data) do true -> [] false -> [ {format_error( :format, "string does not match pattern \"%{pattern}\"", pattern: pattern ), []} ] end end defp validate_aspect(_, _, {"format", format}, data) do Format.validate(format, data) end defp validate_aspect(_, _, _, _), do: [] defp dec(number) do if is_float(number) do Decimal.from_float(number) else Decimal.new(number) end end defp multiple_of_error(multiple_of, data) do {format_error( :number, "expected value to be a multiple of %{multiple_of} but got %{actual}", multiple_of: multiple_of, actual: data ), []} end defp format_description(raw_description, params) do Enum.reduce(params, raw_description, fn {key, value}, acc -> template = "%{#{key}}" case String.contains?(acc, template) do true -> String.replace(acc, template, format_parameter(value)) false -> acc end end) end defp format_parameter(value) when is_list(value), do: Enum.join(value, ", ") defp format_parameter(value), do: to_string(value) defp get_number_validation_params(:maximum, value, true), do: [less_than: value] defp get_number_validation_params(:maximum, value, _), do: [less_than_or_equal_to: value] defp get_number_validation_params(:minimum, value, true), do: [greater_than: value] defp get_number_validation_params(:minimum, value, _), do: [greater_than_or_equal_to: value] defp validation_result_indexes(root, schemata, data, filter) do schemata |> Enum.map(&valid?(root, &1, data)) |> Enum.with_index() |> Enum.filter(filter) |> values() end defp values(map) when is_map(map), do: Map.values(map) defp values(list) when is_list(list), do: Keyword.values(list) end