Packages

An Elixir implementation of JSON Data Definition Format.

Current section

Files

Jump to
jddf lib schema.ex
Raw

lib/schema.ex

defmodule JDDF.Schema do
@moduledoc """
Represents a JSON Data Definition Format schema.
You can construct instances of this type yourself, or you can parse it from
JSON-like data using `from_json/1` or `from_json!/1`.
Not all invariants that JDDF schemas must adhere to are checked by
`from_json/1` or `from_json!/1`. To fully verify the correctness of a schema,
use `verify/1` or `verify!/1`.
"""
defmodule InvalidSchemaError do
@moduledoc """
Indicates that a schema does not conform to the JDDF syntax rules.
"""
defexception message: "invalid schema"
@type t :: %__MODULE__{message: String.t()}
end
defstruct [:definitions, :form]
@type t :: %__MODULE__{definitions: mapping | nil, form: form}
@type mapping :: %{optional(String.t()) => __MODULE__.t()}
@type form ::
{:empty}
| {:ref, String.t()}
| {:type, type}
| {:enum, MapSet.t(String.t())}
| {:elements, __MODULE__.t()}
| {:properties, mapping | nil, mapping | nil, boolean}
| {:values, __MODULE__.t()}
| {:discriminator, String.t(), mapping}
@type type ::
:boolean
| :float32
| :float64
| :int8
| :uint8
| :int16
| :uint16
| :int32
| :uint32
| :string
| :timestamp
@doc """
Construct a JDDF schema from parsed JSON data.
`json` should be an Elixir representation of JSON data. You should first parse
the JSON before passing it into `from_json/1`. You can construct this data
manually, or parse it using:
* [`Jason.decode!/2`](https://hexdocs.pm/jason/Jason.html#decode!/2), or
* [`Poison.decode!/2`](https://github.com/devinus/poison#usage)
Most JSON implementations for Elixir are compatible with this function.
iex> JDDF.Schema.from_json(%{"type" => "uint32"})
{:ok, %JDDF.Schema{definitions: nil, form: {:type, :uint32}}}
iex> JDDF.Schema.from_json(%{"type" => "nonsense"})
{:error, %JDDF.Schema.InvalidSchemaError{message: "invalid type"}}
"""
@spec from_json(json :: String.t()) :: {:ok, Schema.t()} | {:error, InvalidSchemaError.t()}
def from_json(json) do
{:ok, from_json!(json)}
rescue
e in InvalidSchemaError -> {:error, e}
end
@doc """
Construct a JDDF schema from parsed JSON data.
Similar to `from_json/1`, except it will unwrap the result and will raise in
case of errors.
iex> JDDF.Schema.from_json!(%{"type" => "uint32"})
%JDDF.Schema{definitions: nil, form: {:type, :uint32}}
iex> JDDF.Schema.from_json!(%{"type" => "nonsense"})
** (JDDF.Schema.InvalidSchemaError) invalid type
"""
@spec from_json!(json :: String.t()) :: Schema.t()
def from_json!(json) do
unless is_map(json) do
raise InvalidSchemaError, "schema must be object"
end
definitions =
if Map.has_key?(json, "definitions") do
unless is_map(json["definitions"]) do
raise InvalidSchemaError, "definitions must be object"
end
Map.new(json["definitions"], fn {k, v} -> {k, from_json!(v)} end)
else
nil
end
# Strip out non-keyword properties of the schema.
json = Map.take(json, ~w(
ref
type
enum
elements
properties
optionalProperties
additionalProperties
values
discriminator
))
# Attempt to match against each of the eight forms. Exactly one of these
# should succeed.
form_empty =
if json === %{} do
{:empty}
else
nil
end
form_ref =
if Map.has_key?(json, "ref") do
unless is_binary(json["ref"]) do
raise InvalidSchemaError, "ref must be string"
end
{:ref, json["ref"]}
else
nil
end
form_type =
if Map.has_key?(json, "type") do
if Enum.member?(
~w(
boolean
float32
float64
int8
uint8
int16
uint16
int32
uint32
string
timestamp
),
json["type"]
) do
{:type, String.to_atom(json["type"])}
else
raise InvalidSchemaError, message: "invalid type"
end
else
nil
end
form_enum =
if Map.has_key?(json, "enum") do
unless is_list(json["enum"]) do
raise InvalidSchemaError, message: "enum must be array"
end
if json["enum"] === [] do
raise InvalidSchemaError, message: "enum must be non-empty"
end
for value <- json["enum"] do
if !is_binary(value) do
raise InvalidSchemaError, message: "enum must be array of strings"
end
end
set = MapSet.new(json["enum"])
unless MapSet.size(set) === length(json["enum"]) do
raise InvalidSchemaError, message: "enum must not contain repeated values"
end
{:enum, set}
else
nil
end
form_elements =
if Map.has_key?(json, "elements") do
{:elements, from_json!(json["elements"])}
else
nil
end
form_properties =
if Map.has_key?(json, "properties") || Map.has_key?(json, "optionalProperties") do
if Map.has_key?(json, "properties") && !is_map(json["properties"]) do
raise InvalidSchemaError, "properties must be object"
end
if Map.has_key?(json, "optionalProperties") && !is_map(json["optionalProperties"]) do
raise InvalidSchemaError, "optionalProperties must be object"
end
required =
if Map.has_key?(json, "properties") do
Map.new(json["properties"], fn {k, v} -> {k, from_json!(v)} end)
else
nil
end
optional =
if Map.has_key?(json, "optionalProperties") do
Map.new(json["optionalProperties"], fn {k, v} -> {k, from_json!(v)} end)
else
nil
end
additional = Map.get(json, "additionalProperties", false)
{:properties, required, optional, additional}
else
nil
end
form_values =
if Map.has_key?(json, "values") do
{:values, from_json!(json["values"])}
else
nil
end
form_discriminator =
if Map.has_key?(json, "discriminator") do
discriminator = json["discriminator"]
unless is_map(discriminator) do
raise InvalidSchemaError, "discriminator must be object"
end
unless Map.has_key?(discriminator, "tag") do
raise InvalidSchemaError, "discriminator must have tag"
end
unless is_binary(discriminator["tag"]) do
raise InvalidSchemaError, "discriminator tag must be string"
end
unless Map.has_key?(discriminator, "mapping") do
raise InvalidSchemaError, "discriminator must have mapping"
end
unless is_map(discriminator["mapping"]) do
raise InvalidSchemaError, "discriminator mapping must be object"
end
mapping =
discriminator["mapping"]
|> Map.new(fn {k, v} -> {k, from_json!(v)} end)
{:discriminator, discriminator["tag"], mapping}
else
nil
end
matching_forms =
[
form_empty,
form_ref,
form_type,
form_enum,
form_elements,
form_properties,
form_values,
form_discriminator
]
|> Enum.filter(fn x -> x end)
if Enum.count(matching_forms) !== 1 do
raise InvalidSchemaError, message: "invalid form"
end
form = List.first(matching_forms)
%__MODULE__{definitions: definitions, form: form}
end
@doc """
Ensure that the invariants of a JDDF schema are adhered to.
iex> JDDF.Schema.verify(JDDF.Schema.from_json!(%{}))
{:ok, nil}
iex> schema = JDDF.Schema.from_json!(%{
...> "properties" => %{ "a" => %{}},
...> "optionalProperties" => %{ "a" => %{}},
...> })
iex> JDDF.Schema.verify(schema)
{:error, %JDDF.Schema.InvalidSchemaError{message: "properties and optionalProperties share key"}}
"""
@spec verify(schema :: __MODULE__.t()) :: {:ok, nil} | {:error, InvalidSchemaError.t()}
def verify(schema) do
{:ok, verify!(schema)}
rescue
e in InvalidSchemaError -> {:error, e}
end
@doc """
Ensure that the invariants of a JDDF schema are adhered to.
Similar to `verify/1`, except it will unwrap the result and will raise in case
of errors.
iex> JDDF.Schema.verify!(JDDF.Schema.from_json!(%{}))
nil
iex> schema = JDDF.Schema.from_json!(%{
...> "properties" => %{ "a" => %{}},
...> "optionalProperties" => %{ "a" => %{}},
...> })
iex> JDDF.Schema.verify!(schema)
** (JDDF.Schema.InvalidSchemaError) properties and optionalProperties share key
"""
@spec verify!(schema :: __MODULE__.t()) :: nil
def verify!(schema) do
verify!(schema, schema)
end
defp verify!(schema, root) do
if schema.definitions !== nil do
unless schema === root do
raise InvalidSchemaError, message: "non-root definitions"
end
for {_, schema} <- schema.definitions do
verify!(schema, root)
end
end
case schema.form do
{:empty} ->
nil
{:type, _} ->
nil
{:enum, _} ->
nil
{:ref, ref} ->
if root.definitions === nil do
raise InvalidSchemaError, message: "ref but no definitions"
else
if !Map.has_key?(root.definitions, ref) do
raise InvalidSchemaError, message: "ref to non-existent definition"
end
end
{:elements, schema} ->
verify!(schema, root)
{:properties, required, optional, _} ->
required_keys = MapSet.new(Map.keys(required || %{}))
optional_keys = MapSet.new(Map.keys(optional || %{}))
unless MapSet.disjoint?(required_keys, optional_keys) do
raise InvalidSchemaError, "properties and optionalProperties share key"
end
{:values, schema} ->
verify!(schema, root)
{:discriminator, tag, mapping} ->
for {_, schema} <- mapping do
verify!(schema, root)
case schema.form do
{:properties, required, optional, _} ->
if Map.has_key?(required || %{}, tag) || Map.has_key?(optional || %{}, tag) do
raise InvalidSchemaError,
"discriminator mapping value has property equal to tag's value"
end
_ ->
raise InvalidSchemaError, "discriminator mapping value must be of properties form"
end
end
end
end
end