Current section

Files

Jump to
jsv lib jsv schema.ex
Raw

lib/jsv/schema.ex

defmodule JSV.Schema do
alias JSV.Resolver.Internal
@t_doc "`%#{inspect(__MODULE__)}{}` struct"
@moduledoc """
This module defines a struct where all the supported keywords of the JSON
schema specification are defined as keys. Text editors that can predict the
struct keys will make autocompletion available when writing schemas.
### Using in build
The #{@t_doc} can be given to `JSV.build/2`:
schema = %JSV.Schema{type: :integer}
JSV.build(schema, options())
Because Elixir structs always contain all their defined keys, writing a schema
as `%JSV.Schema{type: :integer}` is actually defining the following:
%JSV.Schema{
type: :integer,
"$id": nil
additionalItems: nil,
additionalProperties: nil,
allOf: nil,
anyOf: nil,
contains: nil,
# etc...
}
For that reason, when giving a #{@t_doc} to `JSV.build/2`, any `nil` value is
ignored. The same behaviour can be defined for other struct by implementing
the `JSV.Normalizer.Normalize` protocol. Mere maps will keep their `nil`
values.
Note that `JSV.build/2` does not require #{@t_doc}s, any map with binary or
atom keys is accepted.
This is also why the #{@t_doc} does not define the `const` keyword, because
`nil` is a valid value for that keyword but there is no way to know if the
value was omitted or explicitly defined as `nil`. To circumvent that you may
use the `enum` keyword or just use a regular map instead of this module's
struct:
%#{inspect(__MODULE__)}{enum: [nil]}
# OR
%{const: nil}
### Functional helpers
This module also exports a small range of utility functions to ease writing
schemas in a functional way.
This is mostly useful when generating schemas dynamically, or for shorthands.
For instance, instead of writing the following:
%Schema{
type: :object,
properties: %{
name: %Schema{type: :string, description: "the name of the user", minLength: 1},
age: %Schema{type: :integer, description: "the age of the user"}
},
required: [:name, :age]
}
One can write:
%Schema{
type: :object,
properties: %{
name: string(description: "the name of the user", minLength: 1),
age: integer(description: "the age of the user")
},
required: [:name, :age]
}
This is also useful when building schemas dynamically, as the helpers are
pipe-able one into another:
new()
|> props(
name: string(description: "the name of the user", minLength: 1),
age: integer(description: "the age of the user")
)
|> required([:name, :age])
"""
@moduledoc groups: [
%{
title: "Schema Definition Utilities",
description: """
Helper functions to define schemas or merge into a schema when
given as the first argument.
See `merge/2` for more information.
"""
},
%{
title: "Schema Casts",
description: """
Built-in cast functions for JSON Schemas.
Functions in this section can be called on a schema to return a
new schema that will automatically cast the data to the
desired type upon validation.
"""
}
]
@all_keys [
:"$anchor",
:"$comment",
:"$defs",
:"$dynamicAnchor",
:"$dynamicRef",
:"$id",
:"$ref",
:"$schema",
:additionalItems,
:additionalProperties,
:allOf,
:anyOf,
:contains,
:contentEncoding,
:contentMediaType,
:contentSchema,
:default,
:dependencies,
:dependentRequired,
:dependentSchemas,
:deprecated,
:description,
:else,
:enum,
:examples,
:exclusiveMaximum,
:exclusiveMinimum,
:format,
:if,
:items,
:maxContains,
:maximum,
:maxItems,
:maxLength,
:maxProperties,
:minContains,
:minimum,
:minItems,
:minLength,
:minProperties,
:multipleOf,
:not,
:oneOf,
:pattern,
:patternProperties,
:prefixItems,
:properties,
:propertyNames,
:readOnly,
:required,
:then,
:title,
:type,
:unevaluatedItems,
:unevaluatedProperties,
:uniqueItems,
:writeOnly,
# Internal keys.
:"jsv-cast",
:"x-jsv-cast"
]
@derive {Inspect, optional: @all_keys}
defstruct @all_keys
@type t :: %__MODULE__{}
@type attributes :: %{(binary | atom) => term} | [{atom | binary, term}]
@type schema_data :: %{optional(binary) => schema_data} | [schema_data] | number | binary | boolean | nil
@type merge_base :: attributes | [{atom | binary, term}] | struct | nil
@type schema :: true | false | map
@typedoc """
A single caster definition. Either a module name as a string or atom, or a
list where the first element is the module name (string or atom) and
subsequent elements are arbitrary JSON-compatible arguments passed to the cast
handler.
"""
@type caster :: String.t() | atom() | [schema_data]
@typedoc """
The value of the `x-jsv-cast` schema keyword. When there is a single caster
and that caster is a plain string (module name only, no extra arguments), it
is stored directly as a string without wrapping in a list. Otherwise it is a
list of casters.
"""
@type caster_group :: String.t() | [caster]
@doc """
Use this module to define module-based schemas or schemas with the helpers
API.
* Imports struct and cast definitions from `JSV`.
* Imports the `JSV.Schema.Helpers` module with the `string`, `integer`,
`enum`, _etc._ helpers.
* Imports the `JSV.Schema` transformers `JSV.Schema.xcast/1` and
`JSV.Schema.xcast/2`.
### Example
defmodule MySchema do
use JSV.Schema
defschema %{
type: :object,
properties: %{
foo: string(description: "Some foo!"),
bar: integer(minimum: 100) |> xcast([__MODULE__,:hashid]),
sub: props(sub_foo: string(), sub_bar: integer()) pp
}
}
defcast hashid(bar) do
{:ok, Hashids.decode!(bar, cipher())}
end
end
"""
defmacro __using__(_) do
quote do
import JSV, only: :macros
import JSV.Schema.Helpers
import JSV.Schema, only: [xcast: 1, xcast: 2]
end
end
@doc """
Returns a new empty schema.
"""
@spec new :: t
def new do
%__MODULE__{}
end
@doc """
Returns a new schema with the given key/values.
"""
@spec new(t | attributes) :: t
def new(%__MODULE__{} = schema) do
schema
end
def new(key_values) when is_list(key_values) when is_map(key_values) do
struct!(__MODULE__, key_values)
end
@doc """
Merges the given key/values into the base schema. The merge is shallow and
will overwrite any pre-existing key.
This function is defined to work with the `JSV.Schema.Composer` API.
The resulting schema is always a map or a struct but the actual type depends
on the given base. It follows the followng rules:
* **When the base type is a map or a struct, it is preserved**
- If the base is a #{@t_doc}, the `values` are merged in.
- If the base is another struct, the `values` a merged in. It will fail if
the struct does not define the overriden keys. No invalid struct is
generated.
- If the base is a mere map, it is **not** turned into a #{@t_doc} and the
`values` are merged in.
* **Otherwise the base is cast to a #{@t_doc}**
- If the base is `nil`, the function returns a #{@t_doc} with the given
`values`.
- If the base is a keyword list, the list will be turned into a #{@t_doc}
and then the `values` are merged in.
## Examples
iex> JSV.Schema.merge(%JSV.Schema{description: "base"}, %{type: :integer})
%JSV.Schema{description: "base", type: :integer}
defmodule CustomSchemaStruct do
defstruct [:type, :description]
end
iex> JSV.Schema.merge(%CustomSchemaStruct{description: "base"}, %{type: :integer})
%CustomSchemaStruct{description: "base", type: :integer}
iex> JSV.Schema.merge(%CustomSchemaStruct{description: "base"}, %{format: :date})
** (KeyError) struct CustomSchemaStruct does not accept key :format
iex> JSV.Schema.merge(%{description: "base"}, %{type: :integer})
%{description: "base", type: :integer}
iex> JSV.Schema.merge(nil, %{type: :integer})
%JSV.Schema{type: :integer}
iex> JSV.Schema.merge([description: "base"], %{type: :integer})
%JSV.Schema{description: "base", type: :integer}
"""
@spec merge(merge_base, attributes) :: schema()
def merge(nil, values) do
new(values)
end
def merge(merge_base, values) when is_list(merge_base) do
struct!(new(merge_base), values)
end
def merge(%mod{} = merge_base, values) do
struct!(merge_base, values)
rescue
e in KeyError ->
reraise %{e | message: "struct #{inspect(mod)} does not accept key #{inspect(e.key)}"}, __STACKTRACE__
end
def merge(merge_base, values) when is_map(merge_base) do
Enum.into(values, merge_base)
end
@doc """
Merges two sets of attributes into a single map. Attributes can be a keyword
list or a map.
"""
@spec combine(attributes | nil, attributes) :: schema
def combine(base, overrides)
def combine(nil, overrides) do
Map.new(overrides)
end
def combine(map, attributes) when is_map(map) do
Enum.into(attributes, map)
end
def combine(list, attributes) when is_list(list) do
Enum.into(attributes, Map.new(list))
end
@deprecated "Use `JSV.Schema.Composer.merge/2`."
@doc false
@spec override(merge_base, attributes) :: schema
def override(merge_base, values) do
merge(merge_base, values)
end
@doc """
This is a legacy function using the custom `jsv-cast` schema keyword. It will
be deprecated in the next release. Use `xcast/2`.
Includes the cast function in a schema. The cast function must be given as a
list with two items:
* A module, as atom or string
* A tag, as atom, string or integer.
Atom arguments will be converted to string.
### Examples
iex> JSV.Schema.with_cast([MyApp.Cast, :a_cast_function])
%JSV.Schema{"jsv-cast": ["Elixir.MyApp.Cast", "a_cast_function"]}
iex> JSV.Schema.with_cast([MyApp.Cast, 1234])
%JSV.Schema{"jsv-cast": ["Elixir.MyApp.Cast", 1234]}
iex> JSV.Schema.with_cast(["some_erlang_module", "custom_tag"])
%JSV.Schema{"jsv-cast": ["some_erlang_module", "custom_tag"]}
"""
@spec with_cast(merge_base, [atom | binary | integer, ...]) :: schema()
def with_cast(merge_base \\ nil, [mod, tag] = _mod_tag)
when (is_atom(mod) or is_binary(mod)) and (is_atom(tag) or is_binary(tag) or is_integer(tag)) do
merge(merge_base, "jsv-cast": [to_string_if_atom(mod), to_string_if_atom(tag)])
end
@doc false
@deprecated "Use `xcast/2` instead."
@spec cast(merge_base(), [atom | binary | integer, ...]) :: schema()
def cast(merge_base \\ nil, mod_tag) do
with_cast(merge_base, mod_tag)
end
defp to_string_if_atom(value) when is_atom(value) do
Atom.to_string(value)
end
defp to_string_if_atom(value) do
value
end
@doc """
Appends the caster to a schema passed as the `x-jsv-cast` schema extension
keyword.
A cast function is either a resolvable Elixir module as string, or a list with
a resolvable Elixir module as string and additional arguments.
Given arguments will be normalized to JSON-decoded form.
### Examples for `xcast/1` and `xcast/2`
iex> JSV.Schema.xcast([MyApp.Cast, :a_cast_function])
%{"x-jsv-cast": [["Elixir.MyApp.Cast", "a_cast_function"]]}
iex> JSV.Schema.xcast([:some_erlang_module, "custom_tag"])
%{"x-jsv-cast": [["some_erlang_module", "custom_tag"]]}
If the given cast is a single atom or string, the cast is added (as a string)
directly without a wrapping list, this is a bandwidth optimization.
iex> JSV.Schema.xcast(MyApp.Cast)
%{"x-jsv-cast": "Elixir.MyApp.Cast"}
The function will automatically convert to a list when needed.
iex> %{} |> JSV.Schema.xcast(MyApp.Foo) |> JSV.Schema.xcast(MyApp.Cast)
%{"x-jsv-cast": ["Elixir.MyApp.Foo", "Elixir.MyApp.Cast"]}
iex> %{} |> JSV.Schema.xcast(MyApp.Foo) |> JSV.Schema.xcast([MyApp.Cast, "some_function", %{123 => :foo}])
%{"x-jsv-cast": ["Elixir.MyApp.Foo", ["Elixir.MyApp.Cast", "some_function", %{"123" => "foo"}]]}
Schemas using binary `"x-jsv-cast"` key will have the key converted to atom
form.
iex> %{"x-jsv-cast" => "Elixir.MyApp.Foo"} |> JSV.Schema.xcast(MyApp.Cast)
%{"x-jsv-cast": ["Elixir.MyApp.Foo", "Elixir.MyApp.Cast"]}
"""
@spec xcast(schema, String.t() | atom | [schema_data]) :: %{:"x-jsv-cast" => caster_group}
def xcast(%{} = schema, caster)
when is_binary(caster)
when is_atom(caster)
when is_binary(hd(caster))
when is_atom(hd(caster)) do
normal = JSV.Schema.normalize(caster)
case schema do
%{:"x-jsv-cast" => _, "x-jsv-cast" => _} ->
raise ArgumentError,
"JSV.Schema.xcast/2 base mixing " <>
"#{inspect(:"x-jsv-cast")} and #{inspect("x-jsv-cast")} " <>
"keys: #{inspect(schema)}"
%{"x-jsv-cast": cast_base} ->
%{schema | "x-jsv-cast": append_xcast(cast_base, normal)}
%{"x-jsv-cast" => cast_base} ->
schema
|> Map.delete("x-jsv-cast")
|> Map.put(:"x-jsv-cast", append_xcast(cast_base, normal))
%{} when is_binary(normal) ->
Map.put(schema, :"x-jsv-cast", normal)
%{} when is_list(normal) ->
Map.put(schema, :"x-jsv-cast", [normal])
end
end
defp append_xcast(base, normal) when is_binary(base) do
[base, normal]
end
defp append_xcast(base, normal) when is_list(base) do
base ++ [normal]
end
defp append_xcast(nil, normal) when is_binary(normal) do
normal
end
defp append_xcast(nil, normal) when is_list(normal) do
[normal]
end
defp append_xcast(base, _normal) do
raise ArgumentError,
"invalid x-jsv-cast in base schema given to JSV.Schema.xcast/2, " <>
"expected string or list, got: #{inspect(base)}"
end
@doc """
Returns a schema with the given caster as the `x-jsv-cast` schema extension
keyword.
See `xcast/2` for more information and examples.
"""
@spec xcast(String.t() | atom | [schema_data]) :: %{:"x-jsv-cast" => caster_group}
def xcast(caster)
when is_binary(caster)
when is_atom(caster)
when is_binary(hd(caster))
when is_atom(hd(caster)) do
xcast(%{}, caster)
end
@doc """
Normalizes a JSON schema with the help of `JSV.Normalizer.normalize/3` with
the following customizations:
* `JSV.Schema` structs pairs where the value is `nil` will be removed.
`%JSV.Schema{type: :object, properties: nil, allOf: nil, ...}` becomes
`%{"type" => "object"}`.
* Modules names that export a schema will be converted to a raw schema with a
reference to that module that can be resolved automatically by
`JSV.Resolver.Internal`.
* Other atoms will be checked to see if they correspond to a module name that
exports a `json_schema/0` function.
### Examples
defmodule Elixir.ASchemaExportingModule do
def json_schema, do: %{}
end
iex> JSV.Schema.normalize(ASchemaExportingModule)
%{"$ref" => "jsv:module:Elixir.ASchemaExportingModule"}
defmodule AModuleWithoutExportedSchema do
def hello, do: "world"
end
iex> JSV.Schema.normalize(AModuleWithoutExportedSchema)
"Elixir.AModuleWithoutExportedSchema"
"""
@spec normalize(term) :: %{optional(binary) => schema_data} | [schema_data] | number | binary | boolean | nil
def normalize(term) do
normalize_opts = [
on_general_atom: fn atom, acc ->
if schema_module?(atom) do
{%{"$ref" => Internal.module_to_uri(atom)}, acc}
else
{Atom.to_string(atom), acc}
end
end
]
{normal, _acc} = JSV.Normalizer.normalize(term, [], normalize_opts)
normal
end
@doc """
Behaves like `normalize/1` but all nested module-based schemas are collected
into `$defs` so the result is a self contained schema, whereas the default
normalization function returns references for `JSV.Resolver.Internal`.
Schemas are collected using their title for the key under `$defs`. If multiple
schemas use the same title, the title is suffixed with `_1`, `_2` and so on.
This function does not support schemas with pre-existing `$defs`, it will
ignore them and keep them nested. If such schemas are present and use `$ref`
to their own definitions, the schema returned from this function may not be
valid. To prevent this, schemas with definitions should define an `$id` and
use this in `$ref` references.
### Options
- `:as_root` - boolean, when `true` and used in combination with a
module-based schema, that module's schema will be kept as the root schema
instead of being wrapped in a definition. This will overwrite any `$defs`
present in the schema.
"""
@spec normalize_collect(term, keyword()) :: %{optional(binary) => schema_data} | atom
def normalize_collect(term, opts \\ [])
def normalize_collect(term, opts) when is_atom(term) do
if Keyword.get(opts, :as_root) == true and schema_module?(term) do
do_normalize_collect(term.json_schema(), opts)
else
do_normalize_collect(term, opts)
end
end
def normalize_collect(term, opts) when is_map(term) do
do_normalize_collect(term, opts)
end
defp do_normalize_collect(term, _opts) when is_atom(term) when is_map(term) do
# We will have to run several loops. We call the normalizer, replacing
# modules with a reference, collecting the module in the acc.
#
# After the call, if we collected some modules, we merge the refs and start
# over but we keep the previously used modules and refs so we can skip them
# and directly use the ref
accin = %{
# Collected definitions to merge in the final result
defs: %{},
# module to ref, used to see if we handled the module and store the ref
modules: %{},
# modules for which we generated a reference and we need to normalize into
# a definition.
pending: []
}
normalize_opts = [
on_general_atom: fn atom, acc ->
if schema_module?(atom) do
case Map.fetch(acc.modules, atom) do
{:ok, ref} ->
{%{"$ref" => ref}, acc}
:error ->
schema = from_module(atom)
title = module_schema_title(schema, atom)
refname = available_module_schema_refname(acc.defs, title)
ref = "#/$defs/#{refname}"
acc = put_in(acc.modules[atom], ref)
acc = put_in(acc.defs[refname], :__placeholder__)
acc = update_in(acc.pending, &[{refname, schema} | &1])
{%{"$ref" => ref}, acc}
end
else
{Atom.to_string(atom), acc}
end
end
]
# On the first iteration we get the root schema
case JSV.Normalizer.normalize(term, accin, normalize_opts) do
{root_schema, acc} when is_map(root_schema) ->
{pending, acc} = get_and_update_in(acc.pending, &{&1, []})
defs = normalize_collect_defs(pending, acc, normalize_opts)
case map_size(defs) do
0 -> root_schema
_ -> Map.put(root_schema, "$defs", defs)
end
{other, _} ->
other
end
end
defp normalize_collect_defs([{refname, schema} | pending], acc, normalize_opts) do
{def_schema, acc} = JSV.Normalizer.normalize(schema, acc, normalize_opts)
acc = update_in(acc.defs[refname], fn :__placeholder__ -> def_schema end)
normalize_collect_defs(pending, acc, normalize_opts)
end
defp normalize_collect_defs([], acc, normalize_opts) do
case acc.pending do
[] ->
acc.defs
more ->
acc = Map.put(acc, :pending, [])
normalize_collect_defs(more, acc, normalize_opts)
end
end
defp module_schema_title(%{"title" => title}, _module) when is_binary(title) and title != "" do
title
end
defp module_schema_title(%{title: title}, _module) when is_binary(title) and title != "" do
title
end
defp module_schema_title(_schema, module) do
inspect(module)
end
# we do not append a number at the end of the title on the first try
defp available_module_schema_refname(schemas, title) do
if Map.has_key?(schemas, title) do
available_module_schema_refname(schemas, title, 1)
else
title
end
end
defp available_module_schema_refname(_schemas, title, n) when n > 1000 do
# This should not happen but lets not iterate forever
raise "could not generate a unique name for #{title}"
end
defp available_module_schema_refname(schemas, title, n) do
name = "#{title}_#{Integer.to_string(n)}"
if Map.has_key?(schemas, name) do
available_module_schema_refname(schemas, title, n + 1)
else
name
end
end
@common_atom_values [
true,
false,
nil,
# Common types
:array,
:boolean,
:enum,
:integer,
:null,
:number,
:object,
:string,
# Common Elixir
:ok,
:error,
:date,
# Formats
:ipv4,
:ipv6,
:unknown,
:regex,
:date,
:"date-time",
:time,
:hostname,
:uri,
:"uri-reference",
:uuid,
:email,
:iri,
:"iri-reference",
:"uri-template",
:"json-pointer",
:"relative-json-pointer"
]
@doc """
Returns whether the given atom is a module with a `schema/0` exported
function.
"""
@spec schema_module?(atom) :: boolean
def schema_module?(module) when module in @common_atom_values do
false
end
def schema_module?(module) do
case Code.ensure_compiled(module) do
{:error, _} ->
false
{:module, ^module} ->
Code.ensure_loaded!(module)
function_exported?(module, :json_schema, 0)
end
end
@doc """
Calls the `json_schema/0` function on the given module.
"""
@spec from_module(module) :: schema()
def from_module(module) do
module.json_schema()
end
@doc """
Returns the given `%#{inspect(__MODULE__)}{}` as a map without keys containing
a `nil` value.
"""
@spec to_map(t) :: %{optional(atom) => term}
def to_map(%__MODULE__{} = schema) do
schema
|> Map.from_struct()
|> Map.filter(fn {_, v} -> v != nil end)
end
defimpl JSV.Normalizer.Normalize do
alias JSV.Helpers.MapExt
def normalize(schema) do
MapExt.from_struct_no_nils(schema)
end
end
end