Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.

### Added

- `Zoi.from_json_schema/1` and `Zoi.to_json_schema/1` now support `patternProperties`
- `Zoi.exclusive_union/2` requires exactly one matching schema
- `Zoi.none/1` rejects every input
- `Zoi.from_json_schema/1` and `Zoi.to_json_schema/1` now support boolean schemas
Expand All @@ -23,6 +24,8 @@ All notable changes to this project will be documented in this file.

### Fixed

- Maps now keep valid additional properties in partial results
- `Zoi.to_json_schema/1` now exports `additionalProperties` schemas instead of `true`
- `Zoi.url/1` now accepts non-HTTP URI schemes
- `Zoi.from_json_schema/1` now accepts quoted email local parts
- `Zoi.float/1` now coerces integer inputs when coercion is enabled
Expand Down
54 changes: 37 additions & 17 deletions lib/zoi/json_schema/decoder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Zoi.JSONSchema.Decoder do
@validation_keys ~w(
minLength maxLength pattern format
minimum maximum exclusiveMinimum exclusiveMaximum multipleOf
properties required additionalProperties minProperties maxProperties propertyNames
properties required additionalProperties minProperties maxProperties propertyNames patternProperties
items prefixItems minItems maxItems uniqueItems contains
)

Expand Down Expand Up @@ -197,15 +197,27 @@ defmodule Zoi.JSONSchema.Decoder do
defp object_schema(schema) do
properties = Map.get(schema, "properties", %{})
required = Map.get(schema, "required", [])
additional = Map.get(schema, "additionalProperties")
unrecognized_keys = unrecognized_keys(additional)

properties
|> Map.new(fn {key, prop_schema} ->
{key, prop_schema |> decode_schema() |> maybe_optional(key, required)}
end)
|> add_required_fields(required, unrecognized_keys)
|> Zoi.map(unrecognized_keys: unrecognized_keys)
fields =
Map.new(properties, fn {key, prop_schema} ->
{key, prop_schema |> decode_schema() |> maybe_optional(key, required)}
end)

patterns =
schema
|> Map.get("patternProperties", %{})
|> Enum.flat_map(fn {pattern, value} ->
case Regex.compile(pattern, "u") do
{:ok, regex} -> [{regex, decode_schema(value)}]
{:error, _} -> []
end
end)

unrecognized_keys = unrecognized_keys(Map.get(schema, "additionalProperties"))

fields
|> add_required_fields(required, unrecognized_keys, patterns)
|> Zoi.Types.Map.new(unrecognized_keys: unrecognized_keys, pattern_properties: patterns)
|> maybe_apply(schema, "minProperties", &Zoi.min/2)
|> maybe_apply(schema, "maxProperties", &Zoi.max/2)
|> maybe_apply(schema, "propertyNames", &apply_property_names/2)
Expand All @@ -232,18 +244,26 @@ defmodule Zoi.JSONSchema.Decoder do
end
end

defp add_required_fields(fields, required, unrecognized_keys) do
schema =
case unrecognized_keys do
{:preserve, {_, schema}} -> Zoi.required(schema)
_ -> Zoi.any()
end

defp add_required_fields(fields, required, unrecognized_keys, patterns) do
Enum.reduce(required, fields, fn key, fields ->
Map.put_new(fields, key, schema)
schema =
case Zoi.Types.Map.pattern_schemas(patterns, key) do
[] -> required_property_schema(unrecognized_keys)
_schemas -> Zoi.any()
end

Map.put_new(fields, key, Zoi.required(schema))
end)
end

defp required_property_schema(unrecognized_keys) do
case unrecognized_keys do
{:preserve, {_, schema}} -> schema
:error -> Zoi.none()
_ -> Zoi.any()
end
end

defp unrecognized_keys(false) do
:error
end
Expand Down
38 changes: 23 additions & 15 deletions lib/zoi/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,7 @@ defmodule Zoi.Schema do
end

defp do_traverse_root(%Zoi.Types.Map{fields: fields} = map, fun) when is_list(fields) do
transformed_fields =
Enum.map(fields, fn {key, type} ->
{key, do_traverse(type, [key], fun)}
end)

map
|> Map.put(:fields, transformed_fields)
|> traverse_key_type([], fun)
traverse_map(map, [], fun)
end

defp do_traverse_root(%Zoi.Types.Struct{fields: fields} = struct, fun) do
Expand Down Expand Up @@ -134,14 +127,8 @@ defmodule Zoi.Schema do
defp do_traverse_root(schema, _fun), do: schema

defp do_traverse(%Zoi.Types.Map{fields: fields} = map, path, fun) when is_list(fields) do
transformed_fields =
Enum.map(fields, fn {key, type} ->
{key, do_traverse(type, path ++ [key], fun)}
end)

map
|> Map.put(:fields, transformed_fields)
|> traverse_key_type(path, fun)
|> traverse_map(path, fun)
|> apply_fun(path, fun)
end

Expand Down Expand Up @@ -252,6 +239,18 @@ defmodule Zoi.Schema do
apply_fun(schema, path, fun)
end

defp traverse_map(map, path, fun) do
fields =
Enum.map(map.fields, fn {key, schema} ->
{key, do_traverse(schema, path ++ [key], fun)}
end)

map
|> Map.put(:fields, fields)
|> traverse_key_type(path, fun)
|> traverse_pattern_properties(path, fun)
end

defp traverse_array(array, path, fun) do
array
|> Map.put(:inner, do_traverse(array.inner, path, fun))
Expand All @@ -266,6 +265,15 @@ defmodule Zoi.Schema do
Map.put(schema, :key_type, do_traverse(schema.key_type, path, fun))
end

defp traverse_pattern_properties(schema, path, fun) do
patterns =
Enum.map(schema.pattern_properties, fn {pattern, child} ->
{pattern, do_traverse(child, path, fun)}
end)

Map.put(schema, :pattern_properties, patterns)
end

# Apply function based on arity
defp apply_fun(schema, _path, fun) when is_function(fun, 1), do: fun.(schema)
defp apply_fun(schema, path, fun) when is_function(fun, 2), do: fun.(schema, path)
Expand Down
6 changes: 4 additions & 2 deletions lib/zoi/types/extend.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ defmodule Zoi.Types.Extend do
Zoi.Types.Map.new(Map.new(fields),
unrecognized_keys: schema1.unrecognized_keys,
coerce: schema1.coerce,
empty_values: schema1.empty_values
empty_values: schema1.empty_values,
pattern_properties: schema1.pattern_properties
)
end

Expand All @@ -25,7 +26,8 @@ defmodule Zoi.Types.Extend do
Zoi.Types.Map.new(Map.new(fields),
unrecognized_keys: schema1.unrecognized_keys,
coerce: schema1.coerce,
empty_values: schema1.empty_values
empty_values: schema1.empty_values,
pattern_properties: schema1.pattern_properties
)
end

Expand Down
85 changes: 66 additions & 19 deletions lib/zoi/types/key_value.ex
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ defmodule Zoi.Types.KeyValue do
normalize_key = normalize_key_fun(coerce?)

input_lookup = Map.new(input_pairs, fn {k, v} -> {normalize_key.(k), v} end)
patterns = Map.get(type, :pattern_properties, [])

unknown_pairs =
reject_known_pairs(unrecognized_keys, input_pairs, schema_fields, normalize_key)
Expand All @@ -83,24 +84,27 @@ defmodule Zoi.Types.KeyValue do
if raw_value in empty_values do
handle_missing_field(field_schema, field_key, parsed, errors)
else
case parse_child_value(field_schema, raw_value, opts, [field_key]) do
{:ok, parsed_value, child_errors} ->
{[{field_key, parsed_value} | parsed], Enum.reverse(child_errors, errors)}

{:error, child_errors, partial_value} ->
parsed =
if is_nil(partial_value) do
parsed
else
[{field_key, partial_value} | parsed]
end

{parsed, Enum.reverse(child_errors, errors)}
end
schemas = Zoi.Types.Map.pattern_schemas(patterns, normalized)

result =
field_schema
|> parse_child_value(raw_value, opts, [field_key])
|> validate_patterns(schemas, raw_value, opts, [field_key])

collect_field(field_key, result, parsed, errors)
end
end
end)

{unknown_pairs, {parsed, collected_errors}} =
parse_pattern_pairs(
unknown_pairs,
patterns,
normalize_key,
opts,
{parsed, collected_errors}
)

collected_errors = Enum.reverse(collected_errors)

{parsed, errors} =
Expand All @@ -111,7 +115,7 @@ defmodule Zoi.Types.KeyValue do
:error ->
errors =
unknown_pairs
|> Enum.map(fn {k, _} -> normalize_key.(k) end)
|> Enum.map(fn {key, _value} -> normalize_key.(key) end)
|> Enum.uniq()
|> Enum.map(&Zoi.Error.unrecognized_key/1)

Expand All @@ -138,6 +142,49 @@ defmodule Zoi.Types.KeyValue do
end
end

defp parse_pattern_pairs(pairs, [], _normalize_key, _opts, acc), do: {pairs, acc}

defp parse_pattern_pairs(pairs, patterns, normalize_key, opts, acc) do
Enum.flat_map_reduce(pairs, acc, fn {key, value} = pair, {parsed, errors} ->
case Zoi.Types.Map.pattern_schemas(patterns, normalize_key.(key)) do
[] ->
{[pair], {parsed, errors}}

schemas ->
result = validate_patterns({:ok, value, []}, schemas, value, opts, [key])
{[], collect_field(key, result, parsed, errors)}
end
end)
end

defp validate_patterns({:ok, _value, _errors} = result, schemas, value, opts, path) do
Enum.find_value(schemas, result, fn schema ->
case parse_child_value(schema, value, opts, path) do
{:ok, _parsed, _errors} -> nil
error -> error
end
end)
end

defp validate_patterns(error, _schemas, _value, _opts, _path), do: error

defp collect_field(key, result, parsed, errors) do
case result do
{:ok, parsed_value, child_errors} ->
{[{key, parsed_value} | parsed], Enum.reverse(child_errors, errors)}

{:error, child_errors, partial_value} ->
parsed =
if is_nil(partial_value) do
parsed
else
[{key, partial_value} | parsed]
end

{parsed, Enum.reverse(child_errors, errors)}
end
end

# Turn a field value into {:ok, value, errs} or {:error, errs, partial}
# Ensures errs is flat and has the path prepended.
defp parse_child_value(field_schema, raw_value, opts, path) do
Expand Down Expand Up @@ -206,14 +253,14 @@ defmodule Zoi.Types.KeyValue do

defp validate_preserve_schema(unknown_pairs, key_schema, value_schema, parsed, errors, opts) do
unknown_map = Map.new(unknown_pairs)
temp_schema = Zoi.map(key_schema, value_schema)
schema = Zoi.Types.Map.new(key_schema, value_schema, [])

case Zoi.Type.parse(temp_schema, unknown_map, opts) do
case Zoi.Type.parse(schema, unknown_map, opts) do
{:ok, validated_map} ->
{Map.to_list(validated_map) ++ parsed, errors}

{:error, new_errors, _partial} ->
{parsed, Zoi.Errors.merge(errors, new_errors)}
{:error, new_errors, partial} ->
{Map.to_list(partial) ++ parsed, Zoi.Errors.merge(errors, new_errors)}
end
end
end
Loading
Loading