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.
In order to fetch those schemas, JSV requires a resolver. Resolvers are user-defined, but JSV provides implementations for common use cases:
JSV.Resolver.Embeddedwill resolve the most often used meta-schemas such ashttps://json-schema.org/draft/2020-12/schema.JSV.Resolver.Internalwill resolve struct schemas given as module names as in the example just above.JSV.Resolver.Httpcwill resolve schemas whose URI arehttporhttpsURLs. 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
:prodor:testenvironment.
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}
endResolving 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"
]
endMake 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
MyCustomResolverMyOtherResolverJSV.Resolver.EmbeddedJSV.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.
# You may 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
endThe 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:
# You should do this instead
root = JSV.build!(schema, resolvers: [JSV.Resolver.Embedded, MyApp.SchemaResolver])The JSV.Resolver.Embedded resolver will only be called once.
There may be valid use cases for delegation. If you know of one, just let us know!