Current section

Files

Jump to
jsv lib jsv resolver local.ex
Raw

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