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:
defdepsdo[{: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:
defmoduleSampleCommanddouseEctoCommandcommanddoparam:id,:stringparam: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: trueinternal:hashed_password,:stringenddefexecute(%SampleCommand{}=command)do# ....:okenddeffill(:hashed_password,_changeset,%{"password"=>password},_metadata)do:crypto.hash(:sha256,password)|>Base.encode64()endend: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.
defmoduleMyApp.Commands.CreatePostdouseEctoCommandaliasMyApp.PostRepositorycommanddoparam:title,:string,required: true,length: [min: 3,max: 255]param:body,:string,required: true,length: [min: 3]internal:slug,:stringinternal:author,:stringenddefexecute(%__MODULE__{}=command)doPostRepository.insert(%{title: command.title,body: command.body,slug: command.slug})enddeffill(:slug,_changeset,%{"title"=>title},_metadata)doSlug.slufigy(title)enddeffill(:author,_changeset,_params,%{"triggered_by"=>triggered_by})dotriggered_byenddeffill(:author,changeset,_params,_metadata)doEcto.Changeset.add_error(changeset,:triggered_by,"triggered_by metadata info is missing")endendIn 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 (
parammacro). - A simple and compact way of specifying how these fields should be validated (
parammacro options) - Defining the fields that need to be part of the command but can't be set from the outside (
internalmacro) - 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.
commanddoparam:password,:string,required: true,length: [greater_than_or_equal_to: 8,less_than: 100],trim: trueinternal:hased_password,:stringinternal:triggered_by,:stringenddeffill(:hased_password,_changeset,%{"password"=>password},_metadata)do:crypto.hash(:sha256,password)|>Base.encode64()enddeffill(:triggered_by,_changeset,_params,%{"triggered_by": triggered_by})dotriggered_byenddeffill(:triggered_by,changeset,_params,_metadata)doEcto.Changeset.add_error(changeset,:triggered_by,"triggered_by metadata info is missing")endThese 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.
defmoduleSampleCommanddouseEctoCommandcommanddoparam:name,:stringparam:surname,:stringembeds_one:address,Addressdoparam:street,:string,required: trueparam:city,:string,required: true,length: [min: 10]param:zip,:string,required: trueendendendIn the above example, a new SampleCommand.Address module will be created.
Alternatively, you can achieve the same behavior by writing:
defmoduleSampleCommand.AddressdouseEctoCommandcommanddoparam:street,:string,required: trueparam:city,:string,required: true,length: [min: 10]param:zip,:string,required: trueendenddefmoduleSampleCommanddouseEctoCommandcommanddoparam:name,:stringparam:surname,:stringembeds_one:address,AddressendendIn 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/2new/2execute/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:
defmoduleMyApp.MyMiddlewaredo@behaviourEctoCommand.Middleware@impltruedefbefore_execution(pipeline,_opts)dopipeline|>Pipeline.assign(:some_data,:some_value)|>Pipeline.update!(:command,fncommand->%{command|name: "updated-name"}end)end@impltruedefafter_execution(pipeline,_opts)doLogger.debug("Command executed successfully",command: pipeline.command,result: Pipeline.response(pipeline))pipelineend@impltruedefafter_failure(pipeline,_opts)doLogger.error("Command execution fails",command: pipeline.command,error: Pipeline.response(pipeline))pipelineend@impltruedefinvalid(pipeline,_opts)doLogger.error("invalid params received",params: pipeline.params,error: Pipeline.response(pipeline))pipelineendendEach 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/2is 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/2is executed following a sucessful command execution. In this function, if you'd like, you could alter the returned value.after_failure/2is executed after a failed command execution. In this function you could, if you wish, also update the returned value.invalid/2is 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:
defmoduleMyApp.MyCommanddouseEctoCommanduseMyApp.MyFirstMiddleware,a_middleware_option: :foouseMyApp.MySecondMiddleware,a_middleware_option: :bar....endIn 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:
useEctoCommand.OpenApiBy 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:
useEctoCommand.OpenApi,title: "CustomTitle",type: :objectThen in your controller you can simply pass your command module to the request body specs:
defmoduleMyAppWeb.PostControllerdouseMyAppWeb,:controlleruseOpenApiSpex.ControllerSpecsaliasMyApp.Commands.CreatePostoperation: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",[]}]defcreate_post(conn,params)do...endFor 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.