Packages
jsv
0.5.1
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.Builder
alias JSV.ErrorFormatter
alias JSV.Resolver.Internal
alias JSV.Root
alias JSV.ValidationError
alias JSV.Validator
alias JSV.Validator.ValidationContext
readme =
"README.md"
|> File.read!()
|> String.split("<!-- moduledoc-split -->")
|> tl()
@moduledoc """
This is the main API for the JSV library.
#{readme}
"""
@type raw_schema :: map() | boolean() | module()
@default_default_meta "https://json-schema.org/draft/2020-12/schema"
@build_opts_schema NimbleOptions.new!(
resolver: [
type: {:or, [:atom, :mod_arg, {:list, {:or, [:atom, :mod_arg]}}]},
default: [],
doc: """
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.
"""
],
default_meta: [
type: :string,
doc:
~S(The meta schema to use for resolved schemas that do not define a `"$schema"` property.),
default: @default_default_meta
],
formats: [
type: {:or, [:boolean, nil, {:list, :atom}]},
doc: """
Controls the validation of strings with the `"format"` keyword.
* `nil` - Formats are validated according to the meta-schema vocabulary.
* `true` - Enforces validation with the default validator modules.
* `false` - Disables all format validation.
* `[Module1, Module2,...]` – set those modules as validators. Disables the default format validator modules.
The default validators can be included back in the list manually, see `default_format_validator_modules/0`.
> #### Formats are disabled by the default meta-schemas {: .warning}
>
> The default value for this option is `nil` to respect
> the capability of enably validation with vocabularies.
>
> But 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 to provide your own modules.
""",
default: nil
]
)
@doc """
Builds the schema as a `#{inspect(Root)}` schema for validation.
### Options
#{NimbleOptions.docs(@build_opts_schema)}
"""
@spec build(JSV.raw_schema(), keyword) :: {:ok, Root.t()} | {:error, Exception.t()}
def build(raw_schema, opts \\ [])
def build(raw_schema, opts) when is_map(raw_schema) when is_atom(raw_schema) do
case NimbleOptions.validate(opts, @build_opts_schema) do
{:ok, opts} ->
builder =
opts
|> build_resolvers()
|> Builder.new()
case Builder.build(builder, raw_schema) do
{:ok, root} -> {:ok, root}
{:error, reason} -> {:error, %JSV.BuildError{reason: reason}}
end
{:error, _} = err ->
err
end
end
defp build_resolvers(opts) do
{resolvers, opts} = Keyword.pop!(opts, :resolver)
resolvers = List.wrap(resolvers)
extra = [JSV.Resolver.Internal, JSV.Resolver.Embedded] -- resolvers
resolvers =
Enum.map(resolvers ++ extra, fn
{module, res_opts} -> {module, res_opts}
module -> {module, []}
end)
Keyword.put(opts, :resolvers, resolvers)
end
@doc """
Same as `build/2` but raises on error.
"""
@spec build!(JSV.raw_schema(), keyword) :: Root.t()
def build!(raw_schema, opts \\ [])
def build!(raw_schema, opts) do
case build(raw_schema, opts) do
{:ok, root} -> root
{:error, reason} -> raise reason
end
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)}.
"""
@spec default_meta :: binary
def default_meta do
@default_default_meta
end
@validate_opts_schema NimbleOptions.new!(
cast_formats: [
type: :boolean,
default: false,
doc:
"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."
],
cast_structs: [
type: :boolean,
default: true,
doc:
"When enabled, schemas defining the jsv-struct keyword " <>
"will be casted to the corresponding module. " <>
"This keyword is automatically set by schemas used in `JSV.defschema/1`."
]
)
@doc """
Validates and casts the data with the given schema. The schema must be a
`JSV.Root` struct generated with `build/2`.
**Important**: this function returns casted data:
* If the `:cast_formats` option is enabled, string values may be transformed
in other data structures. Refer to the "Formats" section of the `JSV`
documentation 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.
* Future versions of the library will allow to cast raw data into Elixir
structs.
### Options
#{NimbleOptions.docs(@validate_opts_schema)}
"""
@spec validate(term, JSV.Root.t(), keyword) :: {:ok, term} | {:error, Exception.t()}
def validate(data, root, opts \\ [])
def validate(data, %JSV.Root{} = root, opts) do
case NimbleOptions.validate(opts, @validate_opts_schema) do
{:ok, opts} ->
case validation_entrypoint(root, data, opts) do
{:ok, casted_data, _} -> {:ok, casted_data}
{:error, %ValidationContext{} = validator} -> {:error, Validator.to_error(validator)}
end
{:error, _} = err ->
err
end
end
@spec normalize_error(ValidationError.t() | Validator.context() | [Validator.Error.t()]) :: map()
def normalize_error(%ValidationError{} = error) do
ErrorFormatter.normalize_error(error)
end
def normalize_error(errors) when is_list(errors) do
normalize_error(ValidationError.of(errors))
end
# TODO provide a way to return ordered json for errors, or just provide a
# preprocess function.
def normalize_error(%ValidationContext{} = validator) do
normalize_error(Validator.to_error(validator))
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
root_schema_validators = Map.fetch!(validators, root_key)
context = JSV.Validator.context(validators, _scope = [root_key], opts)
JSV.Validator.validate(data, root_schema_validators, context)
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`.
"""
@spec default_format_validator_modules :: [module]
def default_format_validator_modules do
[JSV.FormatValidator.Default]
end
@doc """
Defines a struct in the calling module where the struct keys are the
properties of the schema.
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.
The `$id` property of the schema will automatically be set, if not present, to
`"jsv:module:" <> Atom.to_string(__MODULE__)`. Because of this, module based
schemas must avoid using relative references to a parent schema as the
references will resolve to that generated `$id`.
### Additional properties
Additional properties are allowed.
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 be invalid.
If the `cast_structs: false` option is given to `JSV.validate/3`, the
additional properties will be kept.
### Example
Given the following module definition:
defmodule MyApp.UserSchema do
require JSV
JSV.defschema(%{
type: :object,
properties: %{
name: %{type: :string, default: ""},
age: %{type: :integer, default: 0}
}
})
end
We can get the struct with default values:
iex> %MyApp.UserSchema{}
%MyApp.UserSchema{name: "", age: 0}
And we can use the module as a schema:
iex> {:ok, root} = JSV.build(MyApp.UserSchema)
iex> data = %{"name" => "Alice"}
iex> JSV.validate(data, root)
{:ok, %MyApp.UserSchema{name: "Alice", age: 0}}
Additional properties are ignored:
iex> {:ok, root} = JSV.build(MyApp.UserSchema)
iex> data = %{"name" => "Alice", "extra" => "hello!"}
iex> JSV.validate(data, root)
{:ok, %MyApp.UserSchema{name: "Alice", age: 0}}
Disabling struct casting with additional properties:
iex> {:ok, root} = JSV.build(MyApp.UserSchema)
iex> data = %{"name" => "Alice", "extra" => "hello!"}
iex> JSV.validate(data, root, cast_structs: false)
{:ok, %{"name" => "Alice", "extra" => "hello!"}}
A module can reference another module:
defmodule MyApp.CompanySchema do
require JSV
JSV.defschema(%{
type: :object,
properties: %{
name: %{type: :string},
owner: MyApp.UserSchema
}
})
end
iex> {:ok, root} = JSV.build(MyApp.CompanySchema)
iex> data = %{"name" => "Schemas Inc.", "owner" => %{"name" => "Alice"}}
iex> JSV.validate(data, root)
{:ok, %MyApp.CompanySchema{name: "Schemas Inc.", owner: %MyApp.UserSchema{name: "Alice", age: 0}}}
"""
defmacro defschema(schema) do
quote bind_quoted: binding() do
:ok = JSV.StructSupport.validate!(schema)
keycast_pairs = JSV.StructSupport.keycast_pairs(schema)
{keys_no_defaults, default_pairs} = JSV.StructSupport.data_pairs_partition(schema)
required = JSV.StructSupport.list_required(schema)
# It is important to set the jsv-struct as a binary, otherwise it would be
# turned into a $ref when the schema will be denormalized by the resolver.
#
# Also we set those keys as atoms because the rest of the schema has to be
# defined with atoms and we do not want to mix key types at this point.
@jsv_schema schema
|> Map.put(:"jsv-struct", Atom.to_string(__MODULE__))
|> Map.put_new(:"$id", Internal.module_to_uri(__MODULE__))
@enforce_keys required
defstruct keys_no_defaults ++ default_pairs
def schema do
@jsv_schema
end
@doc false
def __jsv__(arg)
def __jsv__(:keycast) do
unquote(keycast_pairs)
end
def __jsv__(:defaults_override) do
# No need to return defaults as defaults values are handled by the
# __struct__ functions. So we can return an empty list of pairs.
[]
end
end
end
# TODO document defschema_for
@doc false
defmacro defschema_for(target, schema) do
quote bind_quoted: binding() do
:ok = JSV.StructSupport.validate!(schema)
keycast_pairs = JSV.StructSupport.keycast_pairs(schema, target)
{_keys_no_defaults, default_pairs} = JSV.StructSupport.data_pairs_partition(schema)
# When defining a schema for another struct we will add two internal
# keywords. The $id is still derived from the schema module as we may want
# to define multiple schemas targetting a common struct.
@jsv_schema schema
|> Map.put(:"jsv-source", Atom.to_string(__MODULE__))
|> Map.put(:"jsv-struct", Atom.to_string(target))
|> Map.put_new(:"$id", Internal.module_to_uri(__MODULE__))
def schema do
@jsv_schema
end
@doc false
def __jsv__(arg)
def __jsv__(:keycast) do
unquote(keycast_pairs)
end
def __jsv__(:defaults_override) do
unquote(default_pairs)
end
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
{:ok, contents} = File.read(vsn_file)
String.split(contents, "\n", trim: true)
else
[full] -> full
_ -> major
catch
:error, _ -> major
end
end
end