defmodule Xema.Schema do
@moduledoc """
This module contains the struct for the keywords of a schema.
"""
alias Xema.{Behaviour, Ref, Schema, SchemaError, Utils}
@type xema :: struct
@typedoc """
The struct contains the keywords for a schema.
* `additional_items` disallow additional items, if set to false. The keyword
can also contain a schema to specify the type of additional items.
* `additional_properties` disallow additional properties, if set to true.
* 'all_of' a list of schemas they must all be valid.
* 'any_of' a list of schemas with any valid schema.
* `caster` a custom caster. This can be a function, a tuple with
module and function name, or a `Xema.Caster` behaviour.
* `comment` for the schema.
* `const` specifies a constant.
* `content_encoding` annotation for the encoding.
* `content_media_type` annotation for the media type.
* `contains` validates a list whether any item is valid for the given schema.
* `data` none schema data.
* `default` this keyword can be used to supply a default value for JSON and
`defstruct`.
* `definitions` contains schemas for reuse.
* `dependencies` allows the schema of the map to change based on the presence
of certain special properties
* `description` of the schema.
* `else` see `if`, `then`, `else`.
* `enum` specifies an enumeration
* `examples` the value of this keyword must be an array. There are no
restrictions placed on the values within the array.
* `exclusive_maximum` is a boolean. When true, it indicates that the range
excludes the maximum value.
* `exclusive_minimum` is a boolean. When true, it indicates that the range
excludes the minimum value.
* `format` semantic validation.
* `id` a unique identifier.
* `if`, `then`, `else`: These keywords work together to implement conditional
application of a subschema based on the outcome of another subschema.
* `items` specifies the type(s) of the items.
* `keys` could be `:atoms` or `:strings`.
* `max_items` the maximum length of list.
* `max_length` the maximum length of string.
* `max_properties` the maximum count of properties for the map.
* `maximum` the maximum value.
* `min_items` the minimal length of list.
* `min_length` the minimal length of string.
* `min_properties` the minimal count of properties for the map.
* `minimum` the minimum value.
* `module` the module of a struct.
* `multiple_of` is a number greater 0. The value has to be a multiple of this
number.
* `not` negates the given schema
* `one_of` the given data must be valid against exactly one of the given
subschemas.
* `pattern_properties` specifies schemas for properties by patterns
* `pattern` restrict a string to a particular regular expression.
* `properties` specifies schemas for properties.
* `property_names` a schema to check property names.
* `ref` a reference to a schema.
* `required` contains a set of required properties.
* `schema` declares the used schema.
* `title` of the schema.
* `then` see `if`, `then`, `else`
* `type` specifies the data type for a schema.
* `unique_items` disallow duplicate items, if set to true.
* `validator` a custom validator. This can be a function, a tuple with
module and function name, or a `Xema.Validator` behaviour.
"""
@type t :: %__MODULE__{
additional_items: Behaviour.t() | Schema.t() | boolean | nil,
additional_properties: map | boolean | nil,
all_of: [Schema.t()] | nil,
any_of: [Schema.t()] | nil,
caster: function | module | {module, atom} | {module, atom, arity} | list | nil,
comment: String.t() | nil,
const: any,
content_encoding: String.t() | nil,
content_media_type: String.t() | nil,
contains: Behaviour.t() | Schema.t() | nil,
data: map | nil,
default: any,
definitions: map | nil,
dependencies: list | map | nil,
description: String.t() | nil,
else: Behaviour.t() | Schema.t() | nil,
enum: list | nil,
examples: [any] | nil,
exclusive_maximum: boolean | number | nil,
exclusive_minimum: boolean | number | nil,
format: atom | nil,
id: String.t() | nil,
if: Behaviour.t() | Schema.t() | nil,
items: list | Behaviour.t() | Schema.t() | nil,
keys: atom | nil,
max_items: pos_integer | nil,
max_length: pos_integer | nil,
max_properties: pos_integer | nil,
maximum: number | nil,
min_items: pos_integer | nil,
min_length: pos_integer | nil,
min_properties: pos_integer | nil,
minimum: number | nil,
module: atom | nil,
multiple_of: number | nil,
not: Schema.t() | nil,
one_of: [Schema.t()] | nil,
pattern: Regex.t() | nil,
pattern_properties: map | nil,
properties: map | nil,
property_names: Behaviour.t() | Schema.t() | nil,
ref: Ref.t() | nil,
required: MapSet.t() | nil,
schema: String.t() | nil,
then: Behaviour.t() | Schema.t() | nil,
title: String.t() | nil,
type: type | [type],
unique_items: boolean | nil,
validator: function | module | {module, atom} | {module, atom, arity} | list | nil
}
defstruct [
:additional_items,
:additional_properties,
:all_of,
:any_of,
:caster,
:comment,
:const,
:content_encoding,
:content_media_type,
:contains,
:data,
:default,
:definitions,
:dependencies,
:description,
:else,
:enum,
:examples,
:exclusive_maximum,
:exclusive_minimum,
:format,
:id,
:if,
:items,
:keys,
:max_items,
:max_length,
:max_properties,
:maximum,
:min_items,
:min_length,
:min_properties,
:minimum,
:module,
:multiple_of,
:not,
:one_of,
:pattern,
:pattern_properties,
:properties,
:property_names,
:ref,
:required,
:schema,
:then,
:title,
:unique_items,
:validator,
type: :any
]
@typedoc """
The `type` for the schema.
"""
@type type ::
:any
| :atom
| :boolean
| false
| :float
| :integer
| :keyword
| :list
| :map
| nil
| :number
| :string
| :struct
| true
| :tuple
@types [
:any,
:atom,
:boolean,
false,
:float,
:integer,
:keyword,
:list,
:map,
nil,
:number,
:string,
:struct,
true,
:tuple
]
@doc """
Returns a `%Schema{}` for the given `keywords` in the keyword list.
"""
@spec new(keyword) :: Schema.t()
def new(keywords),
do:
struct!(
Schema,
keywords |> validate_type!() |> update()
)
@doc """
Returns the `%Schema{}` as a map. Items which a `nil` value are not in the
map.
"""
@spec to_map(Schema.t()) :: map
def to_map(schema),
do:
schema
|> Map.from_struct()
|> delete_nils()
@doc """
Returns all available `type`s in a list.
"""
@spec types :: [type]
def types, do: @types
@doc """
Returns all keywords in a list.
The key `:data` is not a regular keyword and is not in the list.
"""
@spec keywords :: [atom]
def keywords,
do:
%Schema{}
|> Map.keys()
|> List.delete(:data)
|> List.delete(:__struct__)
@doc """
Fetches a subschema from the `schema` by the given `pointer`.
If `schema` contains the given pointer with a subschema, then `{:ok, schema}`
is returned otherwise `:error`.
"""
@spec fetch(Schema.t(), Ref.t() | String.t()) :: {:ok, Schema.t()} | :error
def fetch(%Schema{} = schema, "#/" <> pointer), do: fetch(schema, pointer)
def fetch(%Schema{} = schema, pointer) do
keys = pointer |> String.trim("/") |> String.split("/")
do_fetch(schema, keys)
end
defp do_fetch(nil, _), do: :error
defp do_fetch(:error, _), do: :error
defp do_fetch(schema, []), do: {:ok, schema}
defp do_fetch(schema, [key | keys]) when is_list(schema) do
case Integer.parse(key) do
{index, ""} ->
with {:ok, schema} <- Enum.fetch(schema, index),
do: do_fetch(schema, keys)
_ ->
:error
end
end
defp do_fetch(schema, [key | keys] = pointer) do
key = decode(key)
atom_key = Utils.to_existing_atom(key)
case {Map.get(schema, key), Map.get(schema, atom_key)} do
{nil, nil} ->
with {:ok, data} <- Map.fetch(schema, :data),
do: do_fetch(data, pointer)
{value, nil} ->
do_fetch(value, keys)
{nil, value} ->
do_fetch(value, keys)
end
end
@doc """
Fetches a subschema from the `schema` by the given `pointer`.
If `schema` contains the given pointer with a subschema, then `{:ok, schema}`
is returned otherwise a `SchemaError` is raised.
"""
@spec fetch!(Schema.t(), Ref.t() | String.t()) :: Schema.t()
def fetch!(%Schema{} = schema, pointer) do
case fetch(schema, pointer) do
{:ok, schema} -> schema
:error -> raise SchemaError, {:ref_not_found, pointer}
end
end
# Validates the type/types in the given keywords.
# The key `:type` can contain a type or a list of types.
@spec validate_type!(keyword) :: keyword
defp validate_type!(opts) when is_list(opts) do
with {:ok, type} <- Keyword.fetch(opts, :type),
:ok <- validate_type(type) do
opts
else
:error ->
raise SchemaError, :missing_type
{:error, types} when is_list(types) ->
raise SchemaError, {:invalid_types, types}
{:error, type} ->
raise SchemaError, {:invalid_type, type}
end
end
# Validates a list of types. Returns a list of invalid types in an error tuple
# or :ok.
@spec validate_type([atom]) :: :ok | {:error, [atom]}
defp validate_type(types) when is_list(types) do
types
|> Enum.map(&validate_type/1)
|> Enum.filter(fn
:ok -> false
_ -> true
end)
|> case do
[] -> :ok
errors -> {:error, Enum.map(errors, fn {:error, type} -> type end)}
end
end
# Validates a type.
@spec validate_type(atom) :: :ok | {:error, atom}
defp validate_type(type) when type in @types, do: :ok
defp validate_type(type), do: {:error, type}
# This function updates some values in the `keywords`.
#
# * const: a `nil` will be updated to `:__nil__` to distinguish an unset value
# from `nil`.
# * pattern: setups a regex for this key.
# * pattern_properties: setups regexs for this key.
@spec update(keyword) :: keyword
defp update(keywords),
do:
keywords
|> Keyword.update(:const, nil, &mark_nil/1)
|> Keyword.update(:pattern, nil, &pattern/1)
|> Keyword.update(:pattern_properties, nil, &pattern_properties/1)
@spec mark_nil(any) :: any | :__nil__
defp mark_nil(nil), do: :__nil__
defp mark_nil(value), do: value
@spec pattern(Regex.t() | String.t() | atom) :: Regex.t()
defp pattern(string) when is_binary(string), do: Regex.compile!(string)
defp pattern(regex), do: regex
@spec pattern_properties(map | nil) :: map | nil
defp pattern_properties(nil), do: nil
defp pattern_properties(map),
do: for(key_value <- map, into: %{}, do: pattern_property(key_value))
defp pattern_property({pattern, property}) when is_binary(pattern),
do: {Regex.compile!(pattern), property}
defp pattern_property({pattern, property}) when is_atom(pattern),
do: pattern_property({Atom.to_string(pattern), property})
defp pattern_property(key_value), do: key_value
@spec delete_nils(map) :: map
defp delete_nils(schema),
do: for({k, v} <- schema, not is_nil(v), into: %{}, do: {k, v})
@spec decode(String.t()) :: String.t()
defp decode(str) do
str
|> String.replace("~0", "~")
|> String.replace("~1", "/")
|> URI.decode()
end
end
defimpl Inspect, for: Xema.Schema do
def inspect(schema, opts) do
map =
schema
|> Map.from_struct()
|> Map.update!(
:type,
fn
:any -> nil
val -> val
end
)
|> Enum.filter(fn {_, val} -> !is_nil(val) end)
|> Enum.into(%{})
Inspect.Map.inspect(map, "Xema.Schema", opts)
end
end