EctoCommand is a toolkit for mapping, validating, and executing commands received from any source. It provides a simple and flexible way to define and execute commands in Elixir. With support for validation, middleware, and automatic OpenAPI documentation generation, it's a valuable tool for building scalable and maintainable Elixir applications. We hope you find it useful!
To install EctoCommand, add it as a dependency to your project by adding ecto_command
to your list of dependencies in mix.exs
:
def deps do
[
{:ecto_command, "~> 0.1.0"}
]
end
"Ecto is also commonly used to map data from any source into Elixir structs, whether they are backed by a database or not."
Based on this definition of the Ecto library, EctoCommand utilizes the "embedded_schema" functionality to map input data into an Elixir data structure to be used as a "command".
This means that EctoCommand is not tied to your persistence layer.
As a result, you can easily convert data received from any source into a valid command struct, which can be executed easily. Additionally, you can also add functionality through middlewares to the execution pipeline.
Here is an example of a command definition:
defmodule SampleCommand do
use EctoCommand
command do
param :id, :string
param :name, :string, required: true, length: [min: 2, max: 255]
param :email, :string, required: true, format: ~r/@/, length: [min: 6]
param :count, :integer, required: true, number: [greater_than_or_equal_to: 18, less_than: 100]
param :password, :string, required: true, length: [greater_than_or_equal_to: 8, less_than: 100], trim: true
internal :hashed_password, :string
end
def execute(%SampleCommand{} = command) do
# ....
:ok
end
def fill(:hashed_password, _changeset, %{"password" => password}, _metadata) do
:crypto.hash(:sha256, password) |> Base.encode64()
end
end
:ok = SampleCommand.execute(%{id: "aa-bb-cc", name: "foobar", email: "foo@bar.com", count: 22, password: "mysecret"})
To define a new command, create a module that includes the EctoCommand
behaviour and implements the execute/1
function.
The execute/1
function takes the command structure as an argument.
The command
macro is used to define the parameters included in the command.
The param
macro is used to define which parameters are accepted by the command, and the internal
macro is used to define which parameters are internally set.
defmodule MyApp.Commands.CreatePost do
use EctoCommand
alias MyApp.PostRepository
command do
param :title, :string, required: true, length: [min: 3, max: 255]
param :body, :string, required: true, length: [min: 3]
internal :slug, :string
internal :author, :string
end
def execute(%__MODULE__{} = command) do
PostRepository.insert(%{
title: command.title,
body: command.body,
slug: command.slug
})
end
def fill(:slug, _changeset, %{"title" => title}, _metadata) do
Slug.slufigy(title)
end
def fill(:author, _changeset, _params, %{"triggered_by" => triggered_by}) do
triggered_by
end
def fill(:author, changeset, _params, _metadata) do
Ecto.Changeset.add_error(changeset, :triggered_by, "triggered_by metadata info is missing")
end
end
In order to execute the command, you need to call the execute/2
function providing a raw parameter data map and, optionally, some metadata.
params = %{title: "New amazing post", body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut eget ante odio."}
metadata = %{triggered_by: "writer"}
:ok = MyApp.Commands.CreatePost.execute(params, metadata)
This data is validated, and if it passes all validation rules, a new command structure is created and passed as an argument to the execute/1
function defined inside your command module.
If a required parameter is missing or has an invalid value, the EctoCommand.execute/2
function will return an error tuple with an invalid Ecto.Changeset
structure. You can then use the changeset to return errors to the client or perform other actions.
{:error, %Ecto.Changeset{valid?: false}} = MyApp.Commands.CreatePost.execute.execute(%{})
Returning an invalid Ecto.Changeset
is particularly useful when working with Phoenix forms.
EctoCommand aims to provide the following functionality:
- An easy way to define the fields in a command (
param
macro). - A simple and compact way of specifying how these fields should be validated (
param
macro options) - Defining the fields that need to be part of the command but can't be set from the outside (
internal
macro) - Validation of the params received from the outside
- Easy hooking of middleware to add functionality (like audit)
- Automatic generation of OpenApi documentation
To define the params that a command should accept, use the param
macro.
The param
macro is based on the field
macro of Ecto.Schema and defines a field in the schema with a given name and type. You can pass all the options supported by the field
macro. Afterwards, each defined param
is cast with the "external" data received.
In addition to those options, the param
macro accepts a set of other options that indicate how external data for that field should be validated.
These options are applied to the intermediate Changeset created in order to validate data.
These options are mapped into validate_*
methods of the Ecto.Changeset.
For example, if you want a command to have a "name" field that is required and has a length between 2 and 255 chars, you can write:
param :name, :string, required: true, length: [min: 2, max: 255]
This means that the command will have a name
field, which will be cast to a string
type. These functions will be called during the changeset validation:
changeset
|> validate_required([:name])
|> validate_length(:name, min: 2, max: 255)
for validators that accept both data and options, you could pass just data like:
param :email, :string, format: ~r/@/
or data and options in this way:
param :email, :string, format: {~r/@/, message: "my custom error message"}
Sometimes, you might need to define internal fields, like hashed_password
or triggered_by
, which are not supposed to be set externally. To define such fields, you can use the internal
macro.
command do
param :password, :string, required: true, length: [greater_than_or_equal_to: 8, less_than: 100], trim: true
internal :hased_password, :string
internal :triggered_by, :string
end
def fill(:hased_password, _changeset, %{"password" => password}, _metadata) do
:crypto.hash(:sha256, password) |> Base.encode64()
end
def fill(:triggered_by, _changeset, _params, %{"triggered_by": triggered_by}) do
triggered_by
end
def fill(:triggered_by, changeset, _params, _metadata) do
Ecto.Changeset.add_error(changeset, :triggered_by, "triggered_by metadata info is missing")
end
These fields will be ignored during the "cast process". Instead, you need to define a public fill/4
function to populate them. The fill/4
function takes four arguments: the name of the field, the current temporary changeset, the parameters received from external sources, and additional metadata. You can choose to return the value that will populate the field or the updated changeset. Both options are acceptable, but returning the changeset is particularly useful if you want to add errors to it.
Your API can accept structured data by utilizing "subparams" within the request.
To enable the submission of complex information in a hierarchical format, you can utilize the embeds_one/3
macro. This macro allows you to define a field that is composed of other fields, which are subsequently validated.
The arguments for the macro are: the name of the main parameter, the name of the embedded module (which can either exist or be created), and optionally, the parameters of the new embedded module.
In the following example, we demonstrate how you can provide a name, surname, and an address field, which is composed of street, city, and zip_code.
defmodule SampleCommand do
use EctoCommand
command do
param :name, :string
param :surname, :string
embeds_one :address, Address do
param :street, :string, required: true
param :city, :string, required: true, length: [min: 10]
param :zip, :string, required: true
end
end
end
In the above example, a new SampleCommand.Address
module will be created.
Alternatively, you can achieve the same behavior by writing:
defmodule SampleCommand.Address do
use EctoCommand
command do
param :street, :string, required: true
param :city, :string, required: true, length: [min: 10]
param :zip, :string, required: true
end
end
defmodule SampleCommand do
use EctoCommand
command do
param :name, :string
param :surname, :string
embeds_one :address, Address
end
end
In the second example, an existing module is embedded, providing the same functionality. Feel free to choose the approach that best suits your needs.
All parameters are validated in order to instantiate the command structure.
When you use EctoCommand
inside your module, three methods are added:
changeset/2
new/2
execute/2
All three methods take parameter data and metadata as arguments.
The changeset/2
function performs validation and other operations, and returns a valid or invalid Ecto.Changeset
.
The new/2
function internally calls the changeset/2
function and returns either the valid command structure or the invalid Ecto.Changeset
.
The execute/2
function internally calls the new/2
function and then calls the execute/1
function (which should be defined inside the command module), or returns the invalid Ecto.Changeset
.
EctoCommand supports middlewares, which allow you to modify the behavior of a command before and/or after its execution.
A middleware is a module that implements the EctoCommand.Middleware
behavior. Here's how you can use middlewares in your EctoCommand project:
defmodule MyApp.MyMiddleware do
@behaviour EctoCommand.Middleware
@impl true
def before_execution(pipeline, _opts) do
pipeline
|> Pipeline.assign(:some_data, :some_value)
|> Pipeline.update!(:command, fn command -> %{command | name: "updated-name"} end)
end
@impl true
def after_execution(pipeline, _opts) do
Logger.debug("Command executed successfully", command: pipeline.command, result: Pipeline.response(pipeline))
pipeline
end
@impl true
def after_failure(pipeline, _opts) do
Logger.error("Command execution fails", command: pipeline.command, error: Pipeline.response(pipeline))
pipeline
end
@impl true
def invalid(pipeline, _opts) do
Logger.error("invalid params received", params: pipeline.params, error: Pipeline.response(pipeline))
pipeline
end
end
Each method takes two arguments: an EctoCommand.Pipeline structure and the options you set for that middleware.
The method should return an EctoCommand.Pipeline structure.
before_execution/2
is executed before command execution, and only if the command is valid. In this function, if you'd like, you might update the command that will be executed.after_execution/2
is executed following a sucessful command execution. In this function, if you'd like, you could alter the returned value.after_failure/2
is executed after a failed command execution. In this function you could, if you wish, also update the returned value.invalid/2
is executed when data used to build the command is invalid.
There are two ways to specify which middleware should be executed:
- Global configuration:
You can set up a list of middleware to be executed for every command by adding the following to your application's configuration:
config :ecto_command, :middlewares,
{MyApp.MyFirstMiddleware, a_middleware_option: :foo},
{MyApp.MySecondMiddleware, a_middleware_option: :bar}
- Command-level configuration:
You can also specify middleware to be executed for a specific command by adding the use
directive in the command module:
defmodule MyApp.MyCommand do
use EctoCommand
use MyApp.MyFirstMiddleware, a_middleware_option: :foo
use MyApp.MySecondMiddleware, a_middleware_option: :bar
....
end
In this case, the specified middleware is executed only for that particular command.
EctoCommand has a built-in feature that automatically generates OpenAPI documentation based on the parameters and validation rules defined in your command modules.
This can save you a significant amount of time and effort in writing and maintaining documentation, particularly if you have a large number of commands.
To generate the OpenAPI schema, you can use the EctoCommand.OpenApi
module:
use EctoCommand.OpenApi
By default, the schema's title is the fully qualified domain name (FQDN) of the module, and the default type is :object
.
However, you can override the defaults by passing options to the use
module:
use EctoCommand.OpenApi, title: "CustomTitle", type: :object
Then in your controller you can simply pass your command module to the request body specs:
defmodule MyAppWeb.PostController do
use MyAppWeb, :controller
use OpenApiSpex.ControllerSpecs
alias MyApp.Commands.CreatePost
operation :create_post,
summary: "Create a Post",
request_body: {"Post params", "application/json", CreatePost},
responses: [
ok: {"Post response", "application/json", []},
bad_request: {"Post response", "application/json", []}
]
def create_post(conn, params) do
...
end
For more information on serving the Swagger UI, please refer to the readme of the open-api-spex library.
Contributions are always welcome! Please feel free to submit a pull request or create an issue if you find a bug or have a feature request.
This library is licensed under the MIT license. See LICENSE for more details.