Injecto behaviour (injecto v0.1.4)

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

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 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

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

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"}
]

Link to this section Summary

Callbacks

Returns an Ecto changeset

Validates and returns an ex_json_schema schema

Returns the struct with the fields populated with nils

Returns a result of a validated Elixir struct or the validation errors

Calls parse/2 on a list of maps. Returns :ok if all maps are parsed correctly.

Serialises a map, and validates the deserialised result against the JSON schema

Link to this section Callbacks

Link to this callback

changeset(struct, map)

@callback changeset(
  struct(),
  map()
) :: %Ecto.Changeset{
  action: term(),
  changes: term(),
  constraints: term(),
  data: term(),
  empty_values: term(),
  errors: term(),
  filters: term(),
  params: term(),
  prepare: term(),
  repo: term(),
  repo_opts: term(),
  required: term(),
  types: term(),
  valid?: term(),
  validations: term()
}

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 json_schema() :: %ExJsonSchema.Schema.Root{
  custom_format_validator: term(),
  definitions: term(),
  location: term(),
  refs: term(),
  schema: term(),
  version: term()
}

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 new() :: struct()

Returns the struct with the fields populated with nils:

iex> Post.new()
%Post{title: nil, description: nil, likes: nil}
@callback parse(map(), Keyword.t()) :: {:ok, struct()} | {:error, any()}

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"}]
Link to this callback

parse_many(list, t)

@callback parse_many([map()], Keyword.t()) :: {:ok, [struct()]} | {:error, any()}

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"}]]
Link to this callback

validate_json(map)

@callback validate_json(map()) :: {:ok, map()} | {:error, any()}

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"}
]