Current section

Files

Jump to
jsv lib jsv struct_support.ex
Raw

lib/jsv/struct_support.ex

defmodule JSV.StructSupport do
alias JSV.Schema
@moduledoc false
@doc """
Validates the given raw schema can be used to define a module struct or raises
an exception.
It will check the following:
* Schema defines a type `object`.
* Schema has `properties`.
* `properties` is a map with atom keys.
* `required`, if present, contains only atoms
"""
@spec validate!(JSV.native_schema()) :: :ok
def validate!(schema) do
:ok = validate_object_type!(schema)
:ok = validate_properties_presence!(schema)
properties_keys = get_validate_properties_keys!(schema)
:ok = validate_required_keys!(schema, properties_keys)
:ok
end
defp validate_object_type!(schema) do
t =
case schema do
%{"type" => t} -> t
%Schema{type: nil} -> raise ArgumentError, errmsg("must define the :object type")
%{type: t} -> t
_ -> raise ArgumentError, errmsg("must define the :object type")
end
case t do
:object -> :ok
"object" -> :ok
other -> raise ArgumentError, errmsg("must define the :object type, got: #{inspect(other)}")
end
end
defp validate_properties_presence!(schema) do
case schema do
%{"properties" => properties} when is_map(properties) ->
:ok
%{properties: properties} when is_map(properties) ->
:ok
%{"properties" => other} ->
raise ArgumentError, errmsg("must define properties as a map, got: #{inspect(other)}")
%{properties: other} ->
raise ArgumentError, errmsg("must define properties as a map, got: #{inspect(other)}")
_ ->
raise ArgumentError, errmsg("must include a properties key")
end
end
defp get_validate_properties_keys!(schema) do
properties =
case schema do
%{"properties" => properties} -> properties
%{properties: properties} -> properties
end
keys = Enum.map(properties, fn {k, _} -> k end)
if Enum.all?(keys, &is_atom/1) do
keys
else
raise ArgumentError, errmsg("properties must be defined with atom keys")
end
end
defp validate_required_keys!(schema, properties_keys) do
required =
case schema do
%Schema{required: nil} -> []
%{required: required} -> required
%{"required" => required} -> required
_ -> []
end
if not is_list(required) do
raise ArgumentError, errmsg("required must be a list")
end
if not Enum.all?(required, &is_atom/1) do
raise ArgumentError, errmsg("must list atom keys in :required, got: #{inspect(required)}")
end
case Enum.uniq(required) -- properties_keys do
[] ->
:ok
rest ->
raise ArgumentError, errmsg("must use known keys only in :required, unknown keys: #{inspect(rest)}")
end
:ok
end
@doc """
Returns a list of `{binary_key, atom_key}` tuples for the given schema. The
list is sorted by keys.
The schema must be valid against `validate!1/`.
This function accepts a second argument, which must be a module that
implements a struct (with `defstruct/1`). When given, the function will
validate that all schema keys exist in the given struct.
"""
@spec keycast_pairs(JSV.native_schema(), target :: nil | module) :: [{binary, atom}]
def keycast_pairs(schema, target \\ nil)
def keycast_pairs(schema, nil) do
schema
|> props!()
|> Enum.map(fn {k, _} when is_atom(k) -> {Atom.to_string(k), k} end)
|> Enum.sort()
end
def keycast_pairs(schema, target) do
pairs = keycast_pairs(schema, nil)
struct_keys = struct_keys(target)
extra_keys =
Enum.flat_map(pairs, fn {_, k} ->
case k in struct_keys do
true -> []
false -> [k]
end
end)
case extra_keys do
[] ->
pairs
_ ->
raise ArgumentError,
"struct #{inspect(target)} does not define keys given in defschema_for/1 properties: #{inspect(extra_keys)}"
end
end
defp struct_keys(module) do
fields =
try do
module.__info__(:struct)
rescue
_ -> reraise ArgumentError, "module #{inspect(module)} does not define a struct", __STACKTRACE__
end
Enum.map(fields, & &1.field)
end
@doc """
Returns a tuple where the first element is a list of schema `:properties` keys
that do not have a `:default` value, and the second element is a list of
`{key, default_value}` tuples. sorted by keys.
Both lists are sorted by key.
The schema must be valid against `validate!1/`.
"""
@spec data_pairs_partition(JSV.native_schema()) :: {[atom], keyword()}
def data_pairs_partition(schema) do
{no_defaults, with_defaults} =
schema
|> props!()
|> Enum.reduce({[], []}, fn {k, subschema}, {no_defaults, with_defaults} when is_atom(k) ->
case fetch_default(subschema) do
{:ok, default} -> {no_defaults, [{k, default} | with_defaults]}
:error -> {[k | no_defaults], with_defaults}
end
end)
{Enum.sort(no_defaults), Enum.sort(with_defaults)}
end
@doc """
Returns the `required` property of the schema or an empty list.
The schema must be valid against `validate!1/`.
"""
@spec list_required(JSV.native_schema()) :: [atom()]
def list_required(schema) do
list =
case schema do
%{"required" => list} -> list
%Schema{required: nil} -> []
%{required: list} -> list
_ -> []
end
true = is_list(list)
list
end
defp props!(schema) do
case schema do
%{properties: properties} -> properties
%{"properties" => properties} -> properties
end
end
defp fetch_default(schema) do
case schema do
%{default: default} -> {:ok, default}
%{"default" => default} -> {:ok, default}
_ -> :error
end
end
@spec take_keycast(map, [{binary, atom}]) :: [{atom, term}]
def take_keycast(data, keycast) when is_map(data) do
Enum.reduce(keycast, [], fn {str_key, atom_key}, acc ->
case data do
%{^str_key => v} -> [{atom_key, v} | acc]
_ -> acc
end
end)
end
@doc """
Returns a JSON schema definition from the given keyword list. The keyword
describes the properties of the schema. The returned value will return a map
with `type: :object`, `properties: properties` and `:required` will be defined
from all properties that do not have a `:default` value in the schema.
"""
@spec props_to_schema(keyword, overrides :: map) :: %{type: :object, properties: map, required: list}
def props_to_schema(properties, overrides) when is_list(properties) do
# We will not validate that the keys are atoms, this is done when validating
# the returned schema from macros.
{props, required} =
Enum.map_reduce(properties, _required = [], fn
{k, {:optional, prop_schema}}, required when is_map(prop_schema) when is_atom(prop_schema) ->
{{k, prop_schema}, required}
{k, prop_schema}, required when is_map(prop_schema) ->
case fetch_default(prop_schema) do
# No default, the property is required
:error -> {{k, prop_schema}, [k | required]}
{:ok, _} -> {{k, prop_schema}, required}
end
{k, prop_schema}, required when is_atom(prop_schema) ->
{{k, prop_schema}, [k | required]}
_, _ ->
raise ArgumentError, errmsg("as properties must be a keyword list, got: #{inspect(properties)}")
end)
Map.merge(
%{
type: :object,
properties: Map.new(props),
required: :lists.reverse(required)
},
overrides
)
end
defp errmsg(msg) do
"schema given to defschema/1 " <> msg
end
end