Calcinator
Calcinator provides a standardized interface for processing JSONAPI request that is transport neutral. CSD uses it for both API controllers and RPC servers.
Calcinator uses Alembic to validate JSONAPI documents passed to the action functions
in Calcinator. Calcinator supports the JSONAPI CRUD-style actions:
createdeleteget_related_resourceindexshowshow_relationshipupdate
Each action expects to be passed a %Calcinator{}. The struct allow Calcinator to support converting JSONAPI
includes to associations (associations_by_include), authorization (authorization_module and subject),
Ecto.Schema.t interaction (resources_module), and JSONAPI document rendering (view_module).
Authorization
%Calcinator{}authorization_modules need to implement the Calcinator.Authorization behaviour.
can?(subject, action, target) :: booleanfilter_associations_can(target, subject, action) :: targetfilter_can(targets :: [target], subject, action) :: [target]
The can?(suject, action, target) :: boolean matches the signature of the Canada protocol, but it is not required.
Resources
Calcinator.Resources is a behaviour to supporting standard CRUD actions on an Ecto-based backing store. This backing
store does not need to be a database that uses Ecto.Repo. At CSD, we use Calcinator.Resources to hide the
differences between Ecto.Repo backed Ecto.Schema.t and RPC backed Ecto.Schema.t (where we use Ecto to do the
type casting.)
Because Calcinator.Resources need to work as an interface for both Ecto.Repo and RPC backed resources,
the callbacks and returns need to work for both, so all Calcinator.Resources implementations need to support
allow_sandbox_access and sandboxed? used for concurrent Ecto.Repo tests, but they also can return RPC error
messages like {:error, :bad_gateway} and {:error, :timeout}.
Pagination
The list callback instead of returning just the list of resources, also accepts and returns (optional) pagination
information. The pagination param format is documented in Calcinator.Resources.Page.
In addition to pagination in page, Calcinator.Resources.query_options supports associations for JSONAPI includes
(after being converted using %Calcinator{}associations_by_include), filters for JSONAPI filters that are passed
through directly, and sorts for JSONAPI sort.
Installation
If available in Hex, the package can be installed as:
-
Add
calcinatorto your list of dependencies inmix.exs:
```elixir
def deps do
[{:calcinator, "~> 1.0.0"}]
end
```-
Ensure
calcinatoris started before your application:
```elixir
def application do
[applications: [:calcinator]]
end
```Usage
Phoenix
Calcinator.Controller uses Calcinator.Resources, which is transport-agnostic, so you can use it to access multiple
backing stores. CSD itself, uses it to access PostgreSQL database owned by the project using Ecto and to access
remote data over RabbitMQ.
Database
If you want to use Calcinator to access records in a database, you can use Ecto
Ecto.Schema modules
MyApp.Author and MyAuthor.Post are standard use Ecto.Schema modules. MyApp is a separate OTP
application in the umbrella project.
defmodule MyApp.Author do
@moduledoc """
The author of `MyApp.Post`s
"""
use Ecto.Schema
schema "authors" do
field :name, :string
field :password, :string, virtual: true
field :password_confirmation, :string, virtual: true
timestamps
has_many :posts, RemoteApp.Post, foreign_key: :author_id
end
end
-- apps/my_app/lib/my_app/author.ex
defmodule MyApp.Author do
@moduledoc """
Posts by a `MyApp.Author`.
"""
use Ecto.Schema
schema "posts" do
field :text, :string
timestamps
belongs_to :author, MyApp.Author
end
end
-- apps/my_app/lib/my_app/post.ex
Resources module
defmodule MyApp.Posts do
@moduledoc """
Retrieves `%MyApp.Post{}` from `MyApp.Repo`
"""
use Calcinator.Resources.Ecto.Repo
# Functions
## Calcinator.Resources.Ecto.Repo callbacks
def repo, do: MyApp.Repo
endView Module
Calcinator relies on JaSerializer to define view module
defmodule MyAppWeb.PostView do
@moduledoc """
Handles encoding the Post model into JSON:API format.
"""
alias MyApp.Post
use MyAppWeb.Web, :view
use Calcinator.JaSerializer.PhoenixView,
phoenix_view_module: __MODULE__
# Attributes
attributes ~w(inserted_at
text
updated_at)a
# Location
location "/posts/:id"
# Relationships
has_one :author,
serializer: MyAppWeb.AuthorView
# Functions
def relationships(post = %Post{}, conn) do
partner
|> super(conn)
|> Enum.filter(relationships_filter(post))
|> Enum.into(%{})
end
def type(_data, _conn), do: "posts"
## Private Functions
def relationships_filter(%Post{author: %Ecto.Association.NotLoaded{}}) do
fn {name, _relationship} ->
name != :author
end
end
def relationships_filter(_) do
fn {_name, _relationship} ->
true
end
end
end
--- apps/my_app_web/lib/my_app_web/post_view.ex
Controller Module
defmodule MyAppWeb.PostController do
@moduledoc """
Allows reading of Post that are fetched from Remote Server via RPC.
"""
use MyAppWeb.Web, :controller
alias InterpreterServerWeb.Controller
use Controller.Resources,
actions: ~w(index show)a,
configuration: %Calcinator{
authorization_module: MyAppWeb.Authorization,
ecto_schema_module: MyApp.Post,
resources_module: MyApp.Posts,
view_module: MyAppWeb.PostView
}
end
--- apps/my_app_web/lib/my_app_web/post_controller.ex
RabbitMQ
If you want to use Calcinator over RabbitMQ, use Retort: it's
Retort.Resources implements the Calcinator.Resources behaviour.
Ecto.Schema modules
RemoteApp.Author and RemoteApp.Post are standard use Ecto.Schema modules. RemoteApp is a separate OTP
application in the umbrella project.
defmodule RemoteApp.Author do
@moduledoc """
The author of `RemoteApp.Post`s
"""
use Ecto.Schema
schema "authors" do
field :name, :string
field :password, :string, virtual: true
field :password_confirmation, :string, virtual: true
timestamps
has_many :posts, RemoteApp.Post, foreign_key: :author_id
end
end
-- apps/remote_app/lib/remote_app/author.ex
defmodule RemoteApp.Author do
@moduledoc """
Posts by a `RemoteApp.Author`.
"""
use Ecto.Schema
schema "posts" do
field :text, :string
timestamps
belongs_to :author, RemoteApp.Author
end
end
-- apps/remote_app/lib/remote_app/post.ex
Client module
Define a module to setup a Retort.Generic.Client (you can also inline this at Client.Post.start_link() below, but
we find the module useful for tests.
defmodule RemoteApp.Client.Post do
@moduledoc """
Client for accessing Posts on remote-server
"""
alias RemoteApp.{Author, Post}
# Functions
def queue, do: "remote_server_post"
def start_link(opts \\ []) do
Retort.Client.Generic.start_link(
opts ++ [
ecto_schema_module_by_type: %{
"authors" => Author,
"posts" => Post
},
queue: queue,
type: "posts"
]
)
end
end
-- apps/remote_app/lib/remote_app/client/post.ex
Resources module
Define a module that use Retort.Resources to get the Ecto.Schema structs using Retort.Generic.Client
defmodule RemoteApp.Posts do
@moduledoc """
Retrieves `%RemoteApp.Post{}` over RPC
"""
alias RemoteApp.Client
alias RemoteApp.Post
require Ecto.Query
import Ecto.Changeset, only: [cast: 3]
use Retort.Resources
# Constants
@default_timeout 5_000 # milliseconds
@optional_fields ~w()a
@required_fields ~w()a
@allowed_fields @optional_fields ++ @required_fields
# Functions
## Retort.Resources callbacks
def association_to_include(:author), do: "author"
def client_start_link() do
__MODULE__
|> Retort.Resources.client_start_link_options()
|> Client.Post.start_link()
end
def ecto_schema_module(), do: Post
## Resources callbacks
@doc """
Creates a changeset that updates `post` with `params`.
"""
@spec changeset(%Post{}, Resoures.params) :: Ecto.Changeset.t
def changeset(post, params), do: cast(post, params, @allowed_fields)
def sandboxed?(), do: LocalApp.Repo.sandboxed?()
end
-- apps/remote_app/lib/remote_app/posts
View Module
Calcinator relies on JaSerializer to define view module
defmodule LocalAppWeb.PostView do
@moduledoc """
Handles encoding the Post model into JSON:API format.
"""
alias RemoteApp.Post
use LocalAppWeb.Web, :view
use Calcinator.JaSerializer.PhoenixView,
phoenix_view_module: __MODULE__
# Attributes
attributes ~w(inserted_at
text
updated_at)a
# Location
location "/posts/:id"
# Relationships
has_one :author,
serializer: LocalAppWeb.AuthorView
# Functions
def relationships(post = %Post{}, conn) do
partner
|> super(conn)
|> Enum.filter(relationships_filter(post))
|> Enum.into(%{})
end
def type(_data, _conn), do: "posts"
## Private Functions
def relationships_filter(%Post{author: %Ecto.Association.NotLoaded{}}) do
fn {name, _relationship} ->
name != :author
end
end
def relationships_filter(_) do
fn {_name, _relationship} ->
true
end
end
end
--- apps/local_app_web/lib/local_app_web/post_view.ex
Controller Module
defmodule LocalAppWeb.PostController do
@moduledoc """
Allows reading of Post that are fetched from Remote Server via RPC.
"""
use LocalAppWeb.Web, :controller
alias InterpreterServerWeb.Controller
use Controller.Resources,
actions: ~w(index show)a,
configuration: %Calcinator{
authorization_module: LocalAppWeb.Authorization,
ecto_schema_module: RemoteApp.Post,
resources_module: RemoteApp.Posts,
view_module: LocalAppWeb.PostView
}
end
--- apps/local_app_web/lib/local_app_web/post_controller.ex