├── .formatter.exs ├── .github └── workflows │ ├── ci.yml │ └── publish_to_hex.yml ├── .gitignore ├── .tool-versions ├── LICENSE ├── README.md ├── lib ├── ecto_command.ex ├── ecto_command │ └── middleware │ │ ├── middleware.ex │ │ └── pipeline.ex └── open_api │ ├── open_api.ex │ └── type.ex ├── mix.exs ├── mix.lock └── test ├── support └── command_case.ex ├── test_helper.exs └── unit └── command ├── creation_test.exs ├── execution_test.exs ├── extra_validators_test.exs ├── middleware └── pipeline_test.exs ├── middleware_test.exs ├── open_api ├── open_api_test.exs └── type_test.exs ├── options_test.exs └── validators_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | import_deps: [:ecto, :ecto_sql], 4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 5 | locals_without_parens: [ 6 | param: :*, 7 | internal: :*, 8 | command: :* 9 | ], 10 | line_length: 120 11 | ] 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Elixir CI 7 | 8 | on: 9 | push: 10 | branches: [ "master" ] 11 | pull_request: 12 | branches: [ "master" ] 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | build: 19 | 20 | name: Build and test 21 | runs-on: ubuntu-latest 22 | 23 | strategy: 24 | matrix: 25 | elixir: [ '1.18.1' ] 26 | otp: [ '27.2' ] 27 | 28 | steps: 29 | - uses: actions/checkout@v3 30 | 31 | - name: Set up Elixir 32 | uses: erlef/setup-beam@v1 33 | with: 34 | elixir-version: ${{ matrix.elixir }} 35 | otp-version: ${{ matrix.otp }} 36 | 37 | - name: Cache dependencies 38 | uses: actions/cache@v3 39 | with: 40 | path: deps 41 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 42 | restore-keys: | 43 | ${{ runner.os }}-mix- 44 | 45 | - name: Install dependencies 46 | run: mix deps.get 47 | 48 | - name: Run tests 49 | run: mix test 50 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_hex.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Hex.pm 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v3 14 | 15 | - name: Set up Elixir 16 | uses: erlef/setup-beam@v1 17 | with: 18 | elixir-version: '1.18.1' 19 | otp-version: '27.2' 20 | 21 | - name: Install Hex and Rebar 22 | run: | 23 | mix local.hex --force 24 | mix local.rebar --force 25 | 26 | - name: Cache dependencies 27 | uses: actions/cache@v3 28 | with: 29 | path: deps 30 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 31 | restore-keys: | 32 | ${{ runner.os }}-mix- 33 | 34 | - name: Install dependencies 35 | run: mix deps.get 36 | 37 | - name: Run tests 38 | run: mix test 39 | 40 | - name: Publish to Hex.pm 41 | env: 42 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 43 | run: | 44 | mix hex.publish --yes 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | command_ex-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.2 2 | elixir 1.18.1 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Danilo Silva 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EctoCommand 2 | 3 | EctoCommand is a toolkit for mapping, validating, and executing commands received from any source. 4 | 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! 5 | 6 | ## Installation 7 | 8 | To install EctoCommand, add it as a dependency to your project by adding `ecto_command` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:ecto_command, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | ## Why Ecto? 19 | 20 | "Ecto is also commonly used to map data from any source into Elixir structs, whether they are backed by a database or not." 21 | 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". 22 | This means that **EctoCommand is not tied to your persistence layer**. 23 | 24 | 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. 25 | 26 | Here is an example of a command definition: 27 | 28 | ```elixir 29 | defmodule SampleCommand do 30 | use EctoCommand 31 | 32 | command do 33 | param :id, :string 34 | param :name, :string, required: true, length: [min: 2, max: 255] 35 | param :email, :string, required: true, format: ~r/@/, length: [min: 6] 36 | param :count, :integer, required: true, number: [greater_than_or_equal_to: 18, less_than: 100] 37 | param :password, :string, required: true, length: [greater_than_or_equal_to: 8, less_than: 100], trim: true 38 | 39 | internal :hashed_password, :string 40 | end 41 | 42 | def execute(%SampleCommand{} = command) do 43 | # .... 44 | :ok 45 | end 46 | 47 | def fill(:hashed_password, _changeset, %{"password" => password}, _metadata) do 48 | :crypto.hash(:sha256, password) |> Base.encode64() 49 | end 50 | end 51 | 52 | :ok = SampleCommand.execute(%{id: "aa-bb-cc", name: "foobar", email: "foo@bar.com", count: 22, password: "mysecret"}) 53 | 54 | ``` 55 | 56 | ## Usage 57 | 58 | ### Defining a Command 59 | 60 | To define a new command, create a module that includes the `EctoCommand` behaviour and implements the `execute/1` function. 61 | The `execute/1` function takes the command structure as an argument. 62 | The `command` macro is used to define the parameters included in the command. 63 | 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). 64 | 65 | 66 | ```elixir 67 | defmodule MyApp.Commands.CreatePost do 68 | use EctoCommand 69 | 70 | alias MyApp.PostRepository 71 | 72 | command do 73 | param :title, :string, required: true, length: [min: 3, max: 255] 74 | param :body, :string, required: true, length: [min: 3] 75 | 76 | internal :slug, :string 77 | internal :author, :string 78 | end 79 | 80 | def execute(%__MODULE__{} = command) do 81 | PostRepository.insert(%{ 82 | title: command.title, 83 | body: command.body, 84 | slug: command.slug 85 | }) 86 | end 87 | 88 | def fill(:slug, _changeset, %{"title" => title}, _metadata) do 89 | Slug.slufigy(title) 90 | end 91 | 92 | def fill(:author, _changeset, _params, %{"triggered_by" => triggered_by}) do 93 | triggered_by 94 | end 95 | 96 | def fill(:author, changeset, _params, _metadata) do 97 | Ecto.Changeset.add_error(changeset, :triggered_by, "triggered_by metadata info is missing") 98 | end 99 | end 100 | ``` 101 | 102 | ### Executing a Command 103 | 104 | In order to execute the command, you need to call the `execute/2` function providing a raw parameter data map and, optionally, some metadata. 105 | ```elixir 106 | params = %{title: "New amazing post", body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut eget ante odio."} 107 | metadata = %{triggered_by: "writer"} 108 | 109 | :ok = MyApp.Commands.CreatePost.execute(params, metadata) 110 | ``` 111 | 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. 112 | 113 | ### Handling Errors 114 | 115 | 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. 116 | 117 | ```elixir 118 | {:error, %Ecto.Changeset{valid?: false}} = MyApp.Commands.CreatePost.execute.execute(%{}) 119 | ``` 120 | 121 | Returning an invalid `Ecto.Changeset` is particularly useful when working with Phoenix forms. 122 | 123 | ## Main goals and functionality of EctoCommand 124 | 125 | EctoCommand aims to provide the following functionality: 126 | 127 | - [An easy way to define the fields in a command (`param` macro).](#params-definition) 128 | - [A simple and compact way of specifying how these fields should be validated (`param` macro options)](#validations-and-constraints-definition) 129 | - [Defining the fields that need to be part of the command but can't be set from the outside (`internal` macro)](#internal-fields) 130 | - [Validation of the params received from the outside](#validations) 131 | - [Easy hooking of middleware to add functionality (like audit)](#using-middlewares-in-ectocommand) 132 | - [Automatic generation of OpenApi documentation](#automated-generation-of-openapi-documentation) 133 | 134 | ## Params definition 135 | 136 | To define the params that a command should accept, use the `param` macro. 137 | 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. 138 | 139 | ## Validations and constraints definition 140 | 141 | 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. 142 | These options are applied to the intermediate Changeset created in order to validate data. 143 | These options are mapped into `validate_*` methods of the [Ecto.Changeset](https://hexdocs.pm/ecto/Ecto.Changeset.html). 144 | 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: 145 | 146 | ```Elixir 147 | param :name, :string, required: true, length: [min: 2, max: 255] 148 | ``` 149 | 150 | 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: 151 | ```Elixir 152 | changeset 153 | |> validate_required([:name]) 154 | |> validate_length(:name, min: 2, max: 255) 155 | ``` 156 | 157 | for validators that accept both data and options, you could pass just data like: 158 | ```elixir 159 | param :email, :string, format: ~r/@/ 160 | ``` 161 | or data and options in this way: 162 | ```elixir 163 | param :email, :string, format: {~r/@/, message: "my custom error message"} 164 | ``` 165 | 166 | ## Internal fields 167 | 168 | 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. 169 | 170 | ```elixir 171 | command do 172 | param :password, :string, required: true, length: [greater_than_or_equal_to: 8, less_than: 100], trim: true 173 | internal :hased_password, :string 174 | internal :triggered_by, :string 175 | end 176 | 177 | def fill(:hased_password, _changeset, %{"password" => password}, _metadata) do 178 | :crypto.hash(:sha256, password) |> Base.encode64() 179 | end 180 | 181 | def fill(:triggered_by, _changeset, _params, %{"triggered_by": triggered_by}) do 182 | triggered_by 183 | end 184 | 185 | def fill(:triggered_by, changeset, _params, _metadata) do 186 | Ecto.Changeset.add_error(changeset, :triggered_by, "triggered_by metadata info is missing") 187 | end 188 | ``` 189 | 190 | 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. 191 | 192 | ## Subparams 193 | 194 | Your API can accept structured data by utilizing "subparams" within the request. 195 | 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. 196 | 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. 197 | 198 | 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. 199 | ```elixir 200 | defmodule SampleCommand do 201 | use EctoCommand 202 | 203 | command do 204 | param :name, :string 205 | param :surname, :string 206 | 207 | embeds_one :address, Address do 208 | param :street, :string, required: true 209 | param :city, :string, required: true, length: [min: 10] 210 | param :zip, :string, required: true 211 | end 212 | end 213 | end 214 | ``` 215 | In the above example, a new `SampleCommand.Address` module will be created. 216 | Alternatively, you can achieve the same behavior by writing: 217 | ```elixir 218 | defmodule SampleCommand.Address do 219 | use EctoCommand 220 | 221 | command do 222 | param :street, :string, required: true 223 | param :city, :string, required: true, length: [min: 10] 224 | param :zip, :string, required: true 225 | end 226 | end 227 | 228 | defmodule SampleCommand do 229 | use EctoCommand 230 | 231 | command do 232 | param :name, :string 233 | param :surname, :string 234 | 235 | embeds_one :address, Address 236 | end 237 | end 238 | ``` 239 | In the second example, an existing module is embedded, providing the same functionality. Feel free to choose the approach that best suits your needs. 240 | 241 | ## Validations 242 | 243 | All parameters are validated in order to instantiate the command structure. 244 | When you use `EctoCommand` inside your module, three methods are added: 245 | 246 | - `changeset/2` 247 | - `new/2` 248 | - `execute/2` 249 | 250 | All three methods take parameter data and metadata as arguments. 251 | The `changeset/2` function performs validation and other operations, and returns a valid or invalid `Ecto.Changeset`. 252 | The `new/2` function internally calls the `changeset/2` function and returns either the valid command structure or the invalid `Ecto.Changeset`. 253 | 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`. 254 | 255 | ## Using Middlewares in EctoCommand 256 | 257 | EctoCommand supports middlewares, which allow you to modify the behavior of a command before and/or after its execution. 258 | 259 | A middleware is a module that implements the `EctoCommand.Middleware` behavior. Here's how you can use middlewares in your EctoCommand project: 260 | 261 | ```elixir 262 | defmodule MyApp.MyMiddleware do 263 | @behaviour EctoCommand.Middleware 264 | 265 | @impl true 266 | def before_execution(pipeline, _opts) do 267 | pipeline 268 | |> Pipeline.assign(:some_data, :some_value) 269 | |> Pipeline.update!(:command, fn command -> %{command | name: "updated-name"} end) 270 | end 271 | 272 | @impl true 273 | def after_execution(pipeline, _opts) do 274 | Logger.debug("Command executed successfully", command: pipeline.command, result: Pipeline.response(pipeline)) 275 | pipeline 276 | end 277 | 278 | @impl true 279 | def after_failure(pipeline, _opts) do 280 | Logger.error("Command execution fails", command: pipeline.command, error: Pipeline.response(pipeline)) 281 | pipeline 282 | end 283 | 284 | @impl true 285 | def invalid(pipeline, _opts) do 286 | Logger.error("invalid params received", params: pipeline.params, error: Pipeline.response(pipeline)) 287 | pipeline 288 | end 289 | end 290 | ``` 291 | 292 | 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. 293 | The method should return an EctoCommand.Pipeline structure. 294 | 295 | - `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. 296 | - `after_execution/2` is executed following a sucessful command execution. In this function, if you'd like, you could alter the returned value. 297 | - `after_failure/2` is executed after a failed command execution. In this function you could, if you wish, also update the returned value. 298 | - `invalid/2` is executed when data used to build the command is invalid. 299 | 300 | 301 | ### Configuring Middlewares 302 | There are two ways to specify which middleware should be executed: 303 | 304 | 1. **Global configuration:** 305 | 306 | You can set up a list of middleware to be executed for every command by adding the following to your application's configuration: 307 | 308 | ```elixir 309 | config :ecto_command, :middlewares, 310 | {MyApp.MyFirstMiddleware, a_middleware_option: :foo}, 311 | {MyApp.MySecondMiddleware, a_middleware_option: :bar} 312 | ``` 313 | 314 | 2. **Command-level configuration:** 315 | 316 | You can also specify middleware to be executed for a specific command by adding the `use` directive in the command module: 317 | 318 | ```elixir 319 | defmodule MyApp.MyCommand do 320 | use EctoCommand 321 | use MyApp.MyFirstMiddleware, a_middleware_option: :foo 322 | use MyApp.MySecondMiddleware, a_middleware_option: :bar 323 | 324 | .... 325 | end 326 | ``` 327 | 328 | In this case, the specified middleware is executed only for that particular command. 329 | 330 | 331 | ## Automated generation of OpenAPI documentation 332 | 333 | EctoCommand has a built-in feature that automatically generates OpenAPI documentation based on the parameters and validation rules defined in your command modules. 334 | 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. 335 | 336 | To generate the OpenAPI schema, you can use the `EctoCommand.OpenApi` module: 337 | 338 | ```elixir 339 | use EctoCommand.OpenApi 340 | ``` 341 | 342 | By default, the schema's title is the fully qualified domain name (FQDN) of the module, and the default type is `:object`. 343 | However, you can override the defaults by passing options to the `use` module: 344 | 345 | ```elixir 346 | use EctoCommand.OpenApi, title: "CustomTitle", type: :object 347 | ``` 348 | 349 | 350 | Then in your controller you can simply pass your command module to the request body specs: 351 | ```elixir 352 | 353 | defmodule MyAppWeb.PostController do 354 | use MyAppWeb, :controller 355 | use OpenApiSpex.ControllerSpecs 356 | 357 | alias MyApp.Commands.CreatePost 358 | 359 | operation :create_post, 360 | summary: "Create a Post", 361 | request_body: {"Post params", "application/json", CreatePost}, 362 | responses: [ 363 | ok: {"Post response", "application/json", []}, 364 | bad_request: {"Post response", "application/json", []} 365 | ] 366 | def create_post(conn, params) do 367 | ... 368 | end 369 | ``` 370 | 371 | 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. 372 | 373 | ## Contributing 374 | 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. 375 | 376 | ## License 377 | This library is licensed under the MIT license. See [LICENSE](https://github.com/silvadanilo/ecto_command/blob/master/LICENSE) for more details. 378 | -------------------------------------------------------------------------------- /lib/ecto_command.ex: -------------------------------------------------------------------------------- 1 | # credo:disable-for-this-file Credo.Check.Refactor.Nesting 2 | # credo:disable-for-this-file Credo.Check.Refactor.CyclomaticComplexity 3 | defmodule EctoCommand do 4 | @moduledoc """ 5 | The `EctoCommand` module provides a DSL for defining command schemas. 6 | It is used by `use EctoCommand` in your command modules. 7 | 8 | ### Example 9 | defmodule MyApp.Commands.CreatePost do 10 | use EctoCommand 11 | alias MyApp.PostRepository 12 | 13 | command do 14 | param :title, :string, required: true, length: [min: 3, max: 255] 15 | param :body, :string, required: true, length: [min: 3] 16 | 17 | internal :slug, :string 18 | internal :author, :string 19 | end 20 | 21 | def execute(%__MODULE__{} = command) do 22 | ... 23 | :ok 24 | end 25 | 26 | def fill(:slug, _changeset, %{"title" => title}, _metadata) do 27 | Slug.slufigy(title) 28 | end 29 | 30 | def fill(:author, _changeset, _params, %{"triggered_by" => triggered_by}) do 31 | triggered_by 32 | end 33 | 34 | def fill(:author, changeset, _params, _metadata) do 35 | Ecto.Changeset.add_error(changeset, :triggered_by, "triggered_by metadata info is missing") 36 | end 37 | end 38 | """ 39 | 40 | alias EctoCommand 41 | alias EctoCommand.Middleware.Pipeline 42 | 43 | @command_options [:internal, :trim, :doc] 44 | @valid_validators [ 45 | :acceptance, 46 | :change, 47 | :confirmation, 48 | :exclusion, 49 | :format, 50 | :inclusion, 51 | :length, 52 | :number, 53 | :required, 54 | :subset 55 | ] 56 | 57 | @doc false 58 | defmacro __using__(_) do 59 | quote do 60 | import EctoCommand, 61 | only: [ 62 | command: 1, 63 | param: 2, 64 | param: 3, 65 | validate_with: 1, 66 | validate_with: 2, 67 | internal: 2, 68 | internal: 3, 69 | embeds_one: 3, 70 | cast_embedded_fields: 2 71 | ] 72 | 73 | import Ecto.Changeset 74 | 75 | @before_compile unquote(__MODULE__) 76 | 77 | Module.register_attribute(__MODULE__, :cast_fields, accumulate: true) 78 | Module.register_attribute(__MODULE__, :internal_fields, accumulate: true) 79 | Module.register_attribute(__MODULE__, :validators, accumulate: true) 80 | Module.register_attribute(__MODULE__, :trim_fields, accumulate: true) 81 | Module.register_attribute(__MODULE__, :command_fields, accumulate: true) 82 | Module.register_attribute(__MODULE__, :middlewares, accumulate: true) 83 | 84 | @doc """ 85 | 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. 86 | """ 87 | def fill(_, changeset, _params, _metadata), do: changeset 88 | 89 | defoverridable fill: 4 90 | end 91 | end 92 | 93 | @doc false 94 | defmacro __before_compile__(_env) do 95 | quote unquote: false do 96 | # The new/2 function creates a new command struct with the given params and metadata 97 | # in case of invalid data it returns an error changeset. 98 | # ## Examples 99 | # new(%{name: "John", age: 28}) 100 | # new(%{name: "John", age: 28}, %{user_id: 1}) 101 | @spec new(params :: map, metadata :: map) :: {:ok, struct()} | {:error, Ecto.Changeset.t()} 102 | def new(%{} = params, metadata \\ %{}) do 103 | params 104 | |> changeset(metadata) 105 | |> apply_action(:insert) 106 | end 107 | 108 | # The changeset/2 function creates a new changeset with the given params 109 | # and validates it against the given schema. 110 | # It also fills the internal fields with the given metadata. 111 | # ## Examples 112 | # changeset(%{name: "John", age: 28}) 113 | # changeset(%{name: "John", age: 28}, %{user_id: 1}) 114 | @spec changeset(params :: map, metadata :: map) :: Ecto.Changeset.t() 115 | def changeset(params, metadata \\ %{}) 116 | 117 | def changeset(struct, params) when is_struct(struct) do 118 | changeset(params, %{}) 119 | end 120 | 121 | def changeset(%{} = params, metadata) do 122 | params = EctoCommand.trim_fields(params, @trim_fields) 123 | 124 | __MODULE__ 125 | |> struct!(%{}) 126 | |> cast(params, @cast_fields) 127 | |> cast_embedded_fields(Keyword.keys(@ecto_embeds)) 128 | |> __validate() 129 | |> __fill_internal_fields(metadata) 130 | end 131 | 132 | @spec execute(params :: map, metadata :: map) :: any() | {:error, Ecto.Changeset.t()} 133 | def execute(%{} = params, metadata \\ %{}) when is_map(params) do 134 | EctoCommand.execute(%Pipeline{ 135 | params: params, 136 | metadata: metadata, 137 | handler: __MODULE__, 138 | middlewares: Application.get_env(:ecto_command, :middlewares, []) ++ Enum.reverse(@middlewares) 139 | }) 140 | end 141 | 142 | def __fill_internal_fields(changeset, metadata), 143 | do: __fill_internal_fields(changeset, metadata, Enum.reverse(@internal_fields)) 144 | 145 | def __fill_internal_fields(%{valid?: false} = changeset, _metadata, _internal_fields), do: changeset 146 | def __fill_internal_fields(changeset, _metadata, []), do: changeset 147 | 148 | def __fill_internal_fields(changeset, metadata, [field | internal_fields]) do 149 | changeset = 150 | case apply(__MODULE__, :fill, [field, changeset, changeset.params, metadata]) do 151 | %Ecto.Changeset{} = changeset -> changeset 152 | value -> put_change(changeset, field, value) 153 | end 154 | 155 | __fill_internal_fields(changeset, metadata, internal_fields) 156 | end 157 | end 158 | end 159 | 160 | @doc false 161 | def parse_block({:__block__, context, block}), do: {:__block__, context, Enum.map(block, &parse_block/1)} 162 | def parse_block(block), do: block 163 | 164 | @doc """ 165 | The command/1 macro defines a command schema with the given block. 166 | ## Examples 167 | command do 168 | param :name, :string 169 | param :age, :integer 170 | end 171 | """ 172 | defmacro command(do: block) do 173 | prelude = 174 | quote do 175 | use Ecto.Schema 176 | @primary_key false 177 | 178 | embedded_schema do 179 | import Ecto.Schema, except: [embeds_one: 3, embeds_one: 4] 180 | unquote(parse_block(block)) 181 | end 182 | end 183 | 184 | postlude = 185 | quote unquote: false do 186 | validator_ast = 187 | Enum.reduce(@validators, quote(do: changeset), fn 188 | {:extra, function, opts}, acc -> 189 | quote do: unquote(function).(unquote(acc), unquote(opts)) 190 | 191 | {field, :change, {metadata, validator_fn}}, acc -> 192 | quote do: 193 | unquote(acc) 194 | |> unquote(String.to_atom("validate_change"))( 195 | unquote(field), 196 | unquote(metadata), 197 | unquote(validator_fn) 198 | ) 199 | 200 | {field, validator, data, options}, acc -> 201 | quote do: 202 | unquote(acc) 203 | |> unquote(String.to_atom("validate_#{Atom.to_string(validator)}"))( 204 | unquote(field), 205 | unquote(data), 206 | unquote(options) 207 | ) 208 | 209 | {field, validator, {data, options}}, acc -> 210 | quote do: 211 | unquote(acc) 212 | |> unquote(String.to_atom("validate_#{Atom.to_string(validator)}"))( 213 | unquote(field), 214 | unquote(data), 215 | unquote(options) 216 | ) 217 | 218 | {field, validator, options}, acc -> 219 | quote do: 220 | unquote(acc) 221 | |> unquote(String.to_atom("validate_#{Atom.to_string(validator)}"))( 222 | unquote(field), 223 | unquote(options) 224 | ) 225 | end) 226 | 227 | def __validate(changeset) do 228 | unquote(validator_ast) 229 | end 230 | end 231 | 232 | quote do 233 | unquote(prelude) 234 | unquote(postlude) 235 | end 236 | end 237 | 238 | defmacro validate_with(function, opts \\ []) do 239 | quote do 240 | Module.put_attribute(__MODULE__, :validators, {:extra, unquote(function), unquote(opts)}) 241 | end 242 | end 243 | 244 | @doc """ 245 | Defines a command internal field. \n 246 | These fields will be ignored during the "cast process". 247 | 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. 248 | ## Examples 249 | internal :slug, :string 250 | internal :author, :string 251 | """ 252 | defmacro internal(name, type \\ :string, opts \\ []) do 253 | quote do 254 | opts = unquote(opts) 255 | 256 | Module.put_attribute(__MODULE__, :internal_fields, unquote(name)) 257 | 258 | Ecto.Schema.__field__( 259 | __MODULE__, 260 | unquote(name), 261 | unquote(type), 262 | opts |> Keyword.drop(unquote(@command_options ++ @valid_validators)) 263 | ) 264 | end 265 | end 266 | 267 | @doc """ 268 | Defines a command parameter field. \n 269 | 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. 270 | 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. 271 | These options are applied to the intermediate Changeset created in order to validate data. 272 | These options are mapped into `validate_*` methods of the [Ecto.Changeset](https://hexdocs.pm/ecto/Ecto.Changeset.html). 273 | ## Examples 274 | 275 | param :title, :string, required: true, length: [min: 3, max: 255] 276 | param :body, :string, required: true, length: [min: 3] 277 | """ 278 | defmacro param(name, type, opts \\ []) 279 | 280 | defmacro param(name, type, opts) do 281 | quote do 282 | opts = unquote(opts) 283 | 284 | Module.put_attribute(__MODULE__, :command_fields, {unquote(name), unquote(type), opts}) 285 | Module.put_attribute(__MODULE__, :cast_fields, unquote(name)) 286 | 287 | if opts[:trim] == true do 288 | if unquote(type) == :string do 289 | Module.put_attribute(__MODULE__, :trim_fields, unquote(name)) 290 | else 291 | raise ArgumentError, "trim option can only be used with string fields, got: #{inspect(unquote(type))}" 292 | end 293 | end 294 | 295 | unquote(@valid_validators) 296 | |> Enum.each(fn validator -> 297 | if opts[validator] !== nil && opts[validator] !== false do 298 | parsed_opts = if opts[validator] == true, do: [], else: opts[validator] 299 | 300 | Module.put_attribute( 301 | __MODULE__, 302 | :validators, 303 | {unquote(name), validator, Macro.escape(parsed_opts)} 304 | ) 305 | end 306 | end) 307 | 308 | Ecto.Schema.__field__( 309 | __MODULE__, 310 | unquote(name), 311 | unquote(type), 312 | opts |> Keyword.drop(unquote(@command_options ++ @valid_validators)) 313 | ) 314 | end 315 | end 316 | 317 | defmacro embeds_one(name, schema, do: block) do 318 | quote do 319 | defmodule unquote(schema) do 320 | use EctoCommand 321 | 322 | command do 323 | unquote(block) 324 | end 325 | end 326 | 327 | Ecto.Schema.embeds_one(unquote(name), unquote(schema)) 328 | end 329 | end 330 | 331 | @doc false 332 | @spec trim_fields(map(), [atom()]) :: map() 333 | def trim_fields(params, trim_fields) do 334 | Enum.reduce(trim_fields, params, fn field, params -> 335 | Map.update(params, field, nil, &String.trim/1) 336 | end) 337 | end 338 | 339 | @doc false 340 | @spec execute(Pipeline.t()) :: any() | {:error, Ecto.Changeset.t()} 341 | def execute(%Pipeline{} = pipeline) do 342 | pipeline 343 | |> instantiate_command() 344 | |> Pipeline.chain(:before_execution, pipeline.middlewares) 345 | |> Pipeline.execute() 346 | |> Pipeline.chain(:after_execution, Enum.reverse(pipeline.middlewares)) 347 | |> Pipeline.chain(:after_failure, Enum.reverse(pipeline.middlewares)) 348 | |> Pipeline.response() 349 | end 350 | 351 | def cast_embedded_fields(changeset, embedded_fields) do 352 | Enum.reduce(embedded_fields, changeset, fn embedded_field, changeset -> 353 | Ecto.Changeset.cast_embed(changeset, embedded_field) 354 | end) 355 | end 356 | 357 | defp instantiate_command(%Pipeline{handler: handler, params: params, metadata: metadata} = pipeline) do 358 | case handler.new(params, metadata) do 359 | {:ok, command} -> 360 | Pipeline.set(pipeline, :command, command) 361 | 362 | {:error, error} -> 363 | pipeline 364 | |> Pipeline.respond({:error, error}) 365 | |> Pipeline.chain(:invalid, pipeline.middlewares) 366 | |> Pipeline.halt() 367 | end 368 | end 369 | end 370 | -------------------------------------------------------------------------------- /lib/ecto_command/middleware/middleware.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCommand.Middleware do 2 | @moduledoc """ 3 | Middleware provides an extension point to add functions that you want to be 4 | called for every command execution 5 | 6 | Implement the `EctoCommand.Middleware` behaviour in your module and define the 7 | `c:before_execution/2`, `c:after_execution/2`, `c:after_failure/2` and `c:invalid/2` callback functions. 8 | 9 | ## Example middleware 10 | 11 | defmodule SampleMiddleware do 12 | @behaviour EctoCommand.Middleware 13 | 14 | @impl true 15 | def before_execution(pipeline, _opts) do 16 | pipeline 17 | |> Pipeline.assign(:some_data, :some_value) 18 | |> Pipeline.update!(:command, fn command -> %{command | name: "updated-name"} end) 19 | end 20 | 21 | @impl true 22 | def after_execution(pipeline, _opts) do 23 | Logger.debug("Command executed successfully", command: pipeline.command, result: Pipeline.response(pipeline)) 24 | 25 | pipeline 26 | end 27 | 28 | @impl true 29 | def after_failure(pipeline, _opts) do 30 | Logger.error("Command execution fails", command: pipeline.command, error: Pipeline.response(pipeline)) 31 | 32 | pipeline 33 | end 34 | 35 | @impl true 36 | def invalid(pipeline, _opts) do 37 | Logger.error("invalid params received", params: pipeline.params, error: Pipeline.response(pipeline)) 38 | 39 | pipeline 40 | end 41 | end 42 | """ 43 | 44 | alias EctoCommand.Middleware.Pipeline 45 | 46 | @type pipeline :: %Pipeline{} 47 | 48 | @doc """ 49 | 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. 50 | ## Example 51 | @impl true 52 | def before_execution(pipeline, _opts) do 53 | pipeline 54 | |> Pipeline.assign(:some_data, :some_value) 55 | |> Pipeline.update!(:command, fn command -> %{command | name: "updated-name"} end) 56 | end 57 | """ 58 | @callback before_execution(pipeline :: pipeline(), opts :: Keyword.t()) :: pipeline() 59 | 60 | @doc """ 61 | Is executed following a sucessful command execution. In this function, if you'd like, you could alter the returned value. 62 | ## Example 63 | @impl true 64 | def after_execution(pipeline, _opts) do 65 | Logger.debug("Command executed successfully", command: pipeline.command, result: Pipeline.response(pipeline)) 66 | 67 | pipeline 68 | end 69 | """ 70 | @callback after_execution(pipeline :: pipeline(), opts :: Keyword.t()) :: pipeline() 71 | 72 | @doc """ 73 | Is executed after a failed command execution. In this function you could, if you wish, also update the returned value. 74 | ## Example 75 | @impl true 76 | def after_failure(pipeline, _opts) do 77 | Logger.error("Command execution fails", command: pipeline.command, error: Pipeline.response(pipeline)) 78 | 79 | pipeline 80 | end 81 | """ 82 | @callback after_failure(pipeline :: pipeline(), opts :: Keyword.t()) :: pipeline() 83 | 84 | @doc """ 85 | Is executed when the command's inputs are invalid. 86 | ## Example 87 | @impl true 88 | def invalid(pipeline, _opts) do 89 | Logger.error("invalid params received", params: pipeline.params, error: Pipeline.response(pipeline)) 90 | 91 | pipeline 92 | end 93 | """ 94 | @callback invalid(pipeline :: pipeline(), opts :: Keyword.t()) :: pipeline() 95 | end 96 | -------------------------------------------------------------------------------- /lib/ecto_command/middleware/pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCommand.Middleware.Pipeline do 2 | @moduledoc """ 3 | Pipeline is a struct used as an argument in the callback functions of modules 4 | implementing the `EctoCommand.Middleware` behaviour. 5 | 6 | This struct must be returned by each function to be used in the next 7 | middleware based on the configured middleware chain. 8 | 9 | ## Pipeline fields 10 | 11 | - `assigns` - shared user data as a map. 12 | 13 | - `command_uuid` - UUID assigned to the command being executed. 14 | 15 | - `command` - command struct being executed. 16 | 17 | - `params` - raw params received to instantiate the command 18 | 19 | - `metadata` - additional metadata, they could be used to fill internal command fields 20 | 21 | - `halted` - flag indicating whether the pipeline was halted. 22 | 23 | - `handler` - handler module where the "execute/1" function resides 24 | 25 | - `middlewares` - the list of middlewares to be executed 26 | 27 | - `response` - sets the response to send back to the caller. 28 | 29 | - `error` - sets the error to send back to the caller. 30 | 31 | """ 32 | defstruct [ 33 | :handler, 34 | :command, 35 | :params, 36 | :metadata, 37 | :middlewares, 38 | :response, 39 | :error, 40 | assigns: %{}, 41 | halted: false 42 | ] 43 | 44 | alias EctoCommand.Middleware.Pipeline 45 | 46 | @type t :: %__MODULE__{ 47 | handler: atom(), 48 | command: struct() | nil, 49 | params: map(), 50 | metadata: map(), 51 | middlewares: [tuple()], 52 | response: any() | nil, 53 | error: any() | nil, 54 | assigns: map(), 55 | halted: boolean() 56 | } 57 | 58 | @doc """ 59 | Set the `key` with value 60 | 61 | ## Examples 62 | iex> pipeline = set(%Pipeline{}, :command, :my_command) 63 | iex> pipeline.command 64 | :my_command 65 | """ 66 | def set(%Pipeline{} = pipeline, key, value) when is_atom(key) do 67 | Map.put(pipeline, key, value) 68 | end 69 | 70 | @doc """ 71 | Puts the `key` with value equal to `value` into `assigns` map. 72 | 73 | ## Examples 74 | iex> pipeline = assign(%Pipeline{}, :foo, :bar) 75 | iex> pipeline.assigns 76 | %{foo: :bar} 77 | """ 78 | def assign(%Pipeline{} = pipeline, key, value) when is_atom(key) do 79 | %Pipeline{assigns: assigns} = pipeline 80 | 81 | %Pipeline{pipeline | assigns: Map.put(assigns, key, value)} 82 | end 83 | 84 | @doc """ 85 | Update the `key` with function `function` that receive the `key` value. 86 | 87 | ## Examples 88 | iex> pipeline = %Pipeline{command: %{name: "original"}} 89 | iex> pipeline = update!(pipeline, :command, fn command -> %{command | name: "updated"} end) 90 | iex> pipeline.command 91 | %{name: "updated"} 92 | """ 93 | def update!(%Pipeline{} = pipeline, key, function) do 94 | Map.update!(pipeline, key, function) 95 | end 96 | 97 | @doc """ 98 | Has the pipeline been halted? 99 | ## Examples 100 | iex> true = halted?(%Pipeline{halted: true}) 101 | iex> false = halted?(%Pipeline{halted: false}) 102 | """ 103 | def halted?(%Pipeline{halted: halted}), do: halted 104 | 105 | @doc """ 106 | Halts the pipeline by preventing further middleware downstream from being invoked. 107 | 108 | Prevents execution of the command if `halt` occurs in a `before_execution` callback. 109 | 110 | ## Examples 111 | iex> pipeline = %Pipeline{} 112 | iex> pipeline = halt(pipeline) 113 | iex> halted?(pipeline) 114 | true 115 | """ 116 | def halt(%Pipeline{} = pipeline), do: %Pipeline{pipeline | halted: true} 117 | 118 | @doc """ 119 | Halts the pipeline by preventing further middleware downstream from being invoked. 120 | 121 | Prevents execution of the command if `halt` occurs in a `before_execution` callback. 122 | 123 | Similar to `halt/1` but allows a response to be returned to the caller. 124 | 125 | ## Examples 126 | iex> pipeline = %Pipeline{} 127 | iex> pipeline = halt(pipeline, {:error, "halted"}) 128 | iex> response(pipeline) 129 | {:error, "halted"} 130 | iex> halted?(pipeline) 131 | true 132 | """ 133 | def halt(%Pipeline{} = pipeline, response), do: %Pipeline{pipeline | halted: true} |> respond(response) 134 | 135 | @doc """ 136 | Extract the response from the pipeline, return the error if it is set 137 | return the stored response otherwise 138 | return nil if no response is set 139 | 140 | ## Examples 141 | iex> pipeline = %Pipeline{} 142 | iex> pipeline = Pipeline.error(pipeline, "halted") 143 | iex> Pipeline.response(pipeline) 144 | {:error, "halted"} 145 | """ 146 | def response(%Pipeline{error: nil, response: response}), do: response 147 | def response(%Pipeline{error: error}), do: {:error, error} 148 | 149 | @doc """ 150 | Sets the response to be returned to the dispatch caller 151 | 152 | ## Examples 153 | iex> pipeline = %Pipeline{} 154 | iex> pipeline = Pipeline.respond(pipeline, {:error, "halted"}) 155 | iex> Pipeline.response(pipeline) 156 | {:error, "halted"} 157 | """ 158 | def respond(%Pipeline{} = pipeline, response) do 159 | %Pipeline{pipeline | error: nil, response: response} 160 | end 161 | 162 | @doc """ 163 | Sets the error 164 | 165 | ## Examples 166 | iex> pipeline = %Pipeline{} 167 | iex> pipeline = Pipeline.error(pipeline, "an_error") 168 | iex> Pipeline.response(pipeline) 169 | {:error, "an_error"} 170 | """ 171 | def error(%Pipeline{} = pipeline, error) do 172 | %Pipeline{pipeline | error: error} 173 | end 174 | 175 | @doc """ 176 | Executes the middleware chain. 177 | """ 178 | def chain(pipeline, stage, middleware) 179 | def chain(%Pipeline{} = pipeline, _stage, []), do: pipeline 180 | def chain(%Pipeline{halted: true} = pipeline, _stage, _middleware), do: pipeline 181 | def chain(%Pipeline{error: nil} = pipeline, :after_failure, _middleware), do: pipeline 182 | 183 | def chain(%Pipeline{} = pipeline, stage, [{module, opts} | modules]) do 184 | chain(apply(module, stage, [pipeline, opts]), stage, modules) 185 | end 186 | 187 | @doc """ 188 | Executes the function 'execute/1' in the handler module, pass the command to it. 189 | Halt the pipeline if command or handler are not set 190 | 191 | ## Examples 192 | iex> %Pipeline{halted: true} = Pipeline.execute(%Pipeline{halted: true}) 193 | iex> %Pipeline{response: {:error, "command was not initialized"}} = Pipeline.execute(%Pipeline{handler: Pipeline}) 194 | iex> %Pipeline{response: {:error, "handler was not set"}} = Pipeline.execute(%Pipeline{command: %{}}) 195 | iex> %Pipeline{response: {:ok, :result}} = Pipeline.execute(%Pipeline{handler: SampleCommand, command: %SampleCommand{}}) 196 | """ 197 | def execute(%Pipeline{halted: true} = pipeline), do: pipeline 198 | def execute(%Pipeline{handler: nil} = pipeline), do: halt(pipeline, {:error, "handler was not set"}) 199 | def execute(%Pipeline{command: nil} = pipeline), do: halt(pipeline, {:error, "command was not initialized"}) 200 | 201 | def execute(%Pipeline{command: command} = pipeline) do 202 | case pipeline.handler.execute(command) do 203 | {:error, error} -> 204 | error(pipeline, error) 205 | 206 | result -> 207 | respond(pipeline, result) 208 | end 209 | end 210 | end 211 | -------------------------------------------------------------------------------- /lib/open_api/open_api.ex: -------------------------------------------------------------------------------- 1 | # credo:disable-for-this-file Credo.Check.Refactor.Nesting 2 | defmodule EctoCommand.OpenApi do 3 | @moduledoc false 4 | 5 | alias EctoCommand.OpenApi.Type 6 | 7 | @doc false 8 | defmacro __using__(opts \\ []) do 9 | quote bind_quoted: [opts: opts, module: __MODULE__] do 10 | alias EctoCommand.OpenApi.Type 11 | 12 | @ecto_command_openapi_options opts 13 | @before_compile module 14 | end 15 | end 16 | 17 | @doc false 18 | defmacro __before_compile__(_env) do 19 | quote unquote: false do 20 | def schema do 21 | {properties, required} = 22 | Enum.reduce(@command_fields, {%{}, []}, fn field, {fields, required} -> 23 | {name, type, opts} = field 24 | 25 | if opts[:internal] != true and opts[:doc] != false do 26 | required = if Enum.member?([true, []], opts[:required]), do: [name | required], else: required 27 | fields = Map.put(fields, name, EctoCommand.OpenApi.schema_for(type, opts)) 28 | {fields, required} 29 | else 30 | {fields, required} 31 | end 32 | end) 33 | 34 | EctoCommand.OpenApi.add_example(%OpenApiSpex.Schema{ 35 | title: @ecto_command_openapi_options[:title] || __MODULE__, 36 | type: @ecto_command_openapi_options[:type] || :object, 37 | properties: properties, 38 | required: required 39 | }) 40 | end 41 | end 42 | end 43 | 44 | def schema_for(type, opts) do 45 | opts 46 | |> Enum.reduce(base_schema(type, opts), &parse_option/2) 47 | |> then(&struct!(OpenApiSpex.Schema, &1)) 48 | |> add_example() 49 | end 50 | 51 | def add_example(%{example: example} = schema) when not is_nil(example), do: schema 52 | def add_example(schema), do: Map.put(schema, :example, Type.example_for(schema)) 53 | 54 | defp parse_option({key, _}, %{type: :array} = acc) when key not in [:doc, :default], do: acc 55 | defp parse_option({:change, _options}, acc), do: acc 56 | defp parse_option({:inclusion, values}, acc), do: Map.put(acc, :enum, values) 57 | defp parse_option({:subset, values}, acc), do: Map.put(acc, :enum, values) 58 | defp parse_option({:format, format}, acc), do: Map.put(acc, :pattern, format) 59 | 60 | defp parse_option({:length, options}, acc) do 61 | Enum.reduce(options, acc, fn 62 | {:min, min}, acc -> 63 | Map.put(acc, :minLength, min) 64 | 65 | {:max, max}, acc -> 66 | Map.put(acc, :maxLength, max) 67 | 68 | {:is, is}, acc -> 69 | Map.put(acc, :minLength, is) 70 | Map.put(acc, :maxLength, is) 71 | end) 72 | end 73 | 74 | defp parse_option({:number, options}, acc) do 75 | Enum.reduce(options, acc, fn 76 | {:less_than, value}, acc -> 77 | acc 78 | |> Map.put(:maximum, value) 79 | |> Map.put(:exclusiveMaximum, true) 80 | 81 | {:greater_than, value}, acc -> 82 | acc 83 | |> Map.put(:minimum, value) 84 | |> Map.put(:exclusiveMinimum, true) 85 | 86 | {:less_than_or_equal_to, value}, acc -> 87 | acc 88 | |> Map.put(:maximum, value) 89 | |> Map.put(:exclusiveMaximum, false) 90 | 91 | {:greater_than_or_equal_to, value}, acc -> 92 | acc 93 | |> Map.put(:minimum, value) 94 | |> Map.put(:exclusiveMinimum, false) 95 | 96 | {:equal_to, value}, acc -> 97 | acc 98 | |> Map.put(:minimum, value) 99 | |> Map.put(:maximum, value) 100 | |> Map.put(:exclusiveMinimum, false) 101 | |> Map.put(:exclusiveMaximum, false) 102 | 103 | {:not_equal_to, value}, acc -> 104 | Map.put(acc, :not, %{enum: [value]}) 105 | end) 106 | end 107 | 108 | defp parse_option({:values, values}, acc) do 109 | parsed_values = 110 | Enum.map(values, fn 111 | value when is_atom(value) -> Atom.to_string(value) 112 | {value, _mapped_ind} -> Atom.to_string(value) 113 | end) 114 | 115 | Map.put(acc, :enum, parsed_values) 116 | end 117 | 118 | defp parse_option({:default, value}, acc) do 119 | Map.put(acc, :default, value) 120 | end 121 | 122 | defp parse_option({:doc, options}, acc) do 123 | Map.merge(acc, Enum.into(options, %{})) 124 | end 125 | 126 | defp parse_option({:required, _}, acc), do: acc 127 | 128 | defp base_schema({:array, inner_type}, opts) do 129 | %{type: :array, items: schema_for(inner_type, Keyword.drop(opts, [:doc, :default]))} 130 | end 131 | 132 | defp base_schema(type, _opts), do: base_schema(type) 133 | 134 | defp base_schema(:id), do: %{type: :integer} 135 | defp base_schema(type) when type in [:float, :decimal], do: %{type: :number} 136 | defp base_schema(:map), do: %{type: :object, properties: %{}} 137 | defp base_schema({:map, _inner_type}), do: %{type: :object, properties: %{}} 138 | defp base_schema(:date), do: %{type: :string, format: :date} 139 | 140 | defp base_schema(type) when type in [:utc_datetime, :utc_datetime_usec, :naive_datetime, :naive_datetime_usec], 141 | do: %{type: :string, format: :"date-time"} 142 | 143 | defp base_schema(type) when type in [:binary_id, :bitstring, :time, :time_usec, Ecto.Enum, :duration], 144 | do: %{type: :string} 145 | 146 | defp base_schema(type), do: %{type: type} 147 | end 148 | -------------------------------------------------------------------------------- /lib/open_api/type.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCommand.OpenApi.Type do 2 | @moduledoc false 3 | 4 | alias OpenApiSpex.Schema 5 | 6 | def uuid(options \\ []) do 7 | [format: :uuid, description: "UUID"] ++ options 8 | end 9 | 10 | def email(options \\ []) do 11 | [format: :email, description: "Email", example: "user@domain.com"] ++ options 12 | end 13 | 14 | def password(options \\ []) do 15 | [format: :password, description: "Password", example: "Abcd123!!"] ++ options 16 | end 17 | 18 | def phone(options \\ []) do 19 | [format: :telephone, description: "Telephone", example: "(425) 123-4567"] ++ options 20 | end 21 | 22 | @deprecated "Automatically inferred from command schema when type is one of `:utc_datetime`, `:utc_datetime_usec`, `:naive_datetime`, `:naive_datetime_usec`" 23 | def datetime(options \\ []) do 24 | [format: :"date-time"] ++ options 25 | end 26 | 27 | @deprecated "Automatically inferred from command schema when type is `:date`" 28 | def date(options \\ []) do 29 | [format: :date] ++ options 30 | end 31 | 32 | @deprecated "Automatically inferred from command schema when type is `Ecto.Enum` or `inclusion` opt is used" 33 | def enum(values, options \\ []) do 34 | [example: List.first(values)] ++ options 35 | end 36 | 37 | @deprecated "Automatically inferred from command schema when type is `:boolean`" 38 | def boolean(options \\ []) do 39 | [example: true] ++ options 40 | end 41 | 42 | def example_for(%Schema{type: :array, items: %{enum: values}} = _schema) when is_list(values), 43 | do: Enum.take(values, 2) 44 | 45 | def example_for(%Schema{default: default}) when not is_nil(default), do: default 46 | def example_for(%Schema{enum: values}) when is_list(values), do: List.first(values) 47 | def example_for(%Schema{type: :string, format: :email}), do: Keyword.get(email(), :example) 48 | def example_for(%Schema{type: :string, format: :telephone}), do: Keyword.get(phone(), :example) 49 | def example_for(%Schema{type: :string, format: :password}), do: Keyword.get(password(), :example) 50 | def example_for(%Schema{type: :integer} = schema), do: trunc(number_example(schema)) 51 | def example_for(%Schema{type: :number} = schema), do: number_example(schema) 52 | def example_for(%Schema{type: :array, items: %{example: nil}}), do: [] 53 | def example_for(%Schema{type: :array, items: %{example: example}}), do: [example] 54 | 55 | def example_for(%Schema{type: :object, properties: properties}) do 56 | Map.new(properties, fn {name, %Schema{example: example} = schema} -> 57 | {name, example || example_for(schema)} 58 | end) 59 | end 60 | 61 | def example_for(%Schema{} = schema), do: Schema.example(schema) 62 | def example_for(_schema), do: nil 63 | 64 | defp number_example(%Schema{not: %{enum: [n]}}), do: (n + 1) / 1 65 | 66 | defp number_example(%Schema{} = schema) do 67 | min = if schema.minimum, do: schema.minimum + ((schema.exclusiveMinimum && 1) || 0) 68 | max = if schema.maximum, do: schema.maximum - ((schema.exclusiveMaximum && 1) || 0) 69 | number_between(min, max) 70 | end 71 | 72 | defp number_between(nil, nil), do: 10.0 73 | defp number_between(min, nil), do: min 74 | defp number_between(nil, max), do: max 75 | defp number_between(min, max), do: min + (max - min) / 2 76 | end 77 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoCommand.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ecto_command, 7 | version: "0.2.6", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | test_coverage: [tool: ExCoveralls], 12 | preferred_cli_env: [ 13 | coveralls: :test, 14 | "coveralls.html": :test, 15 | coverage_report: :test, 16 | "ecto.migrate.test": :test, 17 | "ecto.reset.test": :test, 18 | "test.slow": :test 19 | ], 20 | deps: deps(), 21 | 22 | # Docs 23 | name: "EctoCommand", 24 | description: "EctoCommand is a toolkit for mapping, validating, and executing commands received from any source.", 25 | package: package(), 26 | source_url: "https://github.com/silvadanilo/ecto_command", 27 | homepage_url: "https://github.com/silvadanilo/ecto_command", 28 | docs: [ 29 | # The main page in the docs 30 | main: "EctoCommand", 31 | extras: ["README.md"] 32 | ] 33 | ] 34 | end 35 | 36 | # Run "mix help compile.app" to learn about applications. 37 | def application do 38 | [ 39 | extra_applications: [:logger] 40 | ] 41 | end 42 | 43 | defp package() do 44 | [ 45 | licenses: ["MIT"], 46 | links: %{"GitHub" => "https://github.com/silvadanilo/ecto_command"} 47 | ] 48 | end 49 | 50 | # Specifies which paths to compile per environment. 51 | defp elixirc_paths(:test), do: ["lib", "test/support"] 52 | defp elixirc_paths(_), do: ["sample", "lib"] 53 | 54 | # Run "mix help deps" to learn about dependencies. 55 | defp deps do 56 | [ 57 | {:ecto_sql, "~> 3.12"}, 58 | {:open_api_spex, "~> 3.21"}, 59 | 60 | # dev 61 | {:ex_doc, "~> 0.37", only: :dev, runtime: false}, 62 | {:makeup_html, ">= 0.2.0", only: :dev, runtime: false}, 63 | {:dialyxir, "~> 1.4", only: [:dev], runtime: false}, 64 | 65 | # test 66 | {:excoveralls, "~> 0.15.3", only: [:dev, :test]}, 67 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false} 68 | ] 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, 4 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 5 | "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, 6 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 7 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 8 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 9 | "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, 10 | "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, 11 | "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, 12 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 13 | "ex_doc": {:hex, :ex_doc, "0.37.1", "65ca30d242082b95aa852b3b73c9d9914279fff56db5dc7b3859be5504417980", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "6774f75477733ea88ce861476db031f9399c110640752ca2b400dbbb50491224"}, 14 | "excoveralls": {:hex, :excoveralls, "0.15.3", "54bb54043e1cf5fe431eb3db36b25e8fd62cf3976666bafe491e3fa5e29eba47", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f8eb5d8134d84c327685f7bb8f1db4147f1363c3c9533928234e496e3070114e"}, 15 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 16 | "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, 17 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 18 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 19 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 20 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 21 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 22 | "makeup_html": {:hex, :makeup_html, "0.2.0", "9f810da8d43d625ccd3f7ea25997e588fa541d80e0a8c6b895157ad5c7e9ca13", [:mix], [{:makeup, "~> 1.2", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "0856f7beb9a6a642ab1307e06d990fe39f0ba58690d0b8e662aa2e027ba331b2"}, 23 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 24 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 25 | "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, 26 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 27 | "open_api_spex": {:hex, :open_api_spex, "3.21.2", "6a704f3777761feeb5657340250d6d7332c545755116ca98f33d4b875777e1e5", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "f42ae6ed668b895ebba3e02773cfb4b41050df26f803f2ef634c72a7687dc387"}, 28 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 29 | "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, 30 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 31 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 32 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 33 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 34 | } 35 | -------------------------------------------------------------------------------- /test/support/command_case.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCommand.Test.CommandCase do 2 | @moduledoc false 3 | 4 | defmacro __using__(_) do 5 | quote do 6 | import EctoCommand.Test.CommandCase 7 | end 8 | end 9 | 10 | def errors_on(changeset) do 11 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 12 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 13 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 14 | end) 15 | end) 16 | end 17 | 18 | defmacro define_a_module_with_fields(module_name, do: block) do 19 | quote do 20 | defmodule unquote(module_name) do 21 | use EctoCommand, resource_type: "Sample", resource_id: :id 22 | 23 | command do 24 | unquote(block) 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/unit/command/creation_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Unit.EctoCommand.CreationTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | use EctoCommand.Test.CommandCase 6 | 7 | describe "new/1 function" do 8 | test "returns a valid command struct when there are not validations error" do 9 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 10 | 11 | define_a_module_with_fields module_name do 12 | param :name, :string, required: true 13 | param :surname, :string, required: true 14 | end 15 | 16 | assert {:ok, struct!(module_name, %{name: "foo", surname: "bar"})} == 17 | module_name.new(%{name: "foo", surname: "bar"}) 18 | end 19 | 20 | test "returns an invalid changeset when there are validations error" do 21 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 22 | 23 | define_a_module_with_fields module_name do 24 | param :name, :string, required: true, length: [min: 10, max: 99] 25 | param :surname, :string, required: true, length: [min: 10, max: 99] 26 | param :age, :integer, number: [greater_than_or_equal_to: 18] 27 | end 28 | 29 | assert {:error, changeset} = module_name.new(%{name: "foo", age: 15}) 30 | 31 | assert %{ 32 | age: ["must be greater than or equal to 18"], 33 | name: ["should be at least 10 character(s)"], 34 | surname: ["can't be blank"] 35 | } == errors_on(changeset) 36 | end 37 | 38 | test "a param could have subparams" do 39 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 40 | 41 | define_a_module_with_fields module_name do 42 | embeds_one :address, Address do 43 | param :street, :string 44 | param :city, :string 45 | param :zip, :string 46 | end 47 | end 48 | 49 | address_module = String.to_atom("Elixir.#{module_name}.Address") 50 | 51 | assert {:ok, 52 | struct!(module_name, %{ 53 | address: 54 | struct!(address_module, %{ 55 | street: "piazzale loreto", 56 | city: "it/milano", 57 | zip: "20142" 58 | }) 59 | })} == module_name.new(%{address: %{street: "piazzale loreto", city: "it/milano", zip: "20142"}}) 60 | end 61 | 62 | test "when a subparam is invalid an invalid changeset is returned" do 63 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 64 | 65 | define_a_module_with_fields module_name do 66 | embeds_one :address, Address do 67 | param :street, :string, required: true, length: [min: 10] 68 | param :city, :string, required: true 69 | param :zip, :string 70 | end 71 | end 72 | 73 | assert {:error, changeset} = module_name.new(%{address: %{street: "foo"}}) 74 | 75 | assert %{address: %{city: ["can't be blank"], street: ["should be at least 10 character(s)"]}} == 76 | errors_on(changeset) 77 | end 78 | 79 | test "an already existing module could be embedded" do 80 | embedded_module = String.to_atom("Sample#{:rand.uniform(999_999)}") 81 | 82 | define_a_module_with_fields embedded_module do 83 | param :street, :string, required: true, length: [min: 10] 84 | param :city, :string, required: true 85 | param :zip, :string 86 | end 87 | 88 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 89 | 90 | define_a_module_with_fields module_name do 91 | embeds_one :address, embedded_module 92 | end 93 | 94 | assert {:error, changeset} = module_name.new(%{address: %{street: "foo"}}) 95 | 96 | assert %{address: %{city: ["can't be blank"], street: ["should be at least 10 character(s)"]}} == 97 | errors_on(changeset) 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/unit/command/execution_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Unit.EctoCommand.ExecutionTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | use EctoCommand.Test.CommandCase 6 | 7 | describe "execute/1 function" do 8 | test "execute is called only when the command is valid" do 9 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 10 | 11 | defmodule module_name do 12 | use EctoCommand 13 | 14 | command do 15 | param :name, :string, required: true 16 | param :surname, :string, required: true 17 | end 18 | 19 | def execute(%__MODULE__{} = _command) do 20 | :executed 21 | end 22 | end 23 | 24 | assert :executed = module_name.execute(%{name: "foo", surname: "bar"}) 25 | assert {:error, %Ecto.Changeset{valid?: false}} = module_name.execute(%{}) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/unit/command/extra_validators_test.exs: -------------------------------------------------------------------------------- 1 | # credo:disable-for-this-file Credo.Check.Design.AliasUsage 2 | defmodule Unit.EctoCommand.ExtraValidatorsTest do 3 | @moduledoc false 4 | 5 | use ExUnit.Case, async: true 6 | use EctoCommand.Test.CommandCase 7 | 8 | test "is it possible to define an extra validator function" do 9 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 10 | 11 | define_a_module_with_fields module_name do 12 | param :name, :string 13 | param :surname, :string 14 | validate_with(&Unit.EctoCommand.ExtraValidatorsTest.custom_validation/2) 15 | end 16 | 17 | changeset = module_name.changeset(%{name: "foo", surname: "bar"}) 18 | assert %{name: ["data is not valid"]} == errors_on(changeset) 19 | end 20 | 21 | test "is it possible to pass options to an extra validator function" do 22 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 23 | 24 | define_a_module_with_fields module_name do 25 | param :name, :string 26 | param :surname, :string 27 | 28 | validate_with(&Unit.EctoCommand.ExtraValidatorsTest.custom_validation/2, 29 | field: :name, 30 | message: "my custom message" 31 | ) 32 | 33 | validate_with(&Unit.EctoCommand.ExtraValidatorsTest.custom_validation/2, 34 | field: :surname, 35 | message: "my custom message" 36 | ) 37 | end 38 | 39 | changeset = module_name.changeset(%{name: "foo", surname: "bar"}) 40 | assert %{name: ["my custom message"], surname: ["my custom message"]} == errors_on(changeset) 41 | end 42 | 43 | def custom_validation(changeset, opts \\ []) do 44 | message = opts[:message] || "data is not valid" 45 | field = opts[:field] || :name 46 | Ecto.Changeset.add_error(changeset, field, message) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/unit/command/middleware/pipeline_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Unit.EctoCommand.Middleware.PipelineTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | 6 | defmodule SampleCommand do 7 | defstruct [] 8 | 9 | def execute(%SampleCommand{} = _command) do 10 | {:ok, :result} 11 | end 12 | end 13 | 14 | alias EctoCommand.Middleware.Pipeline 15 | alias Unit.EctoCommand.Middleware.PipelineTest.SampleCommand 16 | 17 | doctest EctoCommand.Middleware.Pipeline, import: true 18 | end 19 | -------------------------------------------------------------------------------- /test/unit/command/middleware_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Unit.EctoCommand.MiddlewareTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | use EctoCommand.Test.CommandCase 6 | 7 | alias EctoCommand.Middleware.Pipeline 8 | 9 | defmodule SampleMiddleware do 10 | @moduledoc false 11 | 12 | alias EctoCommand.Middleware.Pipeline 13 | 14 | @behaviour EctoCommand.Middleware 15 | 16 | @doc false 17 | defmacro __using__(opts \\ []) do 18 | quote bind_quoted: [opts: opts, module: __MODULE__] do 19 | @middlewares {module, opts} 20 | end 21 | end 22 | 23 | def register_callback(kind, middleware_name, function) do 24 | Process.put({kind, middleware_name}, function) 25 | end 26 | 27 | def get_callback(kind, middleware_name, default_function) do 28 | Process.get({kind, middleware_name}, default_function) 29 | end 30 | 31 | @impl true 32 | def before_execution(pipeline, opts) do 33 | middleware_name = opts[:middleware_name] 34 | store_call(:before_execution, middleware_name) 35 | 36 | get_callback(:before_execution, middleware_name, fn pipeline -> pipeline end).(pipeline) 37 | end 38 | 39 | @impl true 40 | def after_execution(pipeline, opts) do 41 | middleware_name = opts[:middleware_name] 42 | store_call(:after_execution, middleware_name) 43 | 44 | get_callback(:after_execution, middleware_name, fn pipeline, _opts -> pipeline end).(pipeline, opts) 45 | end 46 | 47 | @impl true 48 | def after_failure(pipeline, opts) do 49 | middleware_name = opts[:middleware_name] 50 | store_call(:after_failure, middleware_name) 51 | 52 | get_callback(:after_failure, middleware_name, fn pipeline, _opts -> pipeline end).(pipeline, opts) 53 | end 54 | 55 | @impl true 56 | def invalid(pipeline, opts) do 57 | middleware_name = opts[:middleware_name] 58 | store_call(:invalid, middleware_name) 59 | 60 | get_callback(:invalid, middleware_name, fn pipeline, _opts -> pipeline end).(pipeline, opts) 61 | end 62 | 63 | defp store_call(kind, middleware_name) do 64 | kind 65 | |> Process.get([]) 66 | |> then(&Process.put(kind, &1 ++ [middleware_name])) 67 | end 68 | end 69 | 70 | defmodule SampleCommand do 71 | use EctoCommand 72 | 73 | command do 74 | param :name, :string, required: true 75 | end 76 | 77 | def execute(%__MODULE__{} = command) do 78 | case Process.get(:ecto_commandecution_should_fails, false) do 79 | true -> {:error, :an_error} 80 | false -> {:executed, command} 81 | end 82 | end 83 | end 84 | 85 | setup do 86 | Application.put_env(:ecto_command, :middlewares, [ 87 | {Unit.EctoCommand.MiddlewareTest.SampleMiddleware, middleware_name: :first_middleware}, 88 | {Unit.EctoCommand.MiddlewareTest.SampleMiddleware, middleware_name: :second_middleware} 89 | ]) 90 | 91 | on_exit(fn -> 92 | Application.put_env(:ecto_command, :middlewares, []) 93 | end) 94 | end 95 | 96 | describe "a middleware before_execution function" do 97 | test "is called for every registered middleware" do 98 | assert {:executed, _command} = SampleCommand.execute(%{name: "foo"}) 99 | assert [:first_middleware, :second_middleware] = Process.get(:before_execution, []) 100 | end 101 | 102 | test "is not called when the command is invalid" do 103 | SampleMiddleware.register_callback(:before_execution, :first_middleware, fn _error -> 104 | raise "Should not be called" 105 | end) 106 | 107 | assert {:error, _error} = SampleCommand.execute(%{}) 108 | assert [] = Process.get(:before_execution, []) 109 | end 110 | 111 | test "is not called when a previous middleware returns an error" do 112 | SampleMiddleware.register_callback(:before_execution, :first_middleware, fn pipeline -> 113 | Pipeline.halt(pipeline, {:error, :an_error}) 114 | end) 115 | 116 | assert {:error, :an_error} = SampleCommand.execute(%{name: "foo"}) 117 | assert [:first_middleware] == Process.get(:before_execution, []) 118 | end 119 | 120 | test "a middleware could update the command" do 121 | SampleMiddleware.register_callback(:before_execution, :first_middleware, fn pipeline -> 122 | Pipeline.update!(pipeline, :command, fn command -> %{command | name: "updated"} end) 123 | end) 124 | 125 | assert {:executed, %SampleCommand{name: "updated"}} == SampleCommand.execute(%{name: "foo"}) 126 | end 127 | end 128 | 129 | describe "a middleware after_execution function" do 130 | test "is called (in reverse order) for every registered middleware" do 131 | assert {:executed, _command} = SampleCommand.execute(%{name: "foo"}) 132 | assert [:second_middleware, :first_middleware] = Process.get(:after_execution, []) 133 | end 134 | 135 | test "a middleware could change the result" do 136 | SampleMiddleware.register_callback(:after_execution, :first_middleware, fn pipeline, _opts -> 137 | assert {:executed, _} = Pipeline.response(pipeline) 138 | Pipeline.respond(pipeline, {:error, :an_error}) 139 | end) 140 | 141 | assert {:error, :an_error} = SampleCommand.execute(%{name: "foo"}) 142 | assert [:first_middleware, :second_middleware] = Process.get(:before_execution, []) 143 | end 144 | end 145 | 146 | describe "a middleware invalid function" do 147 | test "is called for every registered middleware when the command is invalid" do 148 | assert {:error, _changeset} = SampleCommand.execute(%{}) 149 | assert [:first_middleware, :second_middleware] = Process.get(:invalid, []) 150 | end 151 | end 152 | 153 | describe "a middleware after_failure function" do 154 | test "is called (in reverse order) for every registered middleware when the execution fails" do 155 | Process.put(:ecto_commandecution_should_fails, true) 156 | 157 | assert {:error, :an_error} = SampleCommand.execute(%{name: "foo"}) 158 | assert [:second_middleware, :first_middleware] = Process.get(:after_failure, []) 159 | end 160 | 161 | test "a middleware could change the failed result" do 162 | SampleMiddleware.register_callback(:after_failure, :first_middleware, fn pipeline, _opts -> 163 | assert {:error, :an_error} = Pipeline.response(pipeline) 164 | Pipeline.respond(pipeline, {:ok, :updated_result}) 165 | end) 166 | 167 | Process.put(:ecto_commandecution_should_fails, true) 168 | 169 | assert {:ok, :updated_result} = SampleCommand.execute(%{name: "foo"}) 170 | end 171 | end 172 | 173 | describe "use command defined middlewares when are set" do 174 | defmodule SampleCommandWithMiddlewares do 175 | use EctoCommand 176 | use SampleMiddleware, middleware_name: :third_middleware 177 | use SampleMiddleware, middleware_name: :fourth_middleware 178 | 179 | command do 180 | param :name, :string, required: true 181 | end 182 | 183 | def execute(%__MODULE__{} = command) do 184 | {:executed, command} 185 | end 186 | end 187 | 188 | test "middlewares are executed in the right order" do 189 | assert {:executed, _command} = SampleCommandWithMiddlewares.execute(%{name: "foo"}) 190 | 191 | assert [:first_middleware, :second_middleware, :third_middleware, :fourth_middleware] = 192 | Process.get(:before_execution, []) 193 | end 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /test/unit/command/open_api/open_api_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Unit.EctoCommand.OpenApi.OpenApiTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | use EctoCommand.Test.CommandCase 6 | 7 | defmodule Sample do 8 | @moduledoc false 9 | 10 | use EctoCommand 11 | use EctoCommand.OpenApi, title: "Sample" 12 | 13 | command do 14 | param :id, :string, doc: Type.uuid() 15 | param :hidden_field, :string, required: true, inclusion: ["a", "b"], doc: false 16 | param :numeric_id, :id 17 | param :name, :string, required: true, length: [min: 2, max: 255], doc: [example: "Mario"] 18 | param :email, :string, required: true, format: ~r/@/, length: [min: 6], doc: Type.email() 19 | param :phone, :string, length: [min: 9], doc: Type.phone() 20 | param :extension, :string, required: false, length: [is: 3], doc: [example: "png"] 21 | 22 | param :mime_type, :string, 23 | required: true, 24 | inclusion: ["image/jpeg", "image/png"] 25 | 26 | param :an_enum, Ecto.Enum, values: [:a, :b] 27 | param :an_enum_stored_as_int, Ecto.Enum, values: [a: 1, b: 2] 28 | 29 | param :count, :integer, required: true, number: [greater_than_or_equal_to: 18, less_than: 100] 30 | param :an_integer_a, :integer, number: [equal_to: 20] 31 | param :an_integer_b, :integer, number: [not_equal_to: 20] 32 | param :an_integer_c, :integer, number: [greater_than: 18, less_than_or_equal_to: 100], doc: [example: 30] 33 | param :a_float, :float, number: [greater_than: 10, less_than_or_equal_to: 100] 34 | param :type_id, :string 35 | param :accepts, :boolean, default: false 36 | param :folder_id, :string, change: &String.valid?/1, doc: [example: "a_folder_id"] 37 | param :uploaded_at, :utc_datetime 38 | param :a_date, :date 39 | param :a_list_of_strings_a, {:array, :string}, default: [] 40 | param :a_list_of_strings_b, {:array, :string}, doc: [description: "A list of strings A"] 41 | param :a_list_of_strings_c, {:array, :string}, subset: ["a", "b", "c"], doc: [description: "A list of strings B"] 42 | param :a_list_of_enums, {:array, Ecto.Enum}, values: [:a, :b, :c], doc: [description: "A list of enums"] 43 | param :a_map, :map, doc: [description: "A map"], default: %{} 44 | param :a_map_with_int_values, {:map, :integer}, doc: [description: "A map with integer values"], default: %{a: 1} 45 | 46 | internal :triggered_by, :map 47 | internal :uploaded_by, :string 48 | end 49 | end 50 | 51 | test "all properties docs are generated correctly" do 52 | assert %{ 53 | accepts: %OpenApiSpex.Schema{example: false, type: :boolean, default: false}, 54 | an_integer_a: %OpenApiSpex.Schema{ 55 | exclusiveMaximum: false, 56 | exclusiveMinimum: false, 57 | maximum: 20, 58 | minimum: 20, 59 | type: :integer, 60 | example: 20 61 | }, 62 | an_integer_b: %OpenApiSpex.Schema{type: :integer, not: %{enum: [20]}, example: 21}, 63 | an_integer_c: %OpenApiSpex.Schema{ 64 | exclusiveMaximum: false, 65 | exclusiveMinimum: true, 66 | maximum: 100, 67 | minimum: 18, 68 | type: :integer, 69 | example: 30 70 | }, 71 | a_float: %OpenApiSpex.Schema{ 72 | exclusiveMaximum: false, 73 | exclusiveMinimum: true, 74 | maximum: 100, 75 | minimum: 10, 76 | type: :number, 77 | example: 55.5 78 | }, 79 | count: %OpenApiSpex.Schema{ 80 | exclusiveMaximum: true, 81 | exclusiveMinimum: false, 82 | maximum: 100, 83 | minimum: 18, 84 | type: :integer, 85 | example: 58 86 | }, 87 | email: %OpenApiSpex.Schema{ 88 | description: "Email", 89 | example: "user@domain.com", 90 | format: :email, 91 | minLength: 6, 92 | pattern: ~r/@/, 93 | type: :string 94 | }, 95 | extension: %OpenApiSpex.Schema{example: "png", maxLength: 3, type: :string}, 96 | folder_id: %OpenApiSpex.Schema{type: :string, example: "a_folder_id"}, 97 | id: %OpenApiSpex.Schema{ 98 | description: "UUID", 99 | example: "02ef9c5f-29e6-48fc-9ec3-7ed57ed351f6", 100 | format: :uuid, 101 | type: :string 102 | }, 103 | numeric_id: %OpenApiSpex.Schema{example: 10, type: :integer}, 104 | mime_type: %OpenApiSpex.Schema{enum: ["image/jpeg", "image/png"], example: "image/jpeg", type: :string}, 105 | name: %OpenApiSpex.Schema{example: "Mario", maxLength: 255, minLength: 2, type: :string}, 106 | phone: %OpenApiSpex.Schema{ 107 | description: "Telephone", 108 | example: "(425) 123-4567", 109 | format: :telephone, 110 | minLength: 9, 111 | type: :string 112 | }, 113 | type_id: %OpenApiSpex.Schema{type: :string, example: ""}, 114 | uploaded_at: %OpenApiSpex.Schema{ 115 | example: "2020-04-20T16:20:00Z", 116 | format: :"date-time", 117 | type: :string 118 | }, 119 | a_date: %OpenApiSpex.Schema{type: :string, format: :date, example: "2020-04-20"}, 120 | a_list_of_enums: %OpenApiSpex.Schema{ 121 | type: :array, 122 | items: %OpenApiSpex.Schema{enum: ["a", "b", "c"], type: :string, example: "a"}, 123 | example: ["a", "b"], 124 | description: "A list of enums" 125 | }, 126 | a_list_of_strings_a: %OpenApiSpex.Schema{ 127 | type: :array, 128 | items: %OpenApiSpex.Schema{type: :string, example: ""}, 129 | default: [], 130 | example: [] 131 | }, 132 | a_list_of_strings_b: %OpenApiSpex.Schema{ 133 | type: :array, 134 | items: %OpenApiSpex.Schema{type: :string, example: ""}, 135 | description: "A list of strings A", 136 | example: [""] 137 | }, 138 | a_list_of_strings_c: %OpenApiSpex.Schema{ 139 | type: :array, 140 | items: %OpenApiSpex.Schema{enum: ["a", "b", "c"], type: :string, example: "a"}, 141 | example: ["a", "b"], 142 | description: "A list of strings B" 143 | }, 144 | an_enum: %OpenApiSpex.Schema{enum: ["a", "b"], type: :string, example: "a"}, 145 | an_enum_stored_as_int: %OpenApiSpex.Schema{enum: ["a", "b"], type: :string, example: "a"}, 146 | a_map: %OpenApiSpex.Schema{ 147 | type: :object, 148 | properties: %{}, 149 | description: "A map", 150 | default: %{}, 151 | example: %{} 152 | }, 153 | a_map_with_int_values: %OpenApiSpex.Schema{ 154 | type: :object, 155 | properties: %{}, 156 | description: "A map with integer values", 157 | default: %{a: 1}, 158 | example: %{a: 1} 159 | } 160 | } == Sample.schema().properties 161 | 162 | refute Map.has_key?(Sample.schema().properties, :hidden_field) 163 | end 164 | 165 | test "example is generated accordingly to properties" do 166 | assert %{ 167 | a_date: "2020-04-20", 168 | a_float: 55.5, 169 | a_list_of_enums: ["a", "b"], 170 | a_list_of_strings_a: [], 171 | a_list_of_strings_b: [""], 172 | a_list_of_strings_c: ["a", "b"], 173 | a_map: %{}, 174 | a_map_with_int_values: %{a: 1}, 175 | accepts: false, 176 | an_enum: "a", 177 | an_enum_stored_as_int: "a", 178 | an_integer_a: 20, 179 | an_integer_b: 21, 180 | an_integer_c: 30, 181 | count: 58, 182 | email: "user@domain.com", 183 | extension: "png", 184 | folder_id: "a_folder_id", 185 | id: "02ef9c5f-29e6-48fc-9ec3-7ed57ed351f6", 186 | numeric_id: 10, 187 | mime_type: "image/jpeg", 188 | name: "Mario", 189 | phone: "(425) 123-4567", 190 | type_id: "", 191 | uploaded_at: "2020-04-20T16:20:00Z" 192 | } == Sample.schema().example 193 | 194 | refute Map.has_key?(Sample.schema().example, :hidden_field) 195 | end 196 | 197 | test "required fields list is generated correctly" do 198 | assert [:name, :email, :mime_type, :count] == Sample.schema().required 199 | end 200 | 201 | test "title and type are correctly set thanks to the use options" do 202 | assert %OpenApiSpex.Schema{title: "Sample", type: :object} = Sample.schema() 203 | end 204 | 205 | test "fields flagged as internal do not appear in the generated documentation" do 206 | schema = Sample.schema() 207 | 208 | refute Map.has_key?(schema.properties, :triggered_by) 209 | refute Map.has_key?(schema.properties, :uploaded_by) 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /test/unit/command/open_api/type_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Unit.EctoCommand.OpenApi.TypeTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | 6 | alias EctoCommand.OpenApi.Type 7 | alias OpenApiSpex.Schema 8 | 9 | describe "example_for/1 generates a valid example" do 10 | test "for arrays of enums" do 11 | assert [1, 2] == Type.example_for(%Schema{type: :array, items: %{type: :integer, enum: [1, 2, 3]}}) 12 | assert ["a", "b"] == Type.example_for(%Schema{type: :array, items: %{type: :string, enum: ["a", "b", "c"]}}) 13 | end 14 | 15 | test "for enums" do 16 | assert 1 == Type.example_for(%Schema{type: :integer, enum: [1, 2, 3]}) 17 | assert "a" == Type.example_for(%Schema{type: :string, enum: ["a", "b", "c"]}) 18 | end 19 | 20 | test "for integers" do 21 | assert 10 == Type.example_for(%Schema{type: :integer}) 22 | end 23 | 24 | test "for integers with exclusive 'min' constraint" do 25 | schema = %Schema{type: :integer, exclusiveMinimum: true, minimum: 100} 26 | assert 101 == Type.example_for(schema) 27 | end 28 | 29 | test "for integers with exclusive 'max' constraint" do 30 | schema = %OpenApiSpex.Schema{type: :integer, exclusiveMaximum: true, maximum: 10} 31 | assert 9 == Type.example_for(schema) 32 | end 33 | 34 | test "for integers with exclusive 'min' and 'max' constraints" do 35 | schema = %OpenApiSpex.Schema{ 36 | type: :integer, 37 | exclusiveMaximum: true, 38 | exclusiveMinimum: true, 39 | maximum: 10, 40 | minimum: 3 41 | } 42 | 43 | assert 6 == Type.example_for(schema) 44 | end 45 | 46 | test "for integers with inclusive 'min' constraint" do 47 | schema = %Schema{type: :integer, exclusiveMinimum: false, minimum: 100} 48 | assert 100 == Type.example_for(schema) 49 | end 50 | 51 | test "for integers with inclusive 'max' constraint" do 52 | schema = %OpenApiSpex.Schema{type: :integer, exclusiveMaximum: false, maximum: 10} 53 | assert 10 == Type.example_for(schema) 54 | end 55 | 56 | test "for integers with inclusive 'min' and 'max' constraints" do 57 | schema = %OpenApiSpex.Schema{ 58 | type: :integer, 59 | exclusiveMaximum: false, 60 | exclusiveMinimum: false, 61 | maximum: 10, 62 | minimum: 5 63 | } 64 | 65 | assert 7 == Type.example_for(schema) 66 | end 67 | 68 | test "for numbers" do 69 | assert 10.0 == Type.example_for(%Schema{type: :number}) 70 | end 71 | 72 | test "for numbers with exclusive 'min' constraint" do 73 | schema = %Schema{type: :number, exclusiveMinimum: true, minimum: 100.0} 74 | assert 101.0 == Type.example_for(schema) 75 | end 76 | 77 | test "for numbers with exclusive 'max' constraint" do 78 | schema = %OpenApiSpex.Schema{type: :number, exclusiveMaximum: true, maximum: 10.0} 79 | assert 9.0 == Type.example_for(schema) 80 | end 81 | 82 | test "for numbers with exclusive 'min' and 'max' constraints" do 83 | schema = %OpenApiSpex.Schema{ 84 | type: :number, 85 | exclusiveMaximum: true, 86 | exclusiveMinimum: true, 87 | maximum: 10, 88 | minimum: 5 89 | } 90 | 91 | assert 7.5 == Type.example_for(schema) 92 | end 93 | 94 | test "for numbers with inclusive 'min' constraint" do 95 | schema = %Schema{type: :number, exclusiveMinimum: false, minimum: 100} 96 | assert 100.0 == Type.example_for(schema) 97 | end 98 | 99 | test "for numbers with inclusive 'max' constraint" do 100 | schema = %OpenApiSpex.Schema{type: :number, exclusiveMaximum: false, maximum: 10} 101 | assert 10.0 == Type.example_for(schema) 102 | end 103 | 104 | test "for numbers with inclusive 'min' and 'max' constraints" do 105 | schema = %OpenApiSpex.Schema{ 106 | type: :number, 107 | exclusiveMaximum: false, 108 | exclusiveMinimum: false, 109 | maximum: 10, 110 | minimum: 9 111 | } 112 | 113 | assert 9.5 == Type.example_for(schema) 114 | end 115 | 116 | test "for dates" do 117 | assert "2020-04-20" == Type.example_for(%Schema{type: :string, format: :date}) 118 | end 119 | 120 | test "for datetimes" do 121 | assert "2020-04-20T16:20:00Z" == Type.example_for(%Schema{type: :string, format: :"date-time"}) 122 | end 123 | 124 | test "for UUIDs" do 125 | assert "02ef9c5f-29e6-48fc-9ec3-7ed57ed351f6" == Type.example_for(%Schema{type: :string, format: :uuid}) 126 | end 127 | 128 | test "for emails" do 129 | assert "user@domain.com" == Type.example_for(%Schema{type: :string, format: :email}) 130 | end 131 | 132 | test "for passwords" do 133 | assert "Abcd123!!" == Type.example_for(%Schema{type: :string, format: :password}) 134 | end 135 | 136 | test "for telephones" do 137 | assert "(425) 123-4567" == Type.example_for(%Schema{type: :string, format: :telephone}) 138 | end 139 | 140 | test "for arrays without inner type example and without default" do 141 | assert [] == Type.example_for(%Schema{type: :array, items: %Schema{type: :integer}}) 142 | end 143 | 144 | test "for arrays with inner type example and without default" do 145 | assert [1] == Type.example_for(%Schema{type: :array, items: %Schema{type: :integer, example: 1}}) 146 | end 147 | 148 | test "for arrays with inner type example and with default" do 149 | assert [1, 2, 3] == 150 | Type.example_for(%Schema{type: :array, items: %Schema{type: :integer, example: 1}, default: [1, 2, 3]}) 151 | end 152 | 153 | test "for object" do 154 | schema = %OpenApiSpex.Schema{ 155 | type: :object, 156 | properties: %{ 157 | active: %OpenApiSpex.Schema{type: :boolean, default: true, example: true}, 158 | count: %OpenApiSpex.Schema{minimum: 10, exclusiveMinimum: true, type: :integer, example: 11}, 159 | id: %OpenApiSpex.Schema{type: :string}, 160 | name: %OpenApiSpex.Schema{type: :string}, 161 | type: %OpenApiSpex.Schema{enum: ["a", "b"], type: :string, example: "a"}, 162 | tags: %OpenApiSpex.Schema{type: :array, items: %OpenApiSpex.Schema{type: :string}, default: []}, 163 | non_required_id: %OpenApiSpex.Schema{type: :string} 164 | } 165 | } 166 | 167 | assert %{ 168 | active: true, 169 | count: 11, 170 | id: "", 171 | name: "", 172 | type: "a", 173 | tags: [], 174 | non_required_id: "" 175 | } == Type.example_for(schema) 176 | end 177 | end 178 | 179 | test "example_for/1 returns nil in all other cases" do 180 | assert nil == Type.example_for(%Schema{}) 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /test/unit/command/options_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Unit.EctoCommand.OptionsTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | use EctoCommand.Test.CommandCase 6 | 7 | describe ":internal option" do 8 | test "an internal field is not casted" do 9 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 10 | 11 | define_a_module_with_fields module_name do 12 | param :name, :string 13 | internal :surname, :string 14 | end 15 | 16 | changeset = module_name.changeset(%{name: "foo", surname: "bar"}) 17 | assert true == Map.has_key?(changeset.changes, :name) 18 | assert false == Map.has_key?(changeset.changes, :surname) 19 | end 20 | 21 | test "an internal field could be set by the 'fill/4' function" do 22 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 23 | 24 | defmodule module_name do 25 | use EctoCommand, resource_type: "Sample", resource_id: :id 26 | 27 | command do 28 | param :name, :string 29 | internal :surname, :string 30 | end 31 | 32 | def fill(:surname, changeset, params, _metadata) do 33 | "my custom set: #{changeset.changes.name} #{params["surname"]}" 34 | end 35 | end 36 | 37 | changeset = module_name.changeset(%{name: "foo", surname: "bar"}) 38 | assert %{name: "foo", surname: "my custom set: foo bar"} == changeset.changes 39 | end 40 | end 41 | 42 | describe ":trim option" do 43 | test "a field with :trim option is trimmed" do 44 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 45 | 46 | define_a_module_with_fields module_name do 47 | param :name, :string, trim: true 48 | param :surname, :string, trim: false 49 | end 50 | 51 | assert {:ok, %{name: "foo", surname: " bar "}} = module_name.new(%{name: " foo ", surname: " bar "}) 52 | end 53 | 54 | test "only :string fields could have the :trim option" do 55 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 56 | 57 | assert_raise(ArgumentError, ~r/with string fields/, fn -> 58 | define_a_module_with_fields module_name do 59 | param :age, :integer, trim: true 60 | end 61 | end) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/unit/command/validators_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Unit.EctoCommand.ValidatorsTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | use EctoCommand.Test.CommandCase 6 | 7 | describe "all ecto validators are supported" do 8 | test "change" do 9 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 10 | 11 | define_a_module_with_fields module_name do 12 | param :name, :string, change: &unquote(__MODULE__).always_invalid/2 13 | 14 | param :surname, :string, change: {{:my_validator, [min: 2]}, &unquote(__MODULE__).always_valid/2} 15 | end 16 | 17 | assert [surname: {:my_validator, [min: 2]}] == defined_validators(module_name), 18 | "just surname has stored metadata" 19 | 20 | assert %{name: ["data is not valid"]} == 21 | errors_on_execution(module_name, %{name: "foo", surname: "bar"}) 22 | end 23 | 24 | test "acceptance" do 25 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 26 | 27 | define_a_module_with_fields module_name do 28 | param :terms_and_condition, :boolean, acceptance: true 29 | end 30 | 31 | assert [terms_and_condition: {:acceptance, []}] == defined_validators(module_name) 32 | 33 | assert %{terms_and_condition: ["must be accepted"]} == 34 | errors_on_execution(module_name, %{terms_and_condition: false}) 35 | 36 | assert %{} == errors_on_execution(module_name, %{terms_and_condition: true}) 37 | end 38 | 39 | test "confirmation" do 40 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 41 | 42 | define_a_module_with_fields module_name do 43 | param :email, :string, confirmation: true 44 | param :email_confirmation, :string 45 | end 46 | 47 | assert [email: {:confirmation, []}] == defined_validators(module_name) 48 | 49 | assert %{} == 50 | errors_on_execution(module_name, %{ 51 | email: "foo@bar.it", 52 | email_confirmation: "foo@bar.it" 53 | }) 54 | 55 | assert %{email_confirmation: ["does not match confirmation"]} == 56 | errors_on_execution(module_name, %{ 57 | email: "foo@bar.it", 58 | email_confirmation: "not_matching@email" 59 | }) 60 | end 61 | 62 | test "exclusion" do 63 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 64 | 65 | define_a_module_with_fields module_name do 66 | param :role, :string, exclusion: ["admin"] 67 | end 68 | 69 | assert [role: {:exclusion, ["admin"]}] == defined_validators(module_name) 70 | assert %{} == errors_on_execution(module_name, %{role: "user"}) 71 | assert %{role: ["is reserved"]} == errors_on_execution(module_name, %{role: "admin"}) 72 | end 73 | 74 | test "format" do 75 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 76 | 77 | define_a_module_with_fields module_name do 78 | param :email, :string, format: ~r/@/ 79 | end 80 | 81 | assert [email: {:format, ~r/@/}] == defined_validators(module_name) 82 | assert %{} == errors_on_execution(module_name, %{email: "foo@bar.com"}) 83 | assert %{email: ["has invalid format"]} == errors_on_execution(module_name, %{email: "foo"}) 84 | end 85 | 86 | test "inclusion" do 87 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 88 | 89 | define_a_module_with_fields module_name do 90 | param :cardinal_direction, :string, inclusion: ["north", "east", "south", "west"] 91 | end 92 | 93 | assert [cardinal_direction: {:inclusion, ["north", "east", "south", "west"]}] == 94 | defined_validators(module_name) 95 | 96 | assert %{} == errors_on_execution(module_name, %{cardinal_direction: "north"}) 97 | 98 | assert %{cardinal_direction: ["is invalid"]} == 99 | errors_on_execution(module_name, %{cardinal_direction: "foobar"}) 100 | end 101 | 102 | test "length" do 103 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 104 | 105 | define_a_module_with_fields module_name do 106 | param :name, :string, length: [min: 3, max: 10] 107 | end 108 | 109 | assert [name: {:length, [min: 3, max: 10]}] == defined_validators(module_name) 110 | assert %{} == errors_on_execution(module_name, %{name: "foobar"}) 111 | 112 | assert %{name: ["should be at least 3 character(s)"]} == 113 | errors_on_execution(module_name, %{name: "f"}) 114 | 115 | assert %{name: ["should be at most 10 character(s)"]} == 116 | errors_on_execution(module_name, %{name: "foobarfoobar"}) 117 | end 118 | 119 | test "number" do 120 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 121 | 122 | define_a_module_with_fields module_name do 123 | param :age, :integer, number: [greater_than_or_equal_to: 18, less_than: 100] 124 | end 125 | 126 | assert [age: {:number, [greater_than_or_equal_to: 18, less_than: 100]}] == 127 | defined_validators(module_name) 128 | 129 | assert %{} == errors_on_execution(module_name, %{age: 20}) 130 | 131 | assert %{age: ["must be greater than or equal to 18"]} == 132 | errors_on_execution(module_name, %{age: 12}) 133 | 134 | assert %{age: ["must be less than 100"]} == errors_on_execution(module_name, %{age: 180}) 135 | end 136 | 137 | test "required" do 138 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 139 | 140 | define_a_module_with_fields module_name do 141 | param :name, :string, required: true 142 | end 143 | 144 | assert [] == defined_validators(module_name) 145 | assert %{} == errors_on_execution(module_name, %{name: "foobar"}) 146 | assert %{name: ["can't be blank"]} == errors_on_execution(module_name, %{}) 147 | assert %{name: ["can't be blank"]} == errors_on_execution(module_name, %{name: nil}) 148 | assert %{name: ["can't be blank"]} == errors_on_execution(module_name, %{name: ""}) 149 | end 150 | 151 | test "subset" do 152 | module_name = String.to_atom("Sample#{:rand.uniform(999_999)}") 153 | 154 | define_a_module_with_fields module_name do 155 | param :lottery_numbers, {:array, :integer}, subset: 0..99 156 | end 157 | 158 | assert [{:lottery_numbers, {:subset, 0..99}}] == defined_validators(module_name) 159 | assert %{} == errors_on_execution(module_name, %{lottery_numbers: [2, 38, 47]}) 160 | 161 | assert %{lottery_numbers: ["has an invalid entry"]} == 162 | errors_on_execution(module_name, %{lottery_numbers: [99, 109, 408]}) 163 | end 164 | end 165 | 166 | defp errors_on_execution(module_name, params) do 167 | params 168 | |> module_name.changeset() 169 | |> errors_on() 170 | end 171 | 172 | defp defined_validators(module_name) do 173 | %{} 174 | |> module_name.changeset() 175 | |> Ecto.Changeset.validations() 176 | end 177 | 178 | def always_invalid(field, _value), do: [{field, "data is not valid"}] 179 | def always_valid(_field, _value), do: [] 180 | end 181 | --------------------------------------------------------------------------------