Defining Schemas

View Source

This guide explains the different possible values to use as a schema with JSV.

Schema sources

Schemas given as input to JSV can come from two sources:

  • Schemas given directly to the JSV.build/2 function. These are expected to be maps or booleans. JSV will not parse JSON strings.
  • Schemas returned by resolvers. These should also be maps (but not booleans). The built-in resolvers will handle JSON deserialization automatically.

JSV is designed to work with raw schemas. Any map or boolean is a valid schema. For instance, it is possible to directly use a schema from a file:

root =
  "my-schema.json"
  |> File.read!()
  |> JSON.decode!()
  |> JSV.build()

Schema formats

Schemas can be either booleans or maps. The true value is equivalent to an empty JSON Schema {}, while false is a schema that will invalidate any value. It is most often used as a sub-schema for additionalProperties.

Maps can define keys and values as binaries or atoms. The following schemas are equivalent:

%{type: :boolean}
%{type: "boolean"}
%{"type" => "boolean"}
# You will rarely find this one in the wild!
%{"type" => :boolean}

This is because JSV will normalize the schemas before building a "root", the base data structure for data validation.

Mixing keys is not recommended. In the following example, JSV will build a schema that will successfully validate integers with a minimum of zero. However, the choice for the maximum value is not made by JSV.

%{:type => :integer, "minimum" => 0, "maximum" => 10, :maximum => 20}

Struct schemas

Schemas can be used to define structs with the JSV.defschema/1 macro.

For instance, with this module definition schema:

defmodule MyApp.UserSchema do
  use JSV.Schema

  defschema %{
    type: :object,
    properties: %{
      name: %{type: :string, default: ""},
      age: %{type: :integer, default: 0}
    }
  }
end

A struct will be defined with the appropriate default values:

iex> %MyApp.UserSchema{}
%MyApp.UserSchema{name: "", age: 0}

The module can be used as a schema to build a validator root and cast data to the corresponding struct:

iex> {:ok, root} = JSV.build(MyApp.UserSchema)
iex> data = %{"name" => "Alice"}
iex> JSV.validate(data, root)
{:ok, %MyApp.UserSchema{name: "Alice", age: 0}}

Casting to a struct can be disabled by passing cast: false into the options of JSV.validate/3.

iex> {:ok, root} = JSV.build(MyApp.UserSchema)
iex> data = %{"name" => "Alice", "extra" => "hello!"}
iex> JSV.validate(data, root, cast: false)
{:ok, %{"name" => "Alice", "extra" => "hello!"}}

The module can also be used in other schemas:

%{
  type: :object,
  properties: %{
    name: %{type: :string},
    owner: MyApp.UserSchema
  }
}

An alternative syntax can be used, by passing only the properties schemas as a list.

defmodule MyApp.UserSchema do
  use JSV.Schema

  defschema name: %{type: :string, default: ""},
            age: %{type: :integer, default: 0}
end

In that case, properties that do not have a default value are automatically required, and the type of the schema is automatically set to object. The title of the schema is set as the last segment of the module name.

Struct defining schemas are a special case of the generic cast mechanism built in JSV. Make sure to check that guide out as well.

Defining multiple schemas with defschema/3

The JSV.defschema/3 macro allows you to define a new module for a schema. It can be used inside an enclosing "group" module, which is useful for organizing related schemas together:

defmodule MyApp.Schemas do
  use JSV.Schema

  defschema User,
    name: string(),
    email: string(),
    age: integer(default: 18)

  defschema Address,
            "Physical address information",
            street: string(),
            city: string(),
            country: string(default: "US")

  defschema Company,
            "Company information with nested schemas",
            name: string(),
            address: Address,
            employees: array_of(User)
end

This creates three separate modules: MyApp.Schemas.User, MyApp.Schemas.Address, and MyApp.Schemas.Company, each with their own struct and JSON schema.

You can use these schemas independently:

user = %MyApp.Schemas.User{name: "Alice", email: "alice@example.com"}
address = %MyApp.Schemas.Address{street: "123 Main St", city: "Boston"}

{:ok, user_root} = JSV.build(MyApp.Schemas.User)
{:ok, company_root} = JSV.build(MyApp.Schemas.Company)

Using full schema maps

You can also use defschema/3 with complete schema maps instead of property lists:

defmodule MyApp.Schemas do
  use JSV.Schema

  defschema ApiResponse,
            "Standard API response format",
            %{
              type: :object,
              properties: %{
                success: %{type: :boolean},
                data: %{type: :object},
                errors: %{type: :array, items: %{type: :string}}
              },
              required: [:success],
              additionalProperties: false
            }
end

When using full schema maps, the title and description from the macro parameters are not automatically applied to the schema, the map is used as-is. Only the description parameter is used for the module's documentation.

Self-referencing schemas

Schemas can reference themselves using __MODULE__:

defmodule MyApp.Schemas do
  use JSV.Schema

  defschema Category,
            "Hierarchical category structure",
            name: string(),
            parent: optional(__MODULE__)
end

This creates a MyApp.Schemas.Category module that can have a parent of the same type, allowing for hierarchical data structures.