Packages
jsv
0.21.2
0.21.2
0.21.1
0.21.0
0.20.0
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.3
0.18.2
0.18.1
0.18.0
0.17.1
0.17.0
0.16.0
0.15.2
0.15.1
0.15.0
0.14.0
0.13.1
0.13.0
0.12.0
0.11.5
0.11.4
0.11.3
retired
0.11.2
0.11.1
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.6.3
0.6.2
0.6.0
0.5.1
0.5.0
0.4.0
0.3.0
0.2.0
0.1.0
A JSON Schema Validator with complete support for the latest specifications.
Current section
Files
Jump to
Current section
Files
lib/jsv.ex
defmodule JSV do
alias JSV.BooleanSchema
alias JSV.Builder
alias JSV.BuildError
alias JSV.ErrorFormatter
alias JSV.Helpers.OptsValidator
alias JSV.Key
alias JSV.Ref
alias JSV.Resolver
alias JSV.Resolver.Internal
alias JSV.Root
alias JSV.Schema
alias JSV.ValidationError
alias JSV.Validator
alias JSV.Validator.ValidationContext
require Record
use JSV.Debanger, records: [:build]
Record.defrecordp(:build_ctx, :build, builder: nil, validators: %{})
@moduledoc """
JSV is a JSON Schema Validator.
This module is the main facade for the library.
To start validating schemas you will need to go through the following steps:
1. [Obtain a schema](guides/schemas/defining-schemas.md). Schemas can be
defined in Elixir code, read from files, fetched remotely, _etc_.
1. [Build a validation root](guides/build/build-basics.md) with `build/2` or
`build!/2`.
1. [Validate the data](guides/validation/validation-basics.md).
## Example
Here is an example of the most simple way of using the library:
```elixir
schema = %{
type: :object,
properties: %{
name: %{type: :string}
},
required: [:name]
}
root = JSV.build!(schema)
case JSV.validate(%{"name" => "Alice"}, root) do
{:ok, data} ->
{:ok, data}
# Errors can be turned into JSON compatible data structure to send them as an
# API response or for logging purposes.
{:error, validation_error} ->
{:error, JSON.encode!(JSV.normalize_error(validation_error))}
end
```
If you want to explore the different capabilities of the library, please refer
to the guides provided in this documentation.
"""
@typedoc """
A schema in a JSON-decoded form: Only maps with binary keys and
binary/number/boolean/nil values, or a boolean.
The name refers to the process of _normalization_. A `t:native_schema/0` can
be turned into a `t:normal_schema/0` with the help of
`JSV.Schema.normalize/1`.
"""
@moduledoc groups: [
[title: "Types"],
[
title: "Schema Validation API",
description: "The main API for JSV, used to build validation roots and validate data."
],
[
title: "Schema Definition Macros",
description: "Macros to create module-based schemas and custom cast functions."
],
[
title: "Custom Build API",
description:
"Low level build API to work with schemas embedded in larger documents such as an OpenAPI specification."
]
]
@default_default_meta "https://json-schema.org/draft/2020-12/schema"
@default_validation_options %{cast: true, cast_formats: false}
@default_build_options %{
resolver: [],
default_meta: @default_default_meta,
formats: nil,
vocabularies: %{},
atoms: nil,
warnings: :emit
}
@type normal_schema :: boolean() | %{binary => normal_schema() | [normal_schema()]}
@typedoc """
A schema in native JSV/Elixir terms: maps with atoms, structs, and module.
"""
@type native_schema :: boolean() | map() | module() | normal_schema()
@type build_opt ::
{:resolver, module() | {module(), term()} | [module() | {module(), term()}]}
| {:default_meta, binary()}
| {:formats, boolean() | nil | [module()]}
| {:vocabularies, %{optional(binary()) => module() | {module(), term()}}}
| {:atoms, boolean()}
| {:warnings, :emit | :silent}
@type validate_opt ::
{:cast, boolean()}
| {:cast_formats, boolean()}
| {:key, term()}
@opaque build_context :: record(:build_ctx, builder: Builder.t(), validators: Validator.validators())
# ---------------------------------------------------------------------------
# Schema Validation API
# ---------------------------------------------------------------------------
@doc_group "Schema Validation API"
@doc """
Builds the schema as a `#{inspect(Root)}` schema for validation.
### Options
* `:resolver` (`module | {module, options} | [module | {module, options}]`) -
The `JSV.Resolver` behaviour implementation module to retrieve schemas
identified by an URL.
Accepts a `module`, a `{module, options}` tuple or a list of those forms.
The options can be any term and will be given to the `resolve/2` callback of
the module.
The `JSV.Resolver.Embedded` and `JSV.Resolver.Internal` will be
automatically appended to support module-based schemas and meta-schemas.
The default value is `[]`.
* `:default_meta` (`t:String.t/0`) - The meta schema to use for resolved
schemas that do not define a `"$schema"` property. The default value is
`#{inspect(@default_default_meta)}`.
* `:formats` (`boolean | nil | [module]`) - Controls the validation of strings
with the `"format"` keyword.
* `nil` - Format validation is enabled if to the meta-schema uses the format
assertion vocabulary.
* `true` - Enforces validation with the default validator modules.
* `false` - Disables all format validation.
* `[Module1, Module2,...]` (A list of modules) - Format validation is
enabled and will use those modules as validators instead of the default
format validator modules. The default format validator modules can be
included back in the list manually, see
`default_format_validator_modules/0`.
> #### Formats are disabled by the default meta-schema {: .warning}
>
> The default value for this option is `nil` to respect the JSON Schema
> specification where format validation is enabled via vocabularies.
>
> The default meta-schemas for the latest drafts (example:
> `#{@default_default_meta}`) do not enable format validation.
>
> You'll probably want this option to be set to `true` or a list of your own
> modules.
Worth noting, while this option does support providing your own formats, the
[official
specification](https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7.2.3)
recommends against it:
> Vocabularies do not support specifically declaring different value sets
> for keywords. Due to this limitation, and the historically uneven
> implementation of this keyword, it is RECOMMENDED to define additional
> keywords in a custom vocabulary rather than additional format attributes
> if interoperability is desired.
The default value is `nil`.
* `:vocabularies` (`%{String.t() => module | {module, options}}`) - Allows to
redefine modules implementing vocabularies.
This option accepts a map with vocabulary URIs as keys and implementations
as values. The URIs are not fetched by JSV and does not need to point to
anything specific. For instance, vocabulary URIs in the standard Draft
2020-12 meta-schema point to human-readable documentation.
The given implementations will only be used if the meta-schema used to build
a validation root actually declare those URIs in their `$vocabulary`
keyword.
For instance, to redefine how the `type` keyword and other validation
keywords are handled, one should pass the following map:
%{
"https://json-schema.org/draft/2020-12/vocab/validation" => MyCustomModule
}
Modules must implement the `JSV.Vocabulary` behaviour.
Implementations can also be passed options by wrapping them in a tuple:
%{
"https://json-schema.org/draft/2020-12/vocab/validation" => {MyCustomModule, foo: "bar"}
}
The default value is `%{}`.
* `:atoms` (`t:boolean/0`) - Whether to allow casts that create atoms. This
enables the following helpers:
- `JSV.Schema.Helpers.string_enum_to_atom/2`
- `JSV.Schema.Helpers.string_enum_to_atom_or_nil/2`
- `JSV.Schema.Helpers.string_to_atom/1`
When set to `false`, these casters are silently dropped at build time.
Schemas that relied on them will validate strings as strings (no atom
conversion happens at runtime), so plan accordingly when toggling this
option on existing data flows.
It is safe to set to `true` for trusted schemas. Use `false` if you are
building untrusted schemas at runtime to avoid third parties to define
unwanted `x-jsv-cast` casts with atom casting.
The current default value is `true` for compatibility reasons. In future
releases, this option will default to false.
* `:warnings` (`:emit | :silent`) - Controls schema build warnings.
- `:emit` - Warnings will be emitted when a schema is built with
`IO.warn/2`.
- `:silent` - Warnings will not be emitted.
Warnings are always returned in the built root.
The default value is `:emit`.
"""
@doc group: @doc_group
@spec build(native_schema(), [build_opt]) :: {:ok, Root.t()} | {:error, Exception.t()}
def build(raw_schema, opts \\ []) do
{:ok, build!(raw_schema, opts)}
rescue
e in BuildError ->
{:error, e}
e in UndefinedFunctionError ->
%{module: m, function: f, arity: a} = e
{:error, BuildError.of(e, {m, f, a})}
end
@doc """
Same as `build/2` but raises on error. Errors are not normalized into a
`JSV.BuildError` as `build/2` does.
"""
@doc group: @doc_group
@spec build!(JSV.native_schema(), [build_opt]) :: Root.t()
def build!(raw_schema, opts \\ [])
def build!(valid?, _opts) when is_boolean(valid?) do
%Root{raw: valid?, root_key: :root, validators: %{root: BooleanSchema.of(valid?, [:root])}}
end
def build!(raw_schema, opts) when is_map(raw_schema) when is_atom(raw_schema) do
ctx = build_init!(opts)
{root_key, normal_schema, ctx} = build_add!(ctx, raw_schema)
{^root_key, build_ctx(builder: builder, validators: validators)} = build_key!(ctx, root_key)
%Root{
raw: normal_schema,
validators: validators,
root_key: root_key,
has_casts: builder.has_casts,
has_unevaluated: builder.has_unevaluated,
warnings: :lists.reverse(builder.warnings)
}
end
@doc """
Returns the list of format validator modules that are used when a schema is
built with format validation enabled and the `:formats` option to `build/2` is
`true`.
"""
@doc group: @doc_group
@spec default_format_validator_modules :: [module]
def default_format_validator_modules do
[JSV.FormatValidator.Default]
end
@doc """
Returns the default meta schema used when the `:default_meta` option is not
set in `build/2`.
Currently returns #{inspect(@default_default_meta)}.
"""
@doc group: @doc_group
@spec default_meta :: binary
def default_meta do
@default_default_meta
end
@doc """
Returns the schema representing errors returned by `normalize_error/1`.
Because errors can be nested, the schema is recursive, so this function
returns a module based schema (a module name).
"""
@doc group: @doc_group
@spec error_schema :: module
def error_schema do
JSV.ErrorFormatter.error_schema()
end
@doc """
Returns a JSON compatible represenation of a `JSV.ValidationError` struct.
See `JSV.ErrorFormatter.normalize_error/2` for options.
When used without the `:atoms` keys option, a normalized error will correspond
to the JSON schema returned by `error_schema/0`.
"""
@doc group: @doc_group
@spec normalize_error(ValidationError.t() | Validator.context() | [Validator.Error.t()], keyword) :: map()
def normalize_error(error, opts \\ [])
def normalize_error(%ValidationError{} = error, opts) do
ErrorFormatter.normalize_error(error, opts)
end
def normalize_error(errors, opts) when is_list(errors) do
normalize_error(ValidationError.of(errors), opts)
end
def normalize_error(%ValidationContext{} = validator, opts) do
normalize_error(Validator.to_error(validator), opts)
end
@doc false
# direct entrypoint for tests when we want to get the returned context.
@spec validation_entrypoint(term, term, term) :: Validator.result()
def validation_entrypoint(%JSV.Root{} = schema, data, opts) do
%JSV.Root{validators: validators, root_key: root_key} = schema
key = Map.get(opts, :key, root_key)
feature_cast = schema.has_casts and opts.cast
feature_unevaluated = schema.has_unevaluated
case Map.fetch(validators, key) do
{:ok, root_schema_validators} ->
context = JSV.Validator.context(validators, key, opts, feature_cast, feature_unevaluated)
JSV.Validator.validate(data, root_schema_validators, context)
:error ->
raise ArgumentError, "validators are not defined for key #{inspect(key)}"
end
end
@doc """
Normalizes a resolver implementation to a list of `{module, options}` and
appends the default resolvers if they are not already present in the list.
### Examples
iex> JSV.resolver_chain(MyModule)
[{MyModule, []}, {JSV.Resolver.Embedded, []}, {JSV.Resolver.Internal, []}]
iex> JSV.resolver_chain([JSV.Resolver.Embedded, MyModule])
[{JSV.Resolver.Embedded, []}, {MyModule, []}, {JSV.Resolver.Internal, []}]
iex> JSV.resolver_chain([{JSV.Resolver.Embedded, []}, {MyModule, %{foo: :bar}}])
[{JSV.Resolver.Embedded, []}, {MyModule, %{foo: :bar}}, {JSV.Resolver.Internal, []}]
"""
@doc group: @doc_group
@spec resolver_chain(resolvers :: module | {module, term} | list({module, term})) :: [{module, term}]
def resolver_chain([]) do
[{JSV.Resolver.Embedded, []}, {JSV.Resolver.Internal, []}]
end
def resolver_chain(resolvers) when is_list(resolvers) do
do_resolver_chain(resolvers, [], %{add_embedded: true, add_internal: true})
end
def resolver_chain(single) do
resolver_chain([single])
end
defp do_resolver_chain([impl | rest], acc, flags) do
{module, _} =
impl =
case impl do
{module, opts} when is_atom(module) -> {module, opts}
module when is_atom(module) -> {module, []}
end
flags =
case module do
JSV.Resolver.Embedded -> %{flags | add_embedded: false}
JSV.Resolver.Internal -> %{flags | add_internal: false}
_ -> flags
end
do_resolver_chain(rest, [impl | acc], flags)
end
defp do_resolver_chain([], acc, flags) do
tail =
case flags do
%{add_embedded: true, add_internal: true} -> [{JSV.Resolver.Embedded, []}, {JSV.Resolver.Internal, []}]
%{add_embedded: false, add_internal: true} -> [{JSV.Resolver.Internal, []}]
%{add_embedded: true, add_internal: false} -> [{JSV.Resolver.Embedded, []}]
_ -> []
end
:lists.reverse(acc, tail)
end
@doc """
Validates and casts the data with the given schema. The schema must be a
`JSV.Root` struct generated with `build/2`.
> #### This function returns cast data {: .info}
>
>
> * If the `:cast_formats` option is enabled, string values may be transformed
> in other data structures. Refer to the "Formats" section of the
> [Validation guide](validation-basics.html#formats) for more information.
> * The JSON Schema specification states that `123.0` is a valid integer. This
> function will return `123` instead. This may return invalid data for
> floats with very large integer parts. As always when dealing with JSON and
> big decimal or extremely precise numbers, use strings.
### Options
* `:cast` (`t:boolean/0`) - Enables calling generic cast functions on
validation.
This is based on the `x-jsv-cast` JSON Schema custom keyword and is
typically used by `defschema/1`.
While it is on by default, some specific casting features are enabled
separately, see option `:cast_formats`.
The default value is `true`.
* `:cast_formats` (`t:boolean/0`) - When enabled, format validators will
return casted values, for instance a `Date` struct instead of the date as
string.
It has no effect when the schema was not built with formats enabled.
The default value is `false`.
* `:key` (`t:term/0`) - When specified, the validation will start in the
schema at the given key instead of using the root schema.
The key must have been built and returned by `build_key!/2`. The validation
does not accept to validate any Ref or pointer in the schema.
This is useful when validating with a JSON document that contains schemas
but is not itself a schema.
"""
@doc group: @doc_group
@spec validate(term, JSV.Root.t(), [validate_opt]) :: {:ok, term} | {:error, Exception.t()}
def validate(data, root, opts \\ [])
def validate(data, %JSV.Root{} = root, opts) do
opts = OptsValidator.validate(opts, @default_validation_options, &validate_validation_opts/2)
case validation_entrypoint(root, data, opts) do
{:ok, casted_data, _} -> {:ok, casted_data}
{:error, %ValidationContext{} = validator} -> {:error, Validator.to_error(validator)}
end
end
defp validate_validation_opts(:cast, value) do
OptsValidator.validate_boolean(:cast, value)
end
defp validate_validation_opts(:cast_formats, value) do
OptsValidator.validate_boolean(:cast_formats, value)
end
defp validate_validation_opts(:key, value) do
value
end
defp validate_validation_opts(key, _value) do
OptsValidator.unknown_option!(key)
end
@doc """
Same as `validate/3` but returns the validated data directly or raises a
validation error.
"""
@doc group: @doc_group
@spec validate!(term, JSV.Root.t(), keyword) :: term
def validate!(data, root, opts \\ []) do
case validate(data, root, opts) do
{:ok, term} -> term
{:error, e} -> raise e
end
end
# From https://github.com/fishcakez/dialyze/blob/6698ae582c77940ee10b4babe4adeff22f1b7779/lib/mix/tasks/dialyze.ex#L168
@doc false
@spec otp_version :: String.t()
def otp_version do
major = :erlang.list_to_binary(:erlang.system_info(:otp_release))
vsn_file = Path.join([:code.root_dir(), "releases", major, "OTP_VERSION"])
try do
vsn_file
|> File.read!()
|> String.split("\n", trim: true)
else
[full] -> full
_ -> major
catch
:error, _ -> major
end
end
# ---------------------------------------------------------------------------
# Schema Definition Macros
# ---------------------------------------------------------------------------
@doc_group "Schema Definition Macros"
@doc """
Defines a struct in the calling module where the struct keys are the
properties of the schema.
The given schema must define the `type` keyword as `object` and must define a
`properties` map. That map can be empty to define a struct without any key.
Properties keys must be given as atoms.
If a default value is given in a property schema, it will be used as the
default value for the corresponding struct key. Otherwise, the default value
will be `nil`. A default value is _not_ validated against the property schema
itself.
defmodule MyApp.UserSchema do
import JSV
defschema %{
type: :object,
properties: %{
name: %{type: :string, default: ""},
age: %{type: :integer, default: 123}
}
}
end
iex> %MyApp.UserSchema{}
%MyApp.UserSchema{name: "", age: 123}
iex> {:ok, root} = JSV.build(MyApp.UserSchema)
iex> JSV.validate(%{"name" => "Alice"}, root)
{:ok, %MyApp.UserSchema{name: "Alice", age: 123}}
The `required` keyword is supported and must use atom keys as well.
defmodule MyApp.WithRequired do
import JSV
defschema %{
type: :object,
properties: %{
name: %{type: :string},
age: %{type: :integer, default: 123}
},
required: [:name]
}
end
iex> %MyApp.WithRequired{name: "Alice"}
%MyApp.WithRequired{name: "Alice", age: 123}
### Property List Syntax
Alternatively, you can use a keyword list to define the properties where each
property is defined as `{key, schema}`. The following rules apply:
- All properties without a `default` value are automatically marked as
required and are enforced at the struct level.
- The resulting schema will have `type: :object` set automatically.
- The `title` of the schema is set as the last segment of the module name.
This provides a more concise way to define simple object schemas.
defmodule MyApp.UserKW do
use JSV.Schema
defschema name: string(default: ""),
age: integer(default: 123)
end
iex> %MyApp.UserKW{}
%MyApp.UserKW{name: "", age: 123}
### Additional properties
Additional properties are allowed by default.
If your schema does not define `additionalProperties: false`, the validation
will accept a map with additional properties, but the keys will not be added
to the resulting struct as it would make an invalid struct.
iex> {:ok, root} = JSV.build(MyApp.UserSchema)
iex> data = %{"name" => "Alice", "extra" => "hello!"}
iex> JSV.validate(data, root)
{:ok, %MyApp.UserSchema{name: "Alice", age: 123}}
If the `cast: false` option is given to `JSV.validate/3`, structs will not be
created. In that case, the additional properties will be kept.
iex> {:ok, root} = JSV.build(MyApp.UserSchema)
iex> data = %{"name" => "Alice", "extra" => "hello!"}
iex> JSV.validate(data, root, cast: false)
{:ok, %{"name" => "Alice", "extra" => "hello!"}}
It is also possible to collect additional properties in a new struct key by
defining the `@additional_properties` attribute above the `defschema`
expression. This property will have a default value of `%{}` (the empty map).
defmodule MyApp.UserSchemaWithAdds do
import JSV
@additional_properties :adds
defschema %{
type: :object,
properties: %{
name: %{type: :string, default: ""},
age: %{type: :integer, default: 123}
}
}
end
iex> {:ok, root} = JSV.build(MyApp.UserSchemaWithAdds)
iex> data = %{"name" => "Alice", "extra" => "hello!"}
iex> JSV.validate(data, root)
{:ok, %MyApp.UserSchemaWithAdds{name: "Alice", age: 123, adds: %{"extra" => "hello!"}}}
### Ignoring struct keys
Some keys can be defined in the schema but not included in the struct by using
the `@def` module attribute. This is helpful when a property uses
`const` but your code rather depends on the struct type.
Keys listed in `@skip_keys` will still be validated according to the schema!
defmodule MyApp.UserEvent do
use JSV.Schema
@skip_keys [:message_type]
defschema message_type: const("user_event"),
user_id: integer(),
event: string()
end
iex> {:ok, root} = JSV.build(MyApp.UserEvent)
iex> data = %{"message_type" => "user_event", "user_id" => 123, "event" => "login"}
iex> {:ok, result} = JSV.validate(data, root)
iex> result
%MyApp.UserEvent{user_id: 123, event: "login"}
### Module references
A module can reference another module in its properties.
defmodule MyApp.CompanySchema do
import JSV
defschema %{
type: :object,
properties: %{
name: %{type: :string},
owner: MyApp.UserSchema
}
}
end
iex> root = JSV.build!(MyApp.CompanySchema)
iex> data = %{"name" => "Schemas Inc.", "owner" => %{"name" => "Alice", "age" => 999}}
iex> JSV.validate(data, root)
{:ok, %MyApp.CompanySchema{
name: "Schemas Inc.",
owner: %MyApp.UserSchema{
name: "Alice",
age: 999
}
}}
"""
@doc group: @doc_group
defmacro defschema(schema_or_properties) do
quote bind_quoted: [schema_or_properties: schema_or_properties] do
# TODO serialization skips is not supported for plain defschema modules
# since we do not have automatic JSON encoder defimpl.
{schema, _serialization_skips} = JSV.__defschema__(:to_schema, {schema_or_properties, __MODULE__, nil})
skip_keys_set = Map.new(Module.get_attribute(__MODULE__, :skip_keys, []), &{&1, true})
@additional_properties_key JSV.__defschema__(
:validate_additional_properties,
Module.get_attribute(__MODULE__, :additional_properties, nil)
)
@jsv_keycast JSV.__defschema__(:keycast, {schema, skip_keys_set})
@enforce_keys JSV.__defschema__(:required, {schema, skip_keys_set})
@legacy_jsv_tag 0
@jsv_schema JSV.Schema.xcast(schema, Atom.to_string(__MODULE__))
def json_schema do
@jsv_schema
end
defstruct JSV.__defschema__(:struct_keys, {schema, skip_keys_set, @additional_properties_key})
@deprecated "use #{inspect(__MODULE__)}.json_schema/0 instead"
@doc false
def schema do
IO.warn(
"the #{inspect(__MODULE__)}.schema/0 is deprecated and will not be automatically defined in future versions, " <>
" use #{inspect(__MODULE__)}.json_schema/0 instead"
)
json_schema()
end
@doc false
def __jsv__({:cast, [], _raw_schema}, builder) do
{{__MODULE__, :__jsv_struct__, 1}, builder}
end
def __jsv__({:cast, [@legacy_jsv_tag], _raw_schema}, builder) do
{{__MODULE__, :__jsv_struct__, 1}, builder}
end
@doc false
def __jsv__(:required) do
@enforce_keys
end
@doc false
def __jsv_struct__(data) do
pairs = JSV.StructSupport.take_keycast(data, @jsv_keycast, @additional_properties_key)
{:ok, struct!(__MODULE__, pairs)}
end
defoverridable schema: 0
end
end
@doc """
Defines a new module with a JSON Schema struct.
This macro is similar to `defschema/1` but it also takes a module name and
defines a nested module in the context where it is called. An optional
description can be given, used as the `@moduledoc` and the description when a
keyword list of properties is given.
The module's struct will automatically `@derive` `Jason.Encoder` and
`JSON.Encoder` if those modules are found during compilation.
### Title and Description Behavior
When passing properties as a keyword list instead of a schema, the `title` and
`description` parameters are automatically applied to the generated schema:
- `title` is set from the module name (without outer module prefix if any)
- `description` is set from the description parameter
When passing a full schema map, the title and description from the parameters
are not applied - the schema map is used as-is. Only the `description`
parameter is used as the module's `@moduledoc`.
### Examples
Basic module definition with keyword list:
defschema User,
name: string(),
age: integer(default: 0)
Module with description using keyword list:
defschema User,
"A user in the system",
name: string(),
age: integer(default: 0)
Module with full schema map:
defschema User,
"User schema",
%{
type: :object,
title: "Custom Title",
description: "Custom Desc",
properties: %{
name: %{type: :string},
age: %{type: :integer, default: 18}
},
required: [:name]
}
## Usage
The created module can be used like any struct:
%User{name: "Alice", age: 25}
And as a JSON Schema for validation:
{:ok, root} = JSV.build(User)
JSV.validate(%{"name" => "Bob"}, root)
#=> {:ok, %User{name: "Bob", age: 0}}
## Module References
Modules can reference other modules in their properties:
defschema Address,
street: string(),
city: string()
defschema User,
name: string(),
address: Address
Use `__MODULE__` for self-references:
defschema Category,
name: string(),
parent: optional(__MODULE__)
## Inherited Module Attributes
This macro reads `@skip_keys` and `@additional_properties` from the caller
module and applies them to the generated nested module.
Those attributes are consumed from the caller when `defschema/3` is expanded.
If you define multiple schemas with `defschema/3` in the same parent module,
you must redeclare those attributes before each schema that needs them.
defmodule Parent do
use JSV.Schema
@skip_keys [:kind]
@additional_properties :ext
defschema User,
name: string(),
kind: const("user")
# Attributes above were consumed by the previous defschema/3 call.
# Redeclare them if they should apply to this schema too.
@skip_keys [:kind]
@additional_properties :ext
defschema Team,
name: string(),
kind: const("team")
end
"""
@doc group: @doc_group
defmacro defschema(module, description \\ nil, schema_or_properties) do
# not giving the caller env so we do not expand the module name to its FQMN
module_name = inspect(Macro.expand_literals(module, __ENV__))
json_encoder = derive_json_encoder()
jason_encoder = derive_jason_encoder()
quoted =
quote do
inherit_attr_skip_keys =
if __MODULE__ do
Module.delete_attribute(__MODULE__, :skip_keys) || []
else
[]
end
inherit_attr_additional_properties =
if __MODULE__ do
Module.delete_attribute(__MODULE__, :additional_properties)
else
nil
end
defmodule unquote(module) do
use JSV.Schema
schema_or_properties = unquote(schema_or_properties)
description = unquote(description)
@skip_keys inherit_attr_skip_keys
@additional_properties inherit_attr_additional_properties
@moduledoc description
{schema, serialization_skips} =
JSV.__defschema__(:to_schema, {schema_or_properties, unquote(module_name), description})
unquote(json_encoder)
unquote(jason_encoder)
defschema schema
end
end
# I'm not sure why ElixirLS points to this macro's line when using
# go-to-definition on defined modules. This does not seem to solve it.
Macro.update_meta(quoted, &Keyword.put(&1, :line, __CALLER__.line))
end
defp derive_json_encoder do
quote do
if Code.ensure_loaded?(JSON.Encoder) do
case serialization_skips do
nil ->
@derive JSON.Encoder
m when map_size(m) == 0 ->
@derive JSON.Encoder
skips when is_map(skips) ->
defimpl JSON.Encoder do
@serialization_skips skips
def encode(%mod{} = struct, encoder) do
value = JSV.__json_norm_skip__(struct, @serialization_skips)
encoder.(value, encoder)
end
end
end
end
end
end
defp derive_jason_encoder do
quote do
if Code.ensure_loaded?(Jason.Encoder) do
case serialization_skips do
nil ->
@derive Jason.Encoder
m when map_size(m) == 0 ->
@derive Jason.Encoder
skips when is_map(skips) ->
defimpl Jason.Encoder do
@serialization_skips skips
def encode(%mod{} = struct, opts) do
value = JSV.__json_norm_skip__(struct, @serialization_skips)
Jason.Encode.map(value, opts)
end
end
end
end
end
end
@doc false
defmacro defschema_for(target, schema) do
quote bind_quoted: binding() do
:ok = JSV.StructSupport.validate!(schema)
@target target
@jsv_keycast JSV.StructSupport.keycast_pairs(schema, target)
{_keys_no_defaults, default_pairs} = JSV.StructSupport.data_pairs_partition(schema)
@default_pairs default_pairs
@legacy_jsv_tag 1
@jsv_schema schema
|> Map.put(:"x-jsv-cast", Atom.to_string(__MODULE__))
|> Map.put_new(:"$id", Internal.module_to_uri(__MODULE__))
def json_schema do
@jsv_schema
end
@deprecated "use #{inspect(__MODULE__)}.json_schema/0 instead"
@doc false
def schema do
IO.warn(
"the #{inspect(__MODULE__)}.schema/0 is deprecated and will not be automatically defined in future versions, " <>
" use #{inspect(__MODULE__)}.json_schema/0 instead"
)
json_schema()
end
@doc false
def __jsv__({:cast, [], _raw_schema}, builder) do
{{__MODULE__, :__jsv_struct__, 1}, builder}
end
def __jsv__({:cast, [@legacy_jsv_tag], _raw_schema}, builder) do
{{__MODULE__, :__jsv_struct__, 1}, builder}
end
def __jsv_struct__(data) do
pairs = JSV.StructSupport.take_keycast(data, @jsv_keycast)
pairs = Keyword.merge(@default_pairs, pairs)
{:ok, struct!(@target, pairs)}
end
defoverridable json_schema: 0, schema: 0
end
end
@doc false
@spec __defschema__(atom, tuple) :: term
def __defschema__(:to_schema, {schema_or_properties, module_or_name, description}) do
{schema, serialization_skips} =
if is_list(schema_or_properties) do
props = schema_or_properties
title =
case module_or_name do
mod when is_atom(mod) -> List.last(Module.split(mod))
bin when is_binary(bin) -> bin
end
overrides =
case description do
nil -> %{title: title}
d when is_binary(d) -> %{title: title, description: d}
end
schema = JSV.StructSupport.props_to_schema(props, overrides)
serialization_skips = JSV.StructSupport.serialization_skips(props)
{schema, serialization_skips}
else
{schema_or_properties, _serialization_skips = nil}
end
:ok = JSV.StructSupport.validate!(schema)
{schema, serialization_skips}
end
def __defschema__(:struct_keys, {schema, skip_keys_set, additional_properties_key}) do
{keys_no_defaults, default_pairs} = JSV.StructSupport.data_pairs_partition(schema)
default_pairs =
case additional_properties_key do
nil -> default_pairs
k when is_atom(k) -> [{k, %{}} | default_pairs]
end
Enum.filter(keys_no_defaults ++ default_pairs, fn
{k, _} -> not is_map_key(skip_keys_set, k)
k -> not is_map_key(skip_keys_set, k)
end)
end
def __defschema__(:keycast, {schema, skip_keys_set}) do
Map.filter(JSV.StructSupport.keycast_pairs(schema), fn {_bin, k} ->
not is_map_key(skip_keys_set, k)
end)
end
def __defschema__(:required, {schema, skip_keys_set}) do
schema
|> JSV.StructSupport.list_required()
|> Enum.reject(&is_map_key(skip_keys_set, &1))
end
def __defschema__(:validate_additional_properties, key) do
case key do
nil -> nil
k when is_atom(k) -> k
other -> raise "invalid @additional_properties key, atom expected, got: #{inspect(other)}"
end
end
@doc false
@spec __json_norm_skip__(struct(), map()) :: map()
def __json_norm_skip__(struct, serialization_skips) do
struct
|> Map.from_struct()
|> Enum.flat_map(fn
{k, v} when :erlang.map_get(k, serialization_skips) == v -> []
{k, v} -> [{k, v}]
end)
|> Map.new()
end
@doc false
defguard is_valid_tag(tag) when (is_integer(tag) and tag >= 0) or is_binary(tag)
@doc """
Enables a casting function in the current module, identified by its function
name.
### Example
```elixir
defmodule MyApp.Cast do
use JSV.Schema
defcast :to_integer
def to_integer(data) when is_binary(data) do
case Integer.parse(data) do
{int, ""} -> {:ok, int}
_ -> {:error, "invalid"}
end
end
def to_integer(_) do
{:error, "invalid"}
end
end
```
iex> schema = JSV.Schema.Helpers.string() |> JSV.Schema.xcast(["Elixir.MyApp.Cast", "to_integer"])
iex> root = JSV.build!(schema)
iex> JSV.validate("1234", root)
{:ok, 1234}
See `defcast/3` for more information.
"""
@doc group: @doc_group
defmacro defcast(local_fun) when is_atom(local_fun) do
defcast_local(__CALLER__, Atom.to_string(local_fun), local_fun)
end
defmacro defcast(_) do
bad_cast()
end
@doc """
Enables a casting function in the current module, identified by a custom tag.
### Example
```elixir
defmodule MyApp.Cast do
use JSV.Schema
defcast "to_integer_if_string", :to_integer
defp to_integer(data) when is_binary(data) do
case Integer.parse(data) do
{int, ""} -> {:ok, int}
_ -> {:error, "invalid"}
end
end
defp to_integer(_) do
{:error, "invalid"}
end
end
```
iex> schema = JSV.Schema.Helpers.string() |> JSV.Schema.xcast(["Elixir.MyApp.Cast", "to_integer_if_string"])
iex> root = JSV.build!(schema)
iex> JSV.validate("1234", root)
{:ok, 1234}
See `defcast/3` for more information.
"""
@doc group: @doc_group
defmacro defcast(tag, local_fun) when is_atom(local_fun) and is_valid_tag(tag) do
defcast_local(__CALLER__, tag, local_fun)
end
defmacro defcast({_, _, _} = call, [{:do, _} | _] = blocks) do
{fun, _} = Macro.decompose_call(call)
tag = Atom.to_string(fun)
defcast_block(__CALLER__, tag, call, blocks)
end
defmacro defcast(_, _) do
bad_cast()
end
@doc """
Defines a casting function in the calling module, and enables it for casting
data during validation.
See the [custom cast functions guide](cast-functions.html) to learn more about
defining your own cast functions.
This documentation assumes the following module is defined. Note that
`JSV.Schema` provides several [predefined cast
functions](JSV.Schema.html#schema-casters), including an [existing atom
cast](JSV.Schema.html#string_to_existing_atom/0).
```elixir
defmodule MyApp.Cast do
use JSV.Schema
defcast to_existing_atom(data) do
{:ok, String.to_existing_atom(data)}
rescue
ArgumentError -> {:error, "bad atom"}
end
end
```
This macro will define the `to_existing_atom/1` function in the calling
module, and enable it to be referenced in the `x-jsv-cast` schema custom
keyword.
iex> MyApp.Cast.to_existing_atom("erlang")
{:ok, :erlang}
iex> MyApp.Cast.to_existing_atom("not an existing atom")
{:error, "bad atom"}
It will also define a zero arity function to get the cast information ready to
be included in a schema:
iex> MyApp.Cast.to_existing_atom()
["Elixir.MyApp.Cast", "to_existing_atom"]
This is accepted by `JSV.Schema.xcast/2` to include in the cast list:
iex> JSV.Schema.xcast(MyApp.Cast.to_existing_atom())
%{"x-jsv-cast": [["Elixir.MyApp.Cast", "to_existing_atom"]]}
With a `x-jsv-cast` property defined in a schema, data will be cast when the
schema is validated:
iex> schema = JSV.Schema.Helpers.string() |> JSV.Schema.xcast(MyApp.Cast.to_existing_atom())
iex> root = JSV.build!(schema)
iex> JSV.validate("noreply", root)
{:ok, :noreply}
iex> schema = JSV.Schema.Helpers.string() |> JSV.Schema.xcast(MyApp.Cast.to_existing_atom())
iex> root = JSV.build!(schema)
iex> {:error, %JSV.ValidationError{}} = JSV.validate(["Elixir.NonExisting"], root)
It is not mandatory to use the schema definition helpers. Raw schemas can
contain cast pointers too:
iex> schema = %{
...> "type" => "string",
...> "x-jsv-cast" => [["Elixir.MyApp.Cast", "to_existing_atom"]]
...> }
iex> root = JSV.build!(schema)
iex> JSV.validate("noreply", root)
{:ok, :noreply}
Note that for security reasons the cast pointer does not allow to call any
function from the schema definition. A cast function MUST be enabled by
`defcast/1`, `defcast/2` or `defcast/3`.
If the `MyApp.Cast` example module defines a `non_cast_function/1` function
like so:
```elixir
defmodule MyApp.Cast do
use JSV.Schema
defcast to_existing_atom(data) do
{:ok, String.to_existing_atom(data)}
rescue
ArgumentError -> {:error, "bad atom"}
end
def non_cast_function(data) do
{:ok, data}
end
end
```
The following schema will fail to build:
iex> schema = %{
...> "type" => "string",
...> "x-jsv-cast" => [["Elixir.MyApp.Cast", "non_cast_function"]]
...> }
iex> {:error, _} = JSV.build(schema)
Using unknown module will fail too:
iex> schema = %{
...> "type" => "string",
...> "x-jsv-cast" => [["Elixir.SomeUnknownModule", "some_fun"]]
...> }
iex> {:error, build_error} = JSV.build(schema)
iex> build_error.reason
{:unknown_module, "Elixir.SomeUnknownModule"}
Finally, you can customize the name present in the `x-jsv-cast` property by
using a custom tag:
```elixir
defcast "my_custom_tag", a_function_name(data) do
# ...
end
```
Make sure to read the [custom cast functions guide](cast-functions.html)!
"""
@doc group: @doc_group
defmacro defcast(tag, fun, block)
defmacro defcast(tag, {_, _, _} = call, blocks) when is_valid_tag(tag) do
defcast_block(__CALLER__, tag, call, blocks)
end
defmacro defcast(_, _, _) do
bad_cast()
end
@doc false
defmacro defcast_module(cast_alias) when is_binary(cast_alias) when :skip == cast_alias do
Module.register_attribute(__CALLER__.module, :jsv_casts, accumulate: true)
Module.put_attribute(__CALLER__.module, :jsv_defcast_module, cast_alias)
:ok
quote do
@before_compile {unquote(__MODULE__), :publish_casts}
end
end
defp defcast_block(env, tag, call, [{:do, _} | _] = blocks) do
cast_prefix =
case Module.get_attribute(env.module, :jsv_defcast_module) do
nil ->
Atom.to_string(env.module)
:skip ->
Module.put_attribute(env.module, :jsv_casts, {:keep, tag})
:skip
mod_alias when is_binary(mod_alias) ->
Module.put_attribute(env.module, :jsv_casts, {:discard, tag})
mod_alias
end
{fun, args} = defcast_decompose(call)
handler_arity = length(args)
helper = defcast_helper(fun, handler_arity, cast_prefix, tag)
quote generated: true do
unquote(helper)
@doc false
def __jsv__({:cast, [unquote(tag) | rest_args], _raw_schema}, builder) do
{{__MODULE__, unquote(fun), unquote(handler_arity), rest_args}, builder}
end
@doc false
def(unquote(fun)(unquote_splicing(args)), unquote(blocks))
end
end
defp defcast_helper(fun, handler_arity, cast_prefix, tag) do
case {handler_arity, cast_prefix} do
{1, :skip} ->
quote do
def unquote(fun)() do
unquote(tag)
end
end
{_, :skip} ->
quote do
def unquote(fun)(args) when is_list(args) do
[unquote(tag) | args]
end
end
{1, _} ->
quote do
def unquote(fun)() do
[unquote(cast_prefix), unquote(tag)]
end
end
{_, _} ->
quote do
def unquote(fun)(args) when is_list(args) do
[unquote(cast_prefix), unquote(tag) | args]
end
end
end
end
defp defcast_decompose(call) do
case Macro.decompose_call(call) do
{:when, [{err_tag, _, _} | _]} ->
raise ArgumentError, """
defcast does not support guards
You may delegate to a local function like so:
defcast #{inspect(Atom.to_string(err_tag))} :my_custom_cast_fun
defp #{Macro.to_string(call)} do
# ...
end
"""
{fun, [_data] = args} ->
{fun, args}
{fun, [_data, _args] = args} ->
{fun, args}
{fun, [_data, _args, _vctx] = args} ->
{fun, args}
_ ->
raise ArgumentError, "invalid defcast signature: #{Macro.to_string(call)}"
end
end
defp defcast_local(_env, tag, local_fun) do
quote do
@doc false
def __jsv__({:cast, [unquote(tag) | rest_args], _raw_schema}, builder) do
{{__MODULE__, unquote(local_fun), nil, rest_args}, builder}
end
end
end
@spec bad_cast :: no_return()
defp bad_cast do
raise ArgumentError, "invalid defcast arguments"
end
@doc false
defmacro publish_casts(env) do
casts = Module.get_attribute(env.module, :jsv_casts)
quote do
def __jsv__(:casts) do
unquote(casts)
end
end
end
# ---------------------------------------------------------------------------
# Custom Build API
# ---------------------------------------------------------------------------
@doc_group "Custom Build API"
@doc """
Initializes a build context for controlled builds.
See `build/2` for options.
"""
@spec build_init!([build_opt]) :: build_context()
@doc group: @doc_group
debang def build_init!(opts \\ [])
def build_init!(opts) do
opts = OptsValidator.validate(opts, @default_build_options, &validate_build_opts/2)
resolver = make_resolver(opts)
opts = Map.put(opts, :resolver, resolver)
builder = Builder.new(opts)
build_ctx(builder: builder)
end
defp validate_build_opts(:resolver, value) do
list = List.wrap(value)
Enum.each(list, fn
atom when is_atom(atom) -> []
{atom, _} when is_atom(atom) -> []
_ -> OptsValidator.invalid_option!(:resolver, value, "a module, {module, arg} or a list of such elements")
end)
list
end
defp validate_build_opts(:default_meta, value) when is_binary(value) do
value
end
defp validate_build_opts(:default_meta, other) do
OptsValidator.invalid_option!(:default_meta, other, "a meta-schema URI (as string)")
end
defp validate_build_opts(:formats, value) when value in [true, false, nil, :default] do
value
end
defp validate_build_opts(:formats, value) when is_list(value) do
Enum.each(value, fn
mod when is_atom(mod) ->
[]
_ ->
OptsValidator.invalid_option!(:formats, value, "a boolean or a list of modules")
end)
value
end
defp validate_build_opts(:formats, value) do
OptsValidator.invalid_option!(:formats, value, "a boolean or a list of modules")
end
defp validate_build_opts(:atoms, value) do
OptsValidator.validate_boolean(:atoms, value)
end
defp validate_build_opts(:vocabularies, value) when is_map(value) do
Enum.each(value, fn
{k, v} when is_binary(k) and is_atom(v) ->
[]
{k, {m, _}} when is_binary(k) and is_atom(m) ->
[]
_ ->
OptsValidator.invalid_option!(:vocabularies, value, "a map of %{URI (as string) => module | {module, arg}")
end)
value
end
defp validate_build_opts(:vocabularies, value) do
OptsValidator.invalid_option!(:vocabularies, value, "a map of %{URI (as string) => module | {module, arg}")
end
defp validate_build_opts(:warnings, value) when value in [:emit, :silent] do
value
end
defp validate_build_opts(:warnings, other) do
OptsValidator.invalid_option!(:warnings, other, ":emit or :silent")
end
defp validate_build_opts(key, _value) do
OptsValidator.unknown_option!(key)
end
@doc "Adds a schema to the build context."
@doc group: @doc_group
@spec build_add!(build_context(), native_schema()) :: {Key.t(), normal_schema(), build_context()}
debang def build_add!(build_ctx, raw_schema)
def build_add!(build_ctx(builder: builder) = ctx, raw_schema) do
raw_schema = ensure_map_schema(raw_schema)
normal_schema = Schema.normalize(raw_schema)
key = schema_to_key(normal_schema)
builder = Builder.add_schema!(builder, key, normal_schema)
{key, normal_schema, build_ctx(ctx, builder: builder)}
end
@doc """
Builds the given reference or root schema.
Returns the build context as well as a key, which is a pointer to the built
schema.
The `ref_or_ns` argument can be:
- `:root` - the root schema added by `build_add/2` when it had no `$id`.
- A `JSV.Ref` struct - as returned by `Ref.parse!/2`.
- A binary string - a schema namespace (the value of a top-level `$id`), such
as `"https://example.com/my-schema"`. This does **not** accept fragment
strings like `"#/some/path"` or `"#anchor"`.
To target a subschema by JSON pointer or anchor, parse the string first:
# JSON pointer relative to :root
Ref.parse!("#/some/path", :root)
# anchor relative to :root
Ref.parse!("#myanchor", :root)
# anchor under a URI namespace
Ref.parse!("#myanchor", "https://example.com/schema")
"""
@doc group: @doc_group
@spec build_key!(build_context(), Ref.ns() | Ref.t()) :: {Key.t(), build_context()}
debang def build_key!(build_ctx, ref_or_ns)
def build_key!(build_ctx(builder: builder, validators: vds) = ctx, ref_or_ns)
when ref_or_ns == :root
when is_binary(ref_or_ns)
when is_struct(ref_or_ns, Ref) do
key = Key.of(ref_or_ns)
{new_vds, builder} = Builder.build!(builder, ref_or_ns, vds)
maybe_emit_warnings(builder)
{key, build_ctx(ctx, builder: builder, validators: new_vds)}
end
defp maybe_emit_warnings(%{warnings: []}) do
:ok
end
defp maybe_emit_warnings(%{warnings: warnings} = builder) do
case builder.opts.warnings do
:emit ->
stacktrace = warning_stacktrace()
Enum.each(warnings, fn w -> emit_warning(w, stacktrace) end)
:silent ->
:ok
end
end
defp warning_stacktrace do
{:current_stacktrace, stacktrace} = :erlang.process_info(self(), :current_stacktrace)
Enum.drop(stacktrace, 2)
end
defp emit_warning(warning, stacktrace) do
%{key: _key, message: message, rev_path: rev_path} = warning
path = JSV.ErrorFormatter.format_schema_path(rev_path)
IO.warn(
"""
#{message}
Warning emitted at #{path}.
Use `JSV.build(schema, warnings: :silent)` to silence all warnings.
""",
stacktrace
)
end
@doc """
Returns a root with all the validators from the build context and the given
`root_key`. That key is used as the default entrypoint for validation when no
`:key` option is passed to `validate/2`.
"""
@doc group: @doc_group
@spec to_root!(build_context, Key.t()) :: Root.t()
debang def to_root!(build_ctx, root_key)
def to_root!(build_ctx(builder: builder, validators: vds), root_key) do
%Root{
raw: nil,
validators: vds,
root_key: root_key,
has_casts: builder.has_casts,
has_unevaluated: builder.has_unevaluated,
warnings: :lists.reverse(builder.warnings)
}
end
defp ensure_map_schema(map) when is_map(map) do
map
end
defp ensure_map_schema(module) when is_atom(module) do
JSV.Schema.from_module(module)
end
defp schema_to_key(raw_schema) do
case Map.get(raw_schema, "$id", :root) do
root_ns when is_binary(root_ns) or :root == root_ns -> ^root_ns = Key.of(root_ns)
other -> raise BuildError.of({:invalid_id, other}, {__MODULE__, :build, 2}, "#")
end
end
defp make_resolver(opts) do
opts.resolver
|> resolver_chain()
|> Resolver.chain_of(opts.default_meta)
end
end