JSV provides a mechanism to declare a cast function into a schema, to be called once the validation is successful. This is the same mechanism used to cast struct-schemas to Elixir structs.
This guide describes how to use a custom function in your schemas.
Migrating from v0.18? See the API changes in v0.19 for a summary of what changed and how to update your code.
JSV's cast system
JSV stores the cast information in the JSON schemas under the x-jsv-cast
extension keyword. There is no central registry of cast functions, so any
library you use can define its own JSV casts without needing you to copy their
mapping, registry or whatever in your configuration.
The x-jsv-cast value is a list of casters. Each caster is either a string
(a resolvable module name) or a list whose first element is the module name
string, followed by arguments.
{
"description": "an existing Elixir atom",
"type": "string",
"x-jsv-cast": [["Elixir.MyApp.Schemas.Cast", "existing_atom"]]
}This solution has multiple advantages:
- No configuration.
- Schemas remain fully declarative. The information about casting is collocated
with other validation keywords such as
type,format,properties, etc. - Schemas are portable as JSV does not need additional configuration to know what code to call. Although, we use a module name so the module needs to be available in the Elixir runtime when data is validated.
- Schema reviewers can know that a schema uses a cast without needing to look elsewhere.
- Cast functions can be referenced into multiple schemas, they are not tied to a
particular struct or schema-map defined in one place. You can also define them
in generic schemas referenced with
$refor$dynamicRef. - Multiple casts can be chained on a single schema and are applied in order.
- Cast functions can receive extra arguments from the schema.
There are some drawbacks as well:
- The
x-jsv-castinformation needs to be JSON-serializable, so modules are referenced as strings, and arguments can only be simple JSON data. - Module names are leaked into the schemas. If this is not acceptable, you can declare a generic "Cast" module in your application and dispatch manually from there. Sometimes just cleaning the schemas before making them public is enough too.
- Refactoring can be harder. In general, you will not write the content of
x-jsv-castby hand but rather use our helper functions. Refactoring will be the same as with regular code. - Indirection for the cast functions is required. See the security concerns below.
Security concerns
In the previous example, "existing_atom" is a "tag" argument, and not a
function name that JSV would call blindly. Otherwise, if your app is processing
third-party schemas, a ["Elixir.System", "stop"] or worse would be very bad.
For that reason, cast functions need to be enabled by developers by defining the
__jsv__/2 callback.
This is an internal callback, not documented by a behaviour, but security is
important and it is worth explaining the mechanism here. The __jsv__/2
callback is generated automatically when you use the defcast macro.
When evaluating ["Elixir.System", "stop"], JSV will call System.__jsv__({:cast, ["stop"], raw_schema}, builder) at schema build time. This function does not exist and JSV will catch that
error, refusing to build the schema.
The only way for that function to exist is if you define it in your own code.
While you could compile a custom Elixir version with a __jsv__/2 function in
the System module, there are only so many reasons to do that.
But that applies to your modules as well. Only you can define the __jsv__/2
function in your modules.
Unresolved casts fail at build time, before any data is ever validated.
While this requires a few extra lines of code, we think it's a simple-enough solution to prevent undesirable remote code execution.
Defining cast functions
Cast functions are functions that return a generic result tuple:
{:ok, transformed_data}for successful transformations.{:error, reason}when the transformation fails.
As described in the security section above, JSV needs the target module to
export a __jsv__/2 callback that resolves the cast at build time. JSV supports
strings and integers as tag arguments.
To define cast functions, use the defcast macro (available via use JSV.Schema
or import JSV).
Basic usage of defcast
The following module expects a string and returns the value in upper case:
defmodule MyApp.Schemas.Cast do
use JSV.Schema
defcast to_uppercase(data) do
{:ok, String.upcase(data)}
end
endThis will define the to_uppercase/1 function that will evaluate the body as
any regular function:
MyApp.Schemas.Cast.to_uppercase("hello")
# => {:ok, "HELLO"}It will also define a to_uppercase/0 helper that returns the caster wire form
to include in a schema. The default tag of a cast is the function name, as a
string:
MyApp.Schemas.Cast.to_uppercase()
# => ["Elixir.MyApp.Schemas.Cast", "to_uppercase"]And finally, it will define the appropriate __jsv__/2 callback so JSV can
resolve the cast at build time.
Use JSV.Schema.xcast/2 to add the cast to a schema:
schema = JSV.Schema.string() |> JSV.Schema.xcast(MyApp.Schemas.Cast.to_uppercase())
# => %{type: :string, "x-jsv-cast": [["Elixir.MyApp.Schemas.Cast", "to_uppercase"]]}
root = JSV.build!(schema)
JSV.validate("hello", root)
# => {:ok, "HELLO"}Cast functions with arguments
Cast functions can accept extra arguments from the schema. Define the function
with an args parameter (arity 2) or with args and vctx (arity 3):
defmodule MyApp.Schemas.Cast do
use JSV.Schema
defcast append_suffix(data, args) do
[suffix] = args
{:ok, data <> suffix}
end
endWhen a handler accepts arguments, its helper takes an argument list:
MyApp.Schemas.Cast.append_suffix(["!"])
# => ["Elixir.MyApp.Schemas.Cast", "append_suffix", "!"]Use it in a schema:
schema = JSV.Schema.string() |> JSV.Schema.xcast(MyApp.Schemas.Cast.append_suffix(["!"]))
root = JSV.build!(schema)
JSV.validate("hello", root)
# => {:ok, "hello!"}Arguments must be JSON-encodable data.
Using a custom tag
Custom tags can be given as the first argument of defcast:
# Using a string tag
defcast "my_custom_tag", to_uppercase(data) do
{:ok, String.upcase(data)}
end
# Using an integer tag
defcast ?u, to_uppercase(data) do
{:ok, data}
endException handling
The rescue, catch and after blocks are supported:
defcast safe_to_atom(data) do
{:ok, String.to_existing_atom(data)}
rescue
ArgumentError -> {:error, :unknown_atom}
endReferring to existing functions
Guards with the when keyword are not supported. But it is possible to refer to
an existing local function instead of defining the body directly.
The referred function must be defined with def (defp is not supported).
defmodule MyApp.Schemas.Cast do
use JSV.Schema
# Pass the local function name as a single argument.
defcast :to_upper
# Custom tags are supported too
defcast "custom_tag", :to_upper
defcast ?u, :to_upper
# The function needs to be defined in the module with `def`.
def to_upper(data) when is_binary(data), do: {:ok, String.upcase(data)}
def to_upper(data), do: {:error, :expected_string}
endMulticasting
Multiple casts can be declared on a single schema. They are applied in order. Each cast receives the output of the previous one. If any cast fails, the chain stops.
schema =
JSV.Schema.string()
|> JSV.Schema.xcast(MyApp.Cast.to_uppercase())
|> JSV.Schema.xcast(MyApp.Cast.append_suffix(["!"]))
root = JSV.build!(schema)
JSV.validate("hello", root)
# => {:ok, "HELLO!"}Error Normalization
To return custom errors from your functions, you can optionally define the
format_error/3 function that will receive the cast arguments (including the
tag), the reason and the validated data.
This will be called when JSV errors are normalized to be JSON-encodable.
defmodule MyApp.Schemas.Cast do
use JSV.Schema
defcast safe_to_atom(data) do
{:ok, String.to_existing_atom(data)}
rescue
ArgumentError -> {:error, :unknown_atom}
end
def format_error(["safe_to_atom"], :unknown_atom, data) do
"could not cast to existing atom: #{inspect(data)}"
end
end
schema = JSV.Schema.Helpers.string() |> JSV.Schema.xcast(MyApp.Schemas.Cast.safe_to_atom())
root = JSV.build!(schema)
{:error, err} = JSV.validate("some string", root)
JSV.normalize_error(err)The code above gives the following normalized error:
%{
details: [
%{
errors: [
%{
kind: :cast,
message: "could not cast to existing atom: \"some string\""
}
],
evaluationPath: "#",
instanceLocation: "#",
schemaLocation: "#",
valid: false
}
],
valid: false
}