# EctoCommand
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!
## Installation
To install EctoCommand, add it as a dependency to your project by adding `ecto_command` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:ecto_command, "~> 0.1.0"}
]
end
```
## Why Ecto?
"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](https://github.com/elixir-ecto/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:
```elixir
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"})
```
## Usage
### Defining a Command
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](#params-definition), and the `internal` macro is used to [define which parameters are internally set](#internal-fields).
```elixir
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
```
### Executing a Command
In order to execute the command, you need to call the `execute/2` function providing a raw parameter data map and, optionally, some metadata.
```elixir
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.
### Handling Errors
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.
```elixir
{:error, %Ecto.Changeset{valid?: false}} = MyApp.Commands.CreatePost.execute.execute(%{})
```
Returning an invalid `Ecto.Changeset` is particularly useful when working with Phoenix forms.
## Main goals and functionality of EctoCommand
EctoCommand aims to provide the following functionality:
- [An easy way to define the fields in a command (`param` macro).](#params-definition)
- [A simple and compact way of specifying how these fields should be validated (`param` macro options)](#validations-and-constraints-definition)
- [Defining the fields that need to be part of the command but can't be set from the outside (`internal` macro)](#internal-fields)
- [Validation of the params received from the outside](#validations)
- [Easy hooking of middleware to add functionality (like audit)](#using-middlewares-in-ectocommand)
- [Automatic generation of OpenApi documentation](#automated-generation-of-openapi-documentation)
## Params definition
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](https://hexdocs.pm/ecto/Ecto.Schema.html) 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.
## Validations and constraints definition
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](https://hexdocs.pm/ecto/Ecto.Changeset.html).
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:
```Elixir
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:
```Elixir
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:
```elixir
param :email, :string, format: ~r/@/
```
or data and options in this way:
```elixir
param :email, :string, format: {~r/@/, message: "my custom error message"}
```
## Internal fields
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.
```elixir
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.
## Subparams
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.
```elixir
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:
```elixir
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.
## Validations
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`.
## Using Middlewares in EctoCommand
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:
```elixir
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](https://github.com/silvadanilo/ecto_command/blob/master/lib/ecto_command/middleware/pipeline.ex) 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.
### Configuring Middlewares
There are two ways to specify which middleware should be executed:
1. **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:
```elixir
config :ecto_command, :middlewares,
{MyApp.MyFirstMiddleware, a_middleware_option: :foo},
{MyApp.MySecondMiddleware, a_middleware_option: :bar}
```
2. **Command-level configuration:**
You can also specify middleware to be executed for a specific command by adding the `use` directive in the command module:
```elixir
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.
## Automated generation of OpenAPI documentation
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:
```elixir
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:
```elixir
use EctoCommand.OpenApi, title: "CustomTitle", type: :object
```
Then in your controller you can simply pass your command module to the request body specs:
```elixir
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](https://github.com/open-api-spex/open_api_spex) library.
## Contributing
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.
## License
This library is licensed under the MIT license. See [LICENSE](https://github.com/silvadanilo/ecto_command/blob/master/LICENSE) for more details.