Packages
jsv
0.11.3
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.
Retired package: Release invalid - invalid error handling
Current section
Files
Jump to
Current section
Files
lib/jsv/resolver/local.ex
defmodule JSV.Resolver.Local do
alias __MODULE__
@moduledoc """
This module allows to build `JSV.Resolver` implementations that resolves
schemas based on disk based on their `$id` property. It is not itself a
`JSV.Resolver` implementation.
To define your own local resolver, `use` this module by providing the
`:source` option with a path to a directory, a path to a file, or a list of
paths to directories and files:
```elixir
schemas_dir = "priv/messaging/schemas"
other_schema = "priv/users/user-schema.schema.json"
defmodule MyApp.LocalResolver do
use JSV.Resolver.Local, source: [schemas_dir, other_schema]
end
```
The macro will read all `.json` files from the given sources and build an
index mapping the `$id` property of each schema to its JSON-deserialized
value.
For convenience, nested lists are accepted. Duplicated files accepted as well
but not duplicates due to symlinks.
### Compilation and caching
Schemas are loaded directly into the generated module code. The module will
recompile everytime a loaded schema file is modified or deleted.
Recompilation will also happen when new files are added in directories listed
as sources.
### Debugging
The `use JSV.Resolver.Local` also accepts the following options:
* `:warn` - A `boolean` flag enabling compilation warnings when a `.json` file
cannot be read or loaded properly. Defaults to `true`.
* `:debug` - A `boolean` flag enabling printouts of the loaded schemas on
compilation. Defaults to `false`.
### Example
The `"priv/schemas/user-schema.schema.json"` file contains the following JSON
text:
```json
{
"$id": "myapp:user-0.0.1",
"type": "object",
"properties": {
"username": {
"type": "string"
}
}
}
```
Then this can be used as a source in your module.
```elixir
defmodule MyApp.LocalResolver do
use JSV.Resolver.Local, source: "priv/schemas"
end
```
You can now build validation roots (`JSV.Root`) by referencing this user
schema in `$ref` and by providing your resolver to the `JSV.build!/2`
function:
iex> schema = %{"$ref" => "myapp:user-0.0.1"}
iex> root = JSV.build!(schema, resolver: MyApp.LocalResolver)
iex> # Here we pass an invalid username (an integer)
iex> result = JSV.validate(%{"username" => 123}, root)
iex> match?({:error, %JSV.ValidationError{}}, result)
true
You can also directly fetch a schema from the defined module:
iex> MyApp.LocalResolver.resolve("myapp:user-0.0.1")
{:ok,
%{
"$id" => "myapp:user-0.0.1",
"type" => "object",
"properties" => %{"username" => %{"type" => "string"}}
}}
iex> MyApp.LocalResolver.resolve("myapp:other")
{:error, {:unknown_id, "myapp:other"}}
Remember that schemas are identified and resolved by their `$id` property and
not their path.
"""
defmacro __using__(opts) do
source_opt =
case Keyword.fetch(opts, :source) do
{:ok, q} -> q
:error -> raise ArgumentError, "the :source option is required when using #{inspect(__MODULE__)}"
end
warn? =
case Keyword.fetch(opts, :warn) do
{:ok, q} -> !!q
:error -> true
end
debug? =
case Keyword.fetch(opts, :debug) do
{:ok, q} -> !!q
:error -> false
end
quote bind_quoted: binding(), location: :keep do
{:current_stacktrace, [_ | warn_stack]} = Process.info(self(), :current_stacktrace)
cfg = %{stacktrace: warn_stack, warn?: warn?, debug?: debug?, module: __MODULE__}
expanded_sources = Local.collect_files(List.wrap(source_opt), cfg)
schemas_sources = Local.read_sources(expanded_sources, cfg)
@__jsv_resolver_compiled_source_opt source_opt
@__jsv_resolver_mtime_index Local.sources_fingerprint(expanded_sources)
@behaviour JSV.Resolver
# Not part of the behaviour implementation. This is added as a convenience
# for the users. So there is no need to return {:normal, _}.
def resolve(id) do
case resolve(id, []) do
{:normal, schema} -> {:ok, schema}
{:error, _} = err -> err
end
end
@impl true
def resolve(id, opts)
Enum.each(schemas_sources, fn {id, raw_schema} ->
def resolve(unquote(id), _opts) do
{:normal, unquote(Macro.escape(raw_schema))}
end
end)
def resolve(id, _opts) do
{:error, {:unknown_id, id}}
end
ids_list = Enum.map(schemas_sources, fn {id, _} -> id end)
def resolvable_ids do
unquote(ids_list)
end
def __mix_recompile__? do
cfg = %{stacktrace: [], warn?: false}
current_index =
@__jsv_resolver_compiled_source_opt
|> List.wrap()
|> Local.collect_files(cfg)
|> Local.sources_fingerprint()
current_index != @__jsv_resolver_mtime_index
rescue
_ -> true
end
defoverridable JSV.Resolver
defoverridable __mix_recompile__?: 0
end
end
@doc false
@spec collect_files(binary | [binary], map) :: [binary]
def collect_files(sources, cfg) when is_binary(sources) when is_list(sources) do
sources
|> List.wrap()
|> :lists.flatten()
|> do_collect(cfg)
|> Enum.uniq_by(&Path.expand/1)
end
defp do_collect(sources, cfg) when is_list(sources) do
sources
|> Enum.filter(fn source ->
case check_valid(source) do
:ok ->
true
{:error, reason} ->
maybe_warn_error(reason, source, cfg)
false
end
end)
|> Enum.flat_map(&do_collect(&1, cfg))
end
defp do_collect(dir_or_file, _) when is_binary(dir_or_file) do
cond do
File.regular?(dir_or_file) ->
case Path.extname(dir_or_file) do
".json" -> [dir_or_file]
_ -> []
end
File.dir?(dir_or_file) ->
Path.wildcard("#{dir_or_file}/**/*.json")
end
end
@doc false
@spec read_sources([binary], map) :: [{id :: binary(), raw_schema :: map()}]
def read_sources(expanded_sources, cfg) do
stream =
expanded_sources
|> Stream.flat_map(fn path ->
with {:ok, json} <- read_source(path),
{:ok, %{"$id" => id} = decoded} <- decode_source(json) do
[{path, {id, decoded}}]
else
{:error, reason} ->
:ok = maybe_warn_error(reason, path, cfg)
[]
end
end)
|> Stream.transform(%{}, fn {path, {id, _raw_schema} = item}, seen ->
seen =
case seen do
%{^id => prev_path} ->
raise "duplicate $id #{inspect(id)} defined in #{prev_path} and #{path}"
_ ->
Map.put(seen, id, path)
end
{[item], seen}
end)
stream =
if cfg.debug? do
module_str = inspect(cfg.module)
Stream.each(stream, fn {id, _} ->
IO.puts(IO.ANSI.format([:green, "✔ ", module_str, " loaded ", id]))
end)
else
stream
end
Enum.to_list(stream)
end
@doc false
@spec sources_fingerprint([binary]) :: %{binary() => {integer(), integer()}}
def sources_fingerprint(expanded_sources) do
# works like a hash or checksum but using the data direcly.
Map.new(expanded_sources, fn path ->
stat = File.stat!(path, time: :posix)
time = max(stat.mtime, stat.ctime)
{path, {time, stat.size}}
end)
end
defp check_valid(dir_or_file) when is_binary(dir_or_file) do
if File.exists?(dir_or_file) do
:ok
else
{:error, {:file_error, :does_not_exist}}
end
end
defp check_valid(other) do
{:error, {:bad_source, other}}
end
defp read_source(path) do
case File.read(path) do
{:ok, json} -> {:ok, json}
{:error, reason} -> {:error, {:file_error, reason}}
end
end
defp decode_source(json) do
case JSV.Codec.decode(json) do
{:ok, %{"$id" => id} = decoded} when is_binary(id) -> {:ok, decoded}
{:ok, decoded} when is_map(decoded) -> {:error, :schema_no_id}
{:ok, _} -> {:error, :schema_not_object}
{:error, reason} -> {:error, {:json_decode_error, reason}}
end
end
defp maybe_warn_error(reason, path, cfg) do
if cfg.warn? do
:ok = warn_error(reason, path, cfg)
else
:ok
end
end
defp warn_error(reason, path, cfg) do
case reason do
{:file_error, err} ->
IO.warn("could not read file: #{path} got: #{inspect(err)}", cfg.stacktrace)
:schema_no_id ->
IO.warn("json schema at #{path} does not have $id", cfg.stacktrace)
:schema_not_object ->
IO.warn("json schema at #{path} is not an object", cfg.stacktrace)
{:json_decode_error, err} ->
IO.warn("could not decode json schema at path #{path}, got: #{inspect(err)}", cfg.stacktrace)
{:bad_source, ^path} ->
IO.warn("invalid source, expected a binary path, got: #{inspect(path)}", cfg.stacktrace)
end
end
end