Resolvers

View Source

JSV 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 as https://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 are http or https 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

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!