Resolvers
View SourceJSV
may have to fetch other schemas when building a validation root. This
happens with $schema
, $ref
, or $dynamicRef
properties pointing to an
absolute URI.
A resolver is also used when a schema references a struct-based schema:
%{
type: :object,
properties: %{
user: MyApp.Schemas.User
}
}
In order to fetch those schemas, JSV requires a resolver. Resolvers are user-defined, but JSV provides implementations for common use cases:
JSV.Resolver.Embedded
will resolve the most often used meta-schemas such ashttps://json-schema.org/draft/2020-12/schema
.JSV.Resolver.Internal
will resolve struct schemas given as module names as in the example just above.JSV.Resolver.Httpc
will resolve schemas whose URI arehttp
orhttps
URLs. It uses the built-in Erlang HTTP client. While not packing many features, it does not enforce an HTTP client dependency in your application.
Using resolvers
The JSV.Resolver.Embedded
and JSV.Resolver.Internal
are always enabled and
there is no need to declare them when building the root.
Other resolvers such as JSV.Resolver.Httpc
or custom resolvers (see below)
need to be explicitly declared in the :resolver
option of JSV.build/2
or
JSV.build!/2
:
resolver = {JSV.Resolvers.Httpc, allowed_prefixes: ["https://example.com/schemas/"]}
root = JSV.build!(schema, resolver: resolver)
Multiple resolvers can be passed as a list:
root = JSV.build!(schema, resolver: [MyCustomResolver, MyOtherResolver])
Custom resolvers
Users are encouraged to write their own resolver to support advanced use cases.
Custom resolvers are most often used for:
- Resolving URLs such as
my-company://some-id/
where the implementation knows a directory to retrieve that path from. - Resolving
https://
URLs with custom network setups involving authentication, proxies, etc., or to use your HTTP library of choice. - Returning hardcoded schemas directly from the codebase.
- Returning a schema dynamically, for instance depending on the
:prod
or:test
environment.
To write a custom resolver, define a module that implements the JSV.Resolver
behaviour.
A basic resolver implementation
defmodule MyApp.SchemaResolver do
@behaviour JSV.Resolver
@user_schema %{type: :object, properties: %{name: %{type: :string}}}
@website_schema %{type: :object, properties: %{url: %{type: :string}}}
@impl true
def resolve("myapp:user", _opts), do: {:ok, @user_schema}
def resolve("myapp:website", _opts), do: {:ok, @website_schema}
def resolve(_, _opts), do: {:error, :unknown}
end
Resolving local files
The JSV.Resolver.Local
helper can automatically load schemas from files and
directories. Schemas will be resolvable by their $id
property.
defmodule MyApp.LocalResolver do
use JSV.Resolver.Local, source: [
"priv/api/schemas",
"priv/message-queue/schemas",
"priv/special.file.json"
]
end
Make sure to check the documentation of JSV.Resolver.Local
for more
information.
Returning normalized schemas
If your resolver returns JSON data that is in normal form like this:
%{
"type" => "object",
"additionalProperties" => false
}
Then the resolver implementation can return {:normal, schema}
instead of
{:ok, schema}
to skip the normalization step operated by JSV when building a
validation root.
The following form is not normal and requires normalization:
%{
type: => :object,
additionalProperties: => false
}
See JSV.Normalizer.normalize/3
for more details.
Resolvers form a chain
As mentioned before, the JSV.Resolver.Embedded
and JSV.Resolver.Internal
are
always enabled when calling JSV.build/2
. This means that when calling the
following code:
root = JSV.build(schema, resolver: [MyCustomResolver, MyOtherResolver])
The actual list of used resolvers is
MyCustomResolver
MyOtherResolver
JSV.Resolver.Embedded
JSV.Resolver.Internal
JSV will try each resolver in order until a successful response is returned, and fail if all of them return an error.
Don't break the chain
Make sure to define a catch-all clause for resolve/2
in your implementation
to return an {:error, _}
tuple and allow other resolvers to be tried.
Otherwise, a FunctionClauseError
would be raised and the whole build would
fail.
As all defined resolvers will be tried, there is no need call the built-in resolvers from your own resolver before running some expensive or slow computation (such as an HTTP call) because they will be called anyway.
# DON'T DO THIS
defmodule MyApp.SchemaResolver do
def resolve("https://" <> _ = uri, _opts) do
with {:error, {:not_embedded, _}} <- JSV.Resolver.Embedded.resolve(uri, []),
{:ok, %{status: 200, body: schema}} <- MyApp.HttpClient.get(uri) do
{:ok, schema}
end
end
def resolve(_, _) do
{:error, :unknown}
end
end
The built-in resolvers are standard resolvers implementations and adhere to the
JSV.Resolver
behaviour. That means that you can just pass them before yours
as regular resolvers:
# Do this instead
root = JSV.build!(schema, resolvers: [JSV.Resolver.Embedded, MyApp.SchemaResolver])
There may be valid use cases for delegation. If you know of one, just let us know!