defmodule Injecto do
@moduledoc """
A behaviour module that defines both an Ecto schema and a JSON schema.
An Injecto schema uses the module attribute `@properties` to define an Ecto schema and
a JSON schema based on the `ex_json_schema` library. In doing so, it also injects a
`Jason` encoder implementation. The advantage of using an Injecto schema is to get a
consistent parsing and validating with Ecto changesets and JSON schema respectively
with minimal boilerplates. This consistency is helpful when working with struct-based
request or response bodies, because we can get accurate Swagger schemas for free.
## Example
In the following documentation, we will use this simple Injecto schema as example:
defmodule Post do
@properties %{
title: {:string, required: true},
description: {:string, []},
likes: {:integer, required: true, minimum: 0}
}
use Injecto
end
The module attribute `@properties` must be defined first before invoking `use Injecto`.
The properties attribute is a map with field names as keys and field specs as values.
A field spec is a 2-tuple of `{type, options}`. For scalar types, most Ecto field types
are supported, namely:
[
:binary,
:binary_id,
:boolean,
:float,
:id,
:integer,
:string,
:map,
:decimal,
:date,
:time,
:time_usec,
:naive_datetime,
:naive_datetime_usec,
:utc_datetime,
:utc_datetime_usec
]
Refer to Ecto's documentation on
[Primitive Types](https://hexdocs.pm/ecto/Ecto.Schema.html#module-primitive-types)
to see how these field types get translated into Elixir types.
Supported compound types include:
* `{:enum, atoms}` and `{:enum, keyword}`;
* `{:object, injecto_module}`; and
* `{:array, inner_type}` where `inner_type` can be a scalar, enum or object type.
## Usage: Ecto
On the Ecto side, `new/0` and `changeset/2` functions can create a `nil`-filled struct
and an Ecto changeset respectively.
iex> Post.new()
%Post{title: nil, description: nil, likes: nil}
iex> %Ecto.Changeset{valid?: false, errors: errors} = Post.changeset(%Post{}, %{})
iex> errors
[
likes: {"can't be blank", [validation: :required]},
title: {"can't be blank", [validation: :required]}
]
iex> post = %{title: "Valid", likes: 10}
iex> %Ecto.Changeset{valid?: true, errors: []} = Post.changeset(%Post{}, post)
The `parse/2` function convert a map to a changeset-validated struct.
iex> {:error, errors} = Post.parse(%{})
iex> errors
%{
likes: [{"can't be blank", [validation: :required]}],
title: [{"can't be blank", [validation: :required]}]
}
iex> post = %{title: "Valid", likes: 10}
iex> {:ok, %Post{title: "Valid", likes: 10}} = Post.parse(post)
iex> valid_posts = [%{title: "A", likes: 1}, %{title: "B", likes: 2}]
iex> {:ok, posts} = Post.parse_many(valid_posts)
iex> Enum.sort_by(posts, &(&1.title))
[
%Post{title: "A", likes: 1, description: nil},
%Post{title: "B", likes: 2, description: nil}
]
The `parse_many/2` function is the collection counter part of `parse/2`. One validation
error is considered to be an error for the entire collection:
iex> invalid_posts = [%{title: 1, likes: "A"}, %{title: 2, likes: "B"}]
iex> {:error, errors} = Post.parse_many(invalid_posts)
iex> errors
[
%{
likes: [{"is invalid", [type: :integer, validation: :cast]}],
title: [{"is invalid", [type: :string, validation: :cast]}]
},
%{
likes: [{"is invalid", [type: :integer, validation: :cast]}],
title: [{"is invalid", [type: :string, validation: :cast]}]
}
]
iex> valid_posts = [%{title: "A", likes: 1}, %{title: "B", likes: 2}]
iex> invalid_posts = [%{title: 1, likes: "A"}]
iex> {:error, errors} = Post.parse_many(valid_posts ++ invalid_posts)
iex> errors
[
%{
likes: [{"is invalid", [type: :integer, validation: :cast]}],
title: [{"is invalid", [type: :string, validation: :cast]}]
}
]
Note that JSON schema constraints such as `minimum: 0` are not caught by the Ecto changeset:
iex> post = %{title: "Invalid", likes: -1}
iex> %Ecto.Changeset{valid?: true, errors: []} = Post.changeset(%Post{}, post)
## Usage: JSON Schema
The function `json_schema/0` returns a resolved `ExJsonSchema.Scheam.Root` struct.
iex> %ExJsonSchema.Schema.Root{schema: schema} = Post.json_schema()
iex> schema
%{
"properties" => %{
"description" => %{
"anyOf" => [%{"type" => "string"}, %{"type" => "null"}]
},
"likes" => %{"minimum" => 0, "type" => "integer"},
"title" => %{"type" => "string"}
},
"required" => ["likes", "title"],
"title" => "Elixir.Post",
"type" => "object",
"x-struct" => "Elixir.Post"
}
Internally, this is used by `validate_json/1` to validate a map using the JSON schema.
iex> valid_post = %{title: "A", likes: 1}
iex> {:ok, ^valid_post} = Post.validate_json(valid_post)
iex> invalid_post = %{title: 123, likes: -1}
iex> {:error, errors} = Post.validate_json(invalid_post)
iex> Enum.sort(errors)
[
{"Expected the value to be >= 0", "#/likes"},
{"Type mismatch. Expected String but got Integer.", "#/title"}
]
"""
@doc """
Returns the struct with the fields populated with `nil`s:
iex> Post.new()
%Post{title: nil, description: nil, likes: nil}
"""
@callback new() :: struct()
@doc """
Returns an Ecto changeset:
iex> %Ecto.Changeset{valid?: false, errors: errors} = Post.changeset(%Post{}, %{})
iex> errors
[
likes: {"can't be blank", [validation: :required]},
title: {"can't be blank", [validation: :required]}
]
iex> post = %{title: "Valid", likes: 10}
iex> %Ecto.Changeset{valid?: true, errors: []} = Post.changeset(%Post{}, post)
Note that JSON schema constraints such as `minimum: 0` are not caught by the Ecto changeset:
iex> post = %{title: "Invalid", likes: -1}
iex> %Ecto.Changeset{valid?: true, errors: []} = Post.changeset(%Post{}, post)
"""
@callback changeset(struct(), map()) :: %Ecto.Changeset{}
@doc """
Returns a result of a validated Elixir struct or the validation errors:
iex> {:error, errors} = Post.parse(%{})
iex> errors
%{
likes: [{"can't be blank", [validation: :required]}],
title: [{"can't be blank", [validation: :required]}]
}
iex> post = %{title: "Valid", likes: 10}
iex> {:ok, %Post{title: "Valid", likes: 10}} = Post.parse(post)
Note that JSON schema constraints such as `minimum: 0` are not caught by `parse/2` by
default. Pass in the option `:validate_json` for JSON schema validation:
iex> post = %{title: "Invalid", likes: -1}
iex> {:ok, %Post{}} = Post.parse(post)
iex> post = %{title: "Invalid", likes: -1}
iex> {:error, errors} = Post.parse(post, validate_json: true)
iex> errors
[{"Expected the value to be >= 0", "#/likes"}]
"""
@callback parse(map(), Keyword.t()) :: {:ok, struct()} | {:error, any()}
@doc """
Calls `parse/2` on a list of maps. Returns `:ok` if all maps are parsed correctly.
iex> valid_posts = [%{title: "A", likes: 1}, %{title: "B", likes: 2}]
iex> {:ok, posts} = Post.parse_many(valid_posts)
iex> Enum.sort_by(posts, &(&1.title))
[
%Post{title: "A", likes: 1, description: nil},
%Post{title: "B", likes: 2, description: nil}
]
iex> invalid_posts = [%{title: 1, likes: "A"}, %{title: 2, likes: "B"}]
iex> {:error, errors} = Post.parse_many(invalid_posts)
iex> errors
[
%{
likes: [{"is invalid", [type: :integer, validation: :cast]}],
title: [{"is invalid", [type: :string, validation: :cast]}]
},
%{
likes: [{"is invalid", [type: :integer, validation: :cast]}],
title: [{"is invalid", [type: :string, validation: :cast]}]
}
]
iex> valid_posts = [%{title: "A", likes: 1}, %{title: "B", likes: 2}]
iex> invalid_posts = [%{title: 1, likes: "A"}]
iex> {:error, errors} = Post.parse_many(valid_posts ++ invalid_posts)
iex> errors
[
%{
likes: [{"is invalid", [type: :integer, validation: :cast]}],
title: [{"is invalid", [type: :string, validation: :cast]}]
}
]
Note that JSON schema constraints such as `minimum: 0` are not caught by `parse` by
default. Pass in the option `:validate_json` for JSON schema validation:
iex> posts = [%{title: "A", likes: -1}]
iex> {:ok, _} = Post.parse_many(posts)
iex> {:error, errors} = Post.parse_many(posts, validate_json: true)
iex> errors
[[{"Expected the value to be >= 0", "#/likes"}]]
"""
@callback parse_many([map()], Keyword.t()) :: {:ok, [struct()]} | {:error, any()}
@doc """
Validates and returns an `ex_json_schema` schema:
iex> %ExJsonSchema.Schema.Root{schema: schema} = Post.json_schema()
iex> schema
%{
"properties" => %{
"description" => %{
"anyOf" => [%{"type" => "string"}, %{"type" => "null"}]
},
"likes" => %{"minimum" => 0, "type" => "integer"},
"title" => %{"type" => "string"}
},
"required" => ["likes", "title"],
"title" => "Elixir.Post",
"type" => "object",
"x-struct" => "Elixir.Post"
}
"""
@callback json_schema() :: %ExJsonSchema.Schema.Root{}
@doc """
Serialises a map, and validates the deserialised result against the JSON schema:
iex> valid_post = %{title: "A", likes: 1}
iex> {:ok, ^valid_post} = Post.validate_json(valid_post)
iex> invalid_post = %{title: 123, likes: -1}
iex> {:error, errors} = Post.validate_json(invalid_post)
iex> Enum.sort(errors)
[
{"Expected the value to be >= 0", "#/likes"},
{"Type mismatch. Expected String but got Integer.", "#/title"}
]
"""
@callback validate_json(map()) :: {:ok, map()} | {:error, any()}
defmacro __using__(_opts) do
quote do
@derive {Jason.Encoder, only: Map.keys(@properties)}
@behaviour Injecto
@scalar_types [
:binary,
:binary_id,
:boolean,
:float,
:id,
:integer,
:string,
:map,
:decimal,
:date,
:time,
:time_usec,
:naive_datetime,
:naive_datetime_usec,
:utc_datetime,
:utc_datetime_usec
]
## ---------------------------------------------------------------------------
## Ecto
## ---------------------------------------------------------------------------
use Ecto.Schema
import Ecto.Changeset
Module.register_attribute(__MODULE__, :source, [])
@source @source || ""
@primary_key false
schema @source do
@properties
|> Enum.map(fn {name, {type, _opts}} ->
case type do
{:object, module} ->
embeds_one(name, module)
{:array, inner_type} ->
case inner_type do
{:enum, values} ->
field(name, {:array, Ecto.Enum}, values: values)
_ ->
if Enum.member?(@scalar_types, inner_type),
do: field(name, {:array, inner_type}),
else: embeds_many(name, inner_type)
end
{:enum, values} ->
field(name, Ecto.Enum, values: values)
type ->
field(name, type)
end
end)
end
@spec new() :: %__MODULE__{}
def new, do: %__MODULE__{}
@spec changeset(struct(), map()) :: %Ecto.Changeset{}
def changeset(struct, map), do: base_changeset(struct, map)
defoverridable changeset: 2
@spec base_changeset(struct(), map()) :: %Ecto.Changeset{}
defp base_changeset(struct, map) do
init_changeset =
struct
|> cast(map, all_non_embeds())
|> validate_required(required_non_embeds())
embedded_properties =
Enum.filter(properties(), fn {_name, {type, _opt}} -> embedded?(type) end)
Enum.reduce(
embedded_properties,
init_changeset,
fn {name, {_type, opts}}, acc_changeset ->
required? = Keyword.get(opts, :required, false)
acc_changeset |> cast_embed(name, required: required?)
end
)
end
@type result :: {:ok, %__MODULE__{}} | {:error, any()}
@spec parse(map() | %__MODULE__{}, Keyword.t()) :: result()
def parse(input, opts \\ []) do
input = ensure_nested_map(input)
validate_fn =
if Keyword.get(opts, :validate_json),
do: &validate_json/1,
else: fn input -> {:ok, input} end
with {:ok, _} <- validate_fn.(input) do
parse_ecto(input)
end
end
@spec parse_ecto(map() | %__MODULE__{}) :: result()
defp parse_ecto(input) do
changeset = __MODULE__.changeset(__MODULE__.new(), input)
if changeset.valid? do
{:ok, Ecto.Changeset.apply_changes(changeset)}
else
errors =
changeset
|> Ecto.Changeset.traverse_errors(&Function.identity/1)
{:error, errors}
end
end
@type results :: {:ok, [%__MODULE__{}]} | {:error, any()}
@spec parse_many([map()], Keyword.t()) :: results()
def parse_many(inputs, opts \\ []) do
reduced =
Enum.reduce(
inputs,
%{oks: [], errors: []},
fn input, acc ->
case parse(input, opts) do
{:ok, ok} -> %{acc | oks: [ok | acc[:oks]]}
{:error, error} -> %{acc | errors: [error | acc[:errors]]}
end
end
)
case reduced do
%{errors: [], oks: oks} -> {:ok, oks}
%{errors: errors} -> {:error, errors}
end
end
# Source: https://elixirforum.com/t/convert-a-nested-struct-into-a-nested-map/23814/7
@spec ensure_nested_map(map() | struct()) :: map()
@guarded_structs [Date, DateTime, NaiveDateTime, Time, Decimal]
defp ensure_nested_map(%{__struct__: struct} = data) when struct in @guarded_structs,
do: data
defp ensure_nested_map(struct) when is_struct(struct) do
map = Map.from_struct(struct)
:maps.map(fn _, value -> ensure_nested_map(value) end, map)
end
defp ensure_nested_map(map) when is_map(map),
do: :maps.map(fn _, value -> ensure_nested_map(value) end, map)
defp ensure_nested_map(list) when is_list(list),
do: Enum.map(list, &ensure_nested_map/1)
defp ensure_nested_map(data), do: data
## ---------------------------------------------------------------------------
## JSON Schema
## ---------------------------------------------------------------------------
@spec json_schema() :: %ExJsonSchema.Schema.Root{}
def json_schema() do
%{
"type" => "object",
"properties" => json_schema_properties(properties()),
"required" => required_fields(properties()) |> Enum.map(&Atom.to_string/1),
"title" => Atom.to_string(__MODULE__),
"x-struct" => Atom.to_string(__MODULE__)
}
|> ExJsonSchema.Schema.resolve()
end
@spec json_schema_properties(map()) :: map()
defp json_schema_properties(props) do
props
|> Enum.map(fn {name, {type, opts}} ->
schema =
case type do
{:object, module} -> Map.merge(object_schema(opts), module.json_schema().schema)
{:array, inner_type} -> array_schema({:array, inner_type}, opts)
{:enum, values} -> enum_schema({:enum, values}, opts)
type -> scalar_schema(type, opts)
end
schema =
if Keyword.get(opts, :required),
do: schema,
else: %{"anyOf" => [schema, %{"type" => "null"}]}
{Atom.to_string(name), schema}
end)
|> Enum.into(%{})
end
@spec array_schema({atom(), any()}, Keyword.t()) :: map()
defp array_schema({:array, inner_type}, opts) do
schema =
case inner_type do
{:enum, values} ->
%{"type" => "array", "items" => enum_schema({:enum, values}, [])}
inner_type when inner_type in @scalar_types ->
%{"type" => "array", "items" => %{"type" => Atom.to_string(inner_type)}}
_ ->
%{"type" => "array", "items" => inner_type.json_schema().schema}
end
opts =
opts
|> Keyword.take([:min_items, :max_items, :unique_items])
|> Enum.map(fn {key, value} ->
case key do
:min_items -> {"minItems", value}
:max_items -> {"maxItems", value}
:unique_items -> {"uniqueItems", value}
_ -> {Atom.to_string(key), value}
end
end)
|> Enum.into(%{})
Map.merge(schema, opts)
end
@spec enum_schema({:enum, [atom()] | Keyword.t()}, Keyword.t()) :: map()
defp enum_schema({:enum, values}, _opts) do
if Keyword.keyword?(values) do
%{"type" => "integer", "enum" => Keyword.values(values)}
else
%{"type" => "string", "enum" => Enum.map(values, &Atom.to_string/1)}
end
end
@spec scalar_schema(atom(), Keyword.t()) :: map()
defp scalar_schema(type, opts) do
case type do
:binary -> string_schema(opts)
:binary_id -> string_schema(opts)
:float -> number_schema(opts)
:id -> integer_schema(opts)
:integer -> integer_schema(opts)
:string -> string_schema(opts)
:map -> object_schema(opts)
:decimal -> string_schema(opts)
:date -> string_schema(Keyword.put(opts, :format, "date"))
:time -> string_schema(Keyword.put(opts, :format, "time"))
:time_usec -> string_schema(Keyword.put(opts, :format, "time"))
:naive_datetime -> string_schema(Keyword.put(opts, :format, "date-time"))
:naive_datetime_usec -> string_schema(Keyword.put(opts, :format, "date-time"))
:utc_datetime -> string_schema(Keyword.put(opts, :format, "date-time"))
:utc_datetime_usec -> string_schema(Keyword.put(opts, :format, "date-time"))
type -> %{"type" => Atom.to_string(type)}
end
end
@spec string_schema(Keyword.t()) :: map()
defp string_schema(opts) do
opts =
opts
|> Keyword.take([:format, :min_length, :max_length, :pattern])
|> Enum.map(fn {key, value} ->
case key do
:min_length -> {"minLength", value}
:max_length -> {"maxLength", value}
_ -> {Atom.to_string(key), value}
end
end)
|> Enum.into(%{})
Map.put(opts, "type", "string")
end
@spec object_schema(Keyword.t()) :: map()
defp object_schema(opts) do
opts =
opts
|> Keyword.take([
:additional_properties,
:property_names,
:min_properties,
:max_properties
])
|> Enum.map(fn {key, value} ->
case key do
:additional_properties -> {"additionalProperties", value}
:property_names -> {"propertyNames", value}
:min_properties -> {"minProperties", value}
:max_properties -> {"maxProperties", value}
_ -> {Atom.to_string(key), value}
end
end)
|> Enum.into(%{})
Map.put(opts, "type", "object")
end
@spec integer_schema(Keyword.t()) :: map()
defp integer_schema(opts) do
%{number_schema(opts) | "type" => "integer"}
end
@spec number_schema(Keyword.t()) :: map()
defp number_schema(opts) do
opts =
opts
|> Keyword.take([
:multiple_of,
:minimum,
:exclusive_minimum,
:maximum,
:exclusive_maximum
])
|> Enum.map(fn {key, value} ->
case key do
:multiple_of -> {"multipleOf", value}
:exclusive_minimum -> {"exclusiveMinimum", value}
:exclusive_maximum -> {"exclusiveMaximum", value}
_ -> {Atom.to_string(key), value}
end
end)
|> Enum.into(%{})
Map.put(opts, "type", "number")
end
@spec validate_json(map()) :: {:ok, map()} | {:error, any()}
def validate_json(map) do
with {:ok, encoded} <- Jason.encode(map),
{:ok, json} <- Jason.decode(encoded),
:ok <- ExJsonSchema.Validator.validate(json_schema(), json) do
{:ok, map}
end
end
## ---------------------------------------------------------------------------
## Utilities
## ---------------------------------------------------------------------------
@doc """
Returns the module attribute `@properties`.
iex> Post.properties()
%{
description: {:string, []},
likes: {:integer, [required: true, minimum: 0]},
title: {:string, [required: true]}
}
"""
@spec properties() :: map()
def properties(), do: @properties
@spec all_fields() :: [atom()]
defp all_fields(), do: all_fields(properties())
@spec all_fields(map()) :: [atom()]
defp all_fields(props) do
props
|> Enum.map(fn {name, _} -> name end)
end
@spec all_non_embeds() :: [atom()]
defp all_non_embeds(), do: all_non_embeds(properties())
@spec all_non_embeds(map()) :: [atom()]
defp all_non_embeds(props) do
props
|> Enum.filter(fn {_name, {type, _opts}} -> !embedded?(type) end)
|> Enum.map(fn {name, _} -> name end)
end
@spec optional_fields() :: [atom()]
defp optional_fields(), do: optional_fields(properties())
@spec optional_fields(map()) :: [atom()]
defp optional_fields(props) do
props
|> Enum.filter(fn {_name, {_type, opts}} ->
!Keyword.get(opts, :required, false)
end)
|> Enum.map(fn {name, _} -> name end)
end
@spec required_fields() :: [atom()]
defp required_fields(), do: required_fields(properties())
@spec required_fields(map()) :: [atom()]
defp required_fields(props) do
props
|> Enum.filter(fn {_name, {_type, opts}} ->
Keyword.get(opts, :required, false)
end)
|> Enum.map(fn {name, _} -> name end)
end
@spec required_non_embeds() :: [atom()]
defp required_non_embeds(), do: required_non_embeds(properties())
@spec required_non_embeds(map()) :: [atom()]
defp required_non_embeds(props) do
props
|> Enum.filter(fn {_name, {type, _opts}} -> !embedded?(type) end)
|> Enum.filter(fn {_name, {_type, opts}} ->
Keyword.get(opts, :required, false)
end)
|> Enum.map(fn {name, _} -> name end)
end
@spec embedded?(any()) :: boolean()
defp embedded?(type) do
case type do
{:object, _} ->
true
{:array, inner_type} ->
case inner_type do
{:enum, values} -> false
_ -> !Enum.member?(@scalar_types, inner_type)
end
{:enum, values} ->
false
_ ->
false
end
end
end
end
end