├── .credo.exs ├── .formatter.exs ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .tool-versions ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── lib └── commandex.ex ├── mix.exs ├── mix.lock └── test ├── commandex_test.exs ├── support ├── generate_report.ex └── register_user.ex └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any exec using `mix credo -C `. If no exec name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: ["lib/", "src/", "test/", "web/", "apps/"], 25 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 26 | }, 27 | # 28 | # Load and configure plugins here: 29 | # 30 | plugins: [], 31 | # 32 | # If you create your own checks, you must specify the source files for 33 | # them here, so they can be loaded by Credo before running the analysis. 34 | # 35 | requires: [], 36 | # 37 | # If you want to enforce a style guide and need a more traditional linting 38 | # experience, you can change `strict` to `true` below: 39 | # 40 | strict: false, 41 | # 42 | # If you want to use uncolored output by default, you can change `color` 43 | # to `false` below: 44 | # 45 | color: true, 46 | # 47 | # You can customize the parameters of any check by adding a second element 48 | # to the tuple. 49 | # 50 | # To disable a check put `false` as second element: 51 | # 52 | # {Credo.Check.Design.DuplicatedCode, false} 53 | # 54 | checks: [ 55 | # 56 | ## Consistency Checks 57 | # 58 | {Credo.Check.Consistency.ExceptionNames, []}, 59 | {Credo.Check.Consistency.LineEndings, []}, 60 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 61 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 62 | {Credo.Check.Consistency.SpaceInParentheses, []}, 63 | {Credo.Check.Consistency.TabsOrSpaces, []}, 64 | 65 | # 66 | ## Design Checks 67 | # 68 | # You can customize the priority of any check 69 | # Priority values are: `low, normal, high, higher` 70 | # 71 | {Credo.Check.Design.AliasUsage, 72 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 73 | # You can also customize the exit_status of each check. 74 | # If you don't want TODO comments to cause `mix credo` to fail, just 75 | # set this value to 0 (zero). 76 | # 77 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 78 | {Credo.Check.Design.TagFIXME, []}, 79 | 80 | # 81 | ## Readability Checks 82 | # 83 | {Credo.Check.Readability.AliasOrder, []}, 84 | {Credo.Check.Readability.FunctionNames, []}, 85 | {Credo.Check.Readability.LargeNumbers, []}, 86 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 87 | {Credo.Check.Readability.ModuleAttributeNames, []}, 88 | {Credo.Check.Readability.ModuleDoc, []}, 89 | {Credo.Check.Readability.ModuleNames, []}, 90 | {Credo.Check.Readability.ParenthesesInCondition, []}, 91 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 92 | {Credo.Check.Readability.PredicateFunctionNames, []}, 93 | {Credo.Check.Readability.PreferImplicitTry, []}, 94 | {Credo.Check.Readability.RedundantBlankLines, []}, 95 | {Credo.Check.Readability.Semicolons, []}, 96 | {Credo.Check.Readability.SpaceAfterCommas, []}, 97 | {Credo.Check.Readability.StringSigils, []}, 98 | {Credo.Check.Readability.TrailingBlankLine, []}, 99 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 100 | # TODO: enable by default in Credo 1.1 101 | {Credo.Check.Readability.UnnecessaryAliasExpansion, false}, 102 | {Credo.Check.Readability.VariableNames, []}, 103 | 104 | # 105 | ## Refactoring Opportunities 106 | # 107 | {Credo.Check.Refactor.CondStatements, []}, 108 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 109 | {Credo.Check.Refactor.FunctionArity, []}, 110 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 111 | {Credo.Check.Refactor.MapInto, false}, 112 | {Credo.Check.Refactor.MatchInCondition, []}, 113 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 114 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 115 | {Credo.Check.Refactor.Nesting, []}, 116 | {Credo.Check.Refactor.UnlessWithElse, []}, 117 | {Credo.Check.Refactor.WithClauses, []}, 118 | 119 | # 120 | ## Warnings 121 | # 122 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 123 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 124 | {Credo.Check.Warning.IExPry, []}, 125 | {Credo.Check.Warning.IoInspect, []}, 126 | {Credo.Check.Warning.LazyLogging, false}, 127 | {Credo.Check.Warning.OperationOnSameValues, []}, 128 | {Credo.Check.Warning.OperationWithConstantResult, []}, 129 | {Credo.Check.Warning.RaiseInsideRescue, []}, 130 | {Credo.Check.Warning.UnusedEnumOperation, []}, 131 | {Credo.Check.Warning.UnusedFileOperation, []}, 132 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 133 | {Credo.Check.Warning.UnusedListOperation, []}, 134 | {Credo.Check.Warning.UnusedPathOperation, []}, 135 | {Credo.Check.Warning.UnusedRegexOperation, []}, 136 | {Credo.Check.Warning.UnusedStringOperation, []}, 137 | {Credo.Check.Warning.UnusedTupleOperation, []}, 138 | 139 | # 140 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 141 | # 142 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 143 | {Credo.Check.Consistency.UnusedVariableNames, false}, 144 | {Credo.Check.Design.DuplicatedCode, false}, 145 | {Credo.Check.Readability.AliasAs, false}, 146 | {Credo.Check.Readability.MultiAlias, false}, 147 | {Credo.Check.Readability.Specs, false}, 148 | {Credo.Check.Readability.SinglePipe, false}, 149 | {Credo.Check.Refactor.ABCSize, false}, 150 | {Credo.Check.Refactor.AppendSingleItem, false}, 151 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 152 | {Credo.Check.Refactor.ModuleDependencies, false}, 153 | {Credo.Check.Refactor.PipeChainStart, false}, 154 | {Credo.Check.Refactor.VariableRebinding, false}, 155 | {Credo.Check.Warning.MapGetUnsafePass, false}, 156 | {Credo.Check.Warning.UnsafeToAtom, false} 157 | 158 | # 159 | # Custom checks can be created using `mix credo.gen.check`. 160 | # 161 | ] 162 | } 163 | ] 164 | } 165 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | locals_without_parens = [ 2 | param: 1, 3 | param: 2, 4 | data: 1, 5 | pipeline: 1 6 | ] 7 | 8 | [ 9 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 10 | locals_without_parens: locals_without_parens, 11 | export: [ 12 | locals_without_parens: locals_without_parens 13 | ] 14 | ] 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [codedge-llc] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | prettier: 11 | name: Prettier 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | - name: Use Node.js 18.x 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: 18.x 20 | - name: Install Prettier 21 | run: npm install --global prettier 22 | - name: Run Prettier 23 | run: prettier --check --no-error-on-unmatched-pattern "**/*.{json,md,yml,yaml}" 24 | check: 25 | name: Format 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v3 29 | - name: Set up Elixir 30 | uses: erlef/setup-beam@v1 31 | with: 32 | elixir-version: "1.17.2" 33 | otp-version: "27.0.1" 34 | - name: Restore dependencies cache 35 | uses: actions/cache@v3 36 | with: 37 | path: deps 38 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 39 | restore-keys: ${{ runner.os }}-mix- 40 | - name: Install dependencies 41 | run: mix deps.get 42 | - name: Run formatter 43 | run: mix format --check-formatted 44 | dialyzer: 45 | name: Dialyzer 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v3 49 | - name: Set up Elixir 50 | uses: erlef/setup-beam@v1 51 | with: 52 | elixir-version: "1.17.2" 53 | otp-version: "27.0.1" 54 | - name: Restore dependencies cache 55 | uses: actions/cache@v3 56 | with: 57 | path: deps 58 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 59 | restore-keys: ${{ runner.os }}-mix- 60 | - name: Install dependencies 61 | run: mix deps.get 62 | - name: Run dialyzer 63 | run: mix dialyzer 64 | test: 65 | name: Test 66 | runs-on: ubuntu-latest 67 | steps: 68 | - uses: actions/checkout@v3 69 | - name: Set up Elixir 70 | uses: erlef/setup-beam@v1 71 | with: 72 | elixir-version: "1.17.2" 73 | otp-version: "27.0.1" 74 | - name: Restore dependencies cache 75 | uses: actions/cache@v3 76 | with: 77 | path: deps 78 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 79 | restore-keys: ${{ runner.os }}-mix- 80 | - name: Install dependencies 81 | run: mix deps.get 82 | - name: Run tests 83 | run: mix test 84 | -------------------------------------------------------------------------------- /.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 | commandex-*.tar 24 | 25 | # Ignore dialyzer files. 26 | /priv/plts/*.plt 27 | /priv/plts/*.plt.hash 28 | 29 | # Misc 30 | .DS_Store 31 | .iex.exs 32 | *.swp 33 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | _build 2 | cover 3 | deps 4 | dist 5 | doc 6 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.17.2-otp-27 2 | erlang 27.0.1 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | matrix: 3 | include: 4 | - elixir: 1.9 5 | otp_release: 22.0 6 | - elixir: 1.10 7 | otp_release: 22.0 8 | - elixir: 1.11 9 | otp_release: 23.0 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.5.1] - 2024-09-09 9 | 10 | ### Fixed 11 | 12 | - `import_deps: [:commandex]` in `.formatter.exs` works again. ([#15](https://github.com/codedge-llc/commandex/pull/15)) 13 | 14 | ## [0.5.0] - 2024-09-08 15 | 16 | ### Added 17 | 18 | - `run/0` function for commands that don't define any parameters. 19 | 20 | ### Changed 21 | 22 | - Raise `ArgumentError` if an invalid `pipeline` is defined. 23 | 24 | ## [0.4.1] - 2020-06-26 25 | 26 | ### Fixed 27 | 28 | - Set `false` parameter correctly when given a Map of params. Was previously 29 | evaluating to `nil`. 30 | 31 | ## [0.4.0] - 2020-05-03 32 | 33 | ### Added 34 | 35 | - Default typespecs and documentation for modules using Commandex. 36 | Note: this will break any existing modules that have `@type t` already defined. 37 | 38 | ## [0.3.0] - 2020-01-31 39 | 40 | ### Added 41 | 42 | - `param` now supports a `:default` option. (eg. `param :limit, default: 10`) 43 | - Added `new/0` to initialize commands without any parameters. 44 | - `pipeline` can now use a 1-arity anonymous function. (eg. `pipeline &IO.inspect/1`) 45 | 46 | ## [0.2.0] - 2020-01-21 47 | 48 | ### Added 49 | 50 | - Enhanced documentation to show `&run/1` shortcut 51 | 52 | ### Changed 53 | 54 | - Renamed `:error` to `:errors` on Command struct 55 | 56 | ## [0.1.0] - 2020-01-18 57 | 58 | - Initial release 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2024 Codedge LLC (https://www.codedge.io/) 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 | # Commandex 2 | 3 | > Make Elixir actions a first-class data type. 4 | 5 | [![CI](https://github.com/codedge-llc/commandex/actions/workflows/ci.yml/badge.svg)](https://github.com/codedge-llc/commandex/actions/workflows/ci.yml) 6 | [![Version](https://img.shields.io/hexpm/v/commandex.svg)](https://hex.pm/packages/commandex) 7 | [![Total Downloads](https://img.shields.io/hexpm/dt/commandex.svg)](https://hex.pm/packages/commandex) 8 | [![License](https://img.shields.io/hexpm/l/commandex.svg)](https://github.com/codedge-llc/commandex/blob/main/LICENSE) 9 | [![Last Updated](https://img.shields.io/github/last-commit/codedge-llc/commandex.svg)](https://github.com/codedge-llc/commandex/commits/main) 10 | [![Documentation](https://img.shields.io/badge/documentation-gray)](https://hexdocs.pm/commandex/) 11 | 12 | Commandex structs are a loose implementation of the command pattern, making it easy 13 | to wrap parameters, data, and errors into a well-defined struct. 14 | 15 | ## Installation 16 | 17 | Add commandex as a `mix.exs` dependency: 18 | 19 | ```elixir 20 | def deps do 21 | [ 22 | {:commandex, "~> 0.5.1"} 23 | ] 24 | end 25 | ``` 26 | 27 | ## Example Usage 28 | 29 | A fully implemented command module might look like this: 30 | 31 | ```elixir 32 | defmodule RegisterUser do 33 | import Commandex 34 | 35 | command do 36 | param :email 37 | param :password 38 | 39 | data :password_hash 40 | data :user 41 | 42 | pipeline :hash_password 43 | pipeline :create_user 44 | pipeline :send_welcome_email 45 | end 46 | 47 | def hash_password(command, %{password: nil} = _params, _data) do 48 | command 49 | |> put_error(:password, :not_given) 50 | |> halt() 51 | end 52 | 53 | def hash_password(command, %{password: password} = _params, _data) do 54 | put_data(command, :password_hash, Base.encode64(password)) 55 | end 56 | 57 | def create_user(command, %{email: email} = _params, %{password_hash: phash} = _data) do 58 | %User{} 59 | |> User.changeset(%{email: email, password_hash: phash}) 60 | |> Repo.insert() 61 | |> case do 62 | {:ok, user} -> put_data(command, :user, user) 63 | {:error, changeset} -> command |> put_error(:repo, changeset) |> halt() 64 | end 65 | end 66 | 67 | def send_welcome_email(command, _params, %{user: user}) do 68 | Mailer.send_welcome_email(user) 69 | command 70 | end 71 | end 72 | ``` 73 | 74 | The `command/1` macro will define a struct that looks like: 75 | 76 | ```elixir 77 | %RegisterUser{ 78 | success: false, 79 | halted: false, 80 | errors: %{}, 81 | params: %{email: nil, password: nil}, 82 | data: %{password_hash: nil, user: nil}, 83 | pipelines: [:hash_password, :create_user, :send_welcome_email] 84 | } 85 | ``` 86 | 87 | As well as two functions: 88 | 89 | ```elixir 90 | &RegisterUser.new/1 91 | &RegisterUser.run/1 92 | ``` 93 | 94 | `&new/1` parses parameters into a new struct. These can be either a keyword list 95 | or map with atom/string keys. 96 | 97 | `&run/1` takes a command struct and runs it through the pipeline functions defined 98 | in the command. Functions are executed _in the order in which they are defined_. 99 | If a command passes through all pipelines without calling `halt/1`, `:success` 100 | will be set to `true`. Otherwise, subsequent pipelines after the `halt/1` will 101 | be ignored and `:success` will be set to `false`. 102 | 103 | Running a command is easy: 104 | 105 | ```elixir 106 | %{email: "example@example.com", password: "asdf1234"} 107 | |> RegisterUser.new() 108 | |> RegisterUser.run() 109 | |> case do 110 | %{success: true, data: %{user: user}} -> 111 | # Success! We've got a user now 112 | 113 | %{success: false, errors: %{password: :not_given}} -> 114 | # Respond with a 400 or something 115 | 116 | %{success: false, errors: _errors} -> 117 | # I'm a lazy programmer that writes catch-all error handling 118 | end 119 | ``` 120 | 121 | For even leaner implementations, you can run a command by passing 122 | the params directly into `&run/1` without using `&new/1`: 123 | 124 | ```elixir 125 | %{email: "example@example.com", password: "asdf1234"} 126 | |> RegisterUser.run() 127 | ``` 128 | 129 | ## Contributing 130 | 131 | ### Testing 132 | 133 | Unit tests can be run with `mix test`. 134 | 135 | ### Formatting 136 | 137 | This project uses Elixir's `mix format` and [Prettier](https://prettier.io) for formatting. 138 | Add hooks in your editor of choice to run it after a save. Be sure it respects this project's 139 | `.formatter.exs`. 140 | 141 | ### Commits 142 | 143 | Git commit subjects use the [Karma style](http://karma-runner.github.io/5.0/dev/git-commit-msg.html). 144 | 145 | ## License 146 | 147 | Copyright (c) 2020-2024 Codedge LLC (https://www.codedge.io/) 148 | 149 | This library is MIT licensed. See the [LICENSE](https://github.com/codedge-llc/commandex/blob/main/LICENSE) for details. 150 | -------------------------------------------------------------------------------- /lib/commandex.ex: -------------------------------------------------------------------------------- 1 | defmodule Commandex do 2 | @moduledoc """ 3 | Defines a command struct. 4 | 5 | Commandex is a loose implementation of the command pattern, making it easy 6 | to wrap parameters, data, and errors into a well-defined struct. 7 | 8 | ## Example 9 | 10 | A fully implemented command module might look like this: 11 | 12 | defmodule RegisterUser do 13 | import Commandex 14 | 15 | command do 16 | param :email 17 | param :password 18 | 19 | data :password_hash 20 | data :user 21 | 22 | pipeline :hash_password 23 | pipeline :create_user 24 | pipeline :send_welcome_email 25 | end 26 | 27 | def hash_password(command, %{password: nil} = _params, _data) do 28 | command 29 | |> put_error(:password, :not_given) 30 | |> halt() 31 | end 32 | 33 | def hash_password(command, %{password: password} = _params, _data) do 34 | put_data(command, :password_hash, Base.encode64(password)) 35 | end 36 | 37 | def create_user(command, %{email: email} = _params, %{password_hash: phash} = _data) do 38 | %User{} 39 | |> User.changeset(%{email: email, password_hash: phash}) 40 | |> Repo.insert() 41 | |> case do 42 | {:ok, user} -> put_data(command, :user, user) 43 | {:error, changeset} -> command |> put_error(:repo, changeset) |> halt() 44 | end 45 | end 46 | 47 | def send_welcome_email(command, _params, %{user: user}) do 48 | Mailer.send_welcome_email(user) 49 | command 50 | end 51 | end 52 | 53 | The `command/1` macro will define a struct that looks like: 54 | 55 | %RegisterUser{ 56 | success: false, 57 | halted: false, 58 | errors: %{}, 59 | params: %{email: nil, password: nil}, 60 | data: %{password_hash: nil, user: nil}, 61 | pipelines: [:hash_password, :create_user, :send_welcome_email] 62 | } 63 | 64 | As well as two functions: 65 | 66 | &RegisterUser.new/1 67 | &RegisterUser.run/1 68 | 69 | `&new/1` parses parameters into a new struct. These can be either a keyword list 70 | or map with atom/string keys. 71 | 72 | `&run/1` takes a command struct and runs it through the pipeline functions defined 73 | in the command. **Functions are executed in the order in which they are defined**. 74 | If a command passes through all pipelines without calling `halt/1`, `:success` 75 | will be set to `true`. Otherwise, subsequent pipelines after the `halt/1` will 76 | be ignored and `:success` will be set to `false`. 77 | 78 | %{email: "example@example.com", password: "asdf1234"} 79 | |> RegisterUser.new() 80 | |> RegisterUser.run() 81 | |> case do 82 | %{success: true, data: %{user: user}} -> 83 | # Success! We've got a user now 84 | 85 | %{success: false, errors: %{password: :not_given}} -> 86 | # Respond with a 400 or something 87 | 88 | %{success: false, errors: _error} -> 89 | # I'm a lazy programmer that writes catch-all error handling 90 | end 91 | 92 | ## Parameter-less Commands 93 | 94 | If a command does not have any parameters defined, a `run/0` will be generated 95 | automatically. Useful for diagnostic jobs and internal tasks. 96 | 97 | iex> GenerateReport.run() 98 | %GenerateReport{ 99 | pipelines: [:fetch_data, :calculate_results], 100 | data: %{total_valid: 183220, total_invalid: 781215}, 101 | params: %{}, 102 | halted: false, 103 | errors: %{}, 104 | success: true 105 | } 106 | """ 107 | 108 | @typedoc """ 109 | Command pipeline stage. 110 | 111 | A pipeline function can be defined multiple ways: 112 | 113 | - `pipeline :do_work` - Name of a function inside the command's module, arity three. 114 | - `pipeline {YourModule, :do_work}` - Arity three. 115 | - `pipeline {YourModule, :do_work, [:additonal, "args"]}` - Arity three plus the 116 | number of additional args given. 117 | - `pipeline &YourModule.do_work/1` - Or any anonymous function of arity one. 118 | - `pipeline &YourModule.do_work/3` - Or any anonymous function of arity three. 119 | """ 120 | @type pipeline :: 121 | atom 122 | | {module, atom} 123 | | {module, atom, [any]} 124 | | (command :: struct -> command :: struct) 125 | | (command :: struct, params :: map, data :: map -> command :: struct) 126 | 127 | @typedoc """ 128 | Command struct. 129 | 130 | ## Attributes 131 | 132 | - `data` - Data generated during the pipeline, defined by `Commandex.data/1`. 133 | - `errors` - Errors generated during the pipeline with `Commandex.put_error/3` 134 | - `halted` - Whether or not the pipeline was halted. 135 | - `params` - Parameters given to the command, defined by `Commandex.param/1`. 136 | - `pipelines` - A list of pipeline functions to execute, defined by `Commandex.pipeline/1`. 137 | - `success` - Whether or not the command was successful. This is only set to 138 | `true` if the command was not halted after running all of the pipelines. 139 | """ 140 | @type command :: %{ 141 | __struct__: atom, 142 | data: map, 143 | errors: map, 144 | halted: boolean, 145 | params: map, 146 | pipelines: [pipeline()], 147 | success: boolean 148 | } 149 | 150 | @doc """ 151 | Defines a command struct with params, data, and pipelines. 152 | """ 153 | @spec command(do: any) :: no_return 154 | defmacro command(do: block) do 155 | prelude = 156 | quote do 157 | for name <- [:struct_fields, :params, :data, :pipelines] do 158 | Module.register_attribute(__MODULE__, name, accumulate: true) 159 | end 160 | 161 | for field <- [{:success, false}, {:errors, %{}}, {:halted, false}] do 162 | Module.put_attribute(__MODULE__, :struct_fields, field) 163 | end 164 | 165 | try do 166 | import Commandex 167 | unquote(block) 168 | after 169 | :ok 170 | end 171 | end 172 | 173 | postlude = 174 | quote unquote: false do 175 | params = for pair <- Module.get_attribute(__MODULE__, :params), into: %{}, do: pair 176 | data = for pair <- Module.get_attribute(__MODULE__, :data), into: %{}, do: pair 177 | pipelines = __MODULE__ |> Module.get_attribute(:pipelines) |> Enum.reverse() 178 | 179 | Module.put_attribute(__MODULE__, :struct_fields, {:params, params}) 180 | Module.put_attribute(__MODULE__, :struct_fields, {:data, data}) 181 | Module.put_attribute(__MODULE__, :struct_fields, {:pipelines, pipelines}) 182 | defstruct @struct_fields 183 | 184 | @typedoc """ 185 | Command struct. 186 | 187 | ## Attributes 188 | 189 | - `data` - Data generated during the pipeline, defined by `Commandex.data/1`. 190 | - `errors` - Errors generated during the pipeline with `Commandex.put_error/3` 191 | - `halted` - Whether or not the pipeline was halted. 192 | - `params` - Parameters given to the command, defined by `Commandex.param/1`. 193 | - `pipelines` - A list of pipeline functions to execute, defined by `Commandex.pipeline/1`. 194 | - `success` - Whether or not the command was successful. This is only set to 195 | `true` if the command was not halted after running all of the pipelines. 196 | """ 197 | @type t :: %__MODULE__{ 198 | data: map, 199 | errors: map, 200 | halted: boolean, 201 | params: map, 202 | pipelines: [Commandex.pipeline()], 203 | success: boolean 204 | } 205 | 206 | @doc """ 207 | Creates a new struct from given parameters. 208 | """ 209 | @spec new(map | Keyword.t()) :: t 210 | def new(opts \\ []) do 211 | Commandex.parse_params(%__MODULE__{}, opts) 212 | end 213 | 214 | if Enum.empty?(params) do 215 | @doc """ 216 | Runs given pipelines in order and returns command struct. 217 | """ 218 | @spec run :: t 219 | def run do 220 | new() 221 | |> run() 222 | end 223 | end 224 | 225 | @doc """ 226 | Runs given pipelines in order and returns command struct. 227 | 228 | `run/1` can either take parameters that would be passed to `new/1` 229 | or the command struct itself. 230 | """ 231 | @spec run(map | Keyword.t() | t) :: t 232 | def run(%unquote(__MODULE__){pipelines: pipelines} = command) do 233 | pipelines 234 | |> Enum.reduce_while(command, fn fun, acc -> 235 | case acc do 236 | %{halted: false} -> {:cont, Commandex.apply_fun(acc, fun)} 237 | _ -> {:halt, acc} 238 | end 239 | end) 240 | |> Commandex.maybe_mark_successful() 241 | end 242 | 243 | def run(params) do 244 | params 245 | |> new() 246 | |> run() 247 | end 248 | end 249 | 250 | quote do 251 | unquote(prelude) 252 | unquote(postlude) 253 | end 254 | end 255 | 256 | @doc """ 257 | Defines a command parameter field. 258 | 259 | Parameters are supplied at struct creation, before any pipelines are run. 260 | 261 | command do 262 | param :email 263 | param :password 264 | 265 | # ...data 266 | # ...pipelines 267 | end 268 | """ 269 | @spec param(atom, Keyword.t()) :: no_return 270 | defmacro param(name, opts \\ []) do 271 | quote do 272 | Commandex.__param__(__MODULE__, unquote(name), unquote(opts)) 273 | end 274 | end 275 | 276 | @doc """ 277 | Defines a command data field. 278 | 279 | Data field values are created and set as pipelines are run. Set one with `put_data/3`. 280 | 281 | command do 282 | # ...params 283 | 284 | data :password_hash 285 | data :user 286 | 287 | # ...pipelines 288 | end 289 | """ 290 | @spec data(atom) :: no_return 291 | defmacro data(name) do 292 | quote do 293 | Commandex.__data__(__MODULE__, unquote(name)) 294 | end 295 | end 296 | 297 | @doc """ 298 | Defines a command pipeline. 299 | 300 | Pipelines are functions executed against the command, *in the order in which they are defined*. 301 | 302 | For example, two pipelines could be defined: 303 | 304 | pipeline :check_valid_email 305 | pipeline :create_user 306 | 307 | Which could be mentally interpreted as: 308 | 309 | command 310 | |> check_valid_email() 311 | |> create_user() 312 | 313 | A pipeline function can be defined multiple ways: 314 | 315 | - `pipeline :do_work` - Name of a function inside the command's module, arity three. 316 | - `pipeline {YourModule, :do_work}` - Arity three. 317 | - `pipeline {YourModule, :do_work, [:additonal, "args"]}` - Arity three plus the 318 | number of additional args given. 319 | - `pipeline &YourModule.do_work/1` - Or any anonymous function of arity one. 320 | - `pipeline &YourModule.do_work/3` - Or any anonymous function of arity three. 321 | """ 322 | @spec pipeline(atom) :: no_return 323 | defmacro pipeline(name) do 324 | quote do 325 | Commandex.__pipeline__(__MODULE__, unquote(name)) 326 | end 327 | end 328 | 329 | @doc """ 330 | Sets a data field with given value. 331 | 332 | Define a data field first: 333 | 334 | data :password_hash 335 | 336 | Set the password pash in one of your pipeline functions: 337 | 338 | def hash_password(command, %{password: password} = _params, _data) do 339 | # Better than plaintext, I guess 340 | put_data(command, :password_hash, Base.encode64(password)) 341 | end 342 | """ 343 | @spec put_data(command, atom, any) :: command 344 | def put_data(%{data: data} = command, key, val) do 345 | %{command | data: Map.put(data, key, val)} 346 | end 347 | 348 | @doc """ 349 | Sets error for given key and value. 350 | 351 | `:errors` is a map. Putting an error on the same key will overwrite the previous value. 352 | 353 | def hash_password(command, %{password: nil} = _params, _data) do 354 | command 355 | |> put_error(:password, :not_supplied) 356 | |> halt() 357 | end 358 | """ 359 | @spec put_error(command, any, any) :: command 360 | def put_error(%{errors: error} = command, key, val) do 361 | %{command | errors: Map.put(error, key, val)} 362 | end 363 | 364 | @doc """ 365 | Halts a command pipeline. 366 | 367 | Any pipelines defined after the halt will be ignored. If a command finishes running through 368 | all pipelines, `:success` will be set to `true`. 369 | 370 | def hash_password(command, %{password: nil} = _params, _data) do 371 | command 372 | |> put_error(:password, :not_supplied) 373 | |> halt() 374 | end 375 | """ 376 | @spec halt(command) :: command 377 | def halt(command), do: %{command | halted: true} 378 | 379 | @doc false 380 | def maybe_mark_successful(%{halted: false} = command), do: %{command | success: true} 381 | def maybe_mark_successful(command), do: command 382 | 383 | @doc false 384 | def parse_params(%{params: p} = struct, params) when is_list(params) do 385 | params = for {key, _} <- p, into: %{}, do: {key, Keyword.get(params, key, p[key])} 386 | %{struct | params: params} 387 | end 388 | 389 | def parse_params(%{params: p} = struct, %{} = params) do 390 | params = for {key, _} <- p, into: %{}, do: {key, get_param(params, key, p[key])} 391 | %{struct | params: params} 392 | end 393 | 394 | @doc false 395 | def apply_fun(%mod{params: params, data: data} = command, name) when is_atom(name) do 396 | :erlang.apply(mod, name, [command, params, data]) 397 | end 398 | 399 | def apply_fun(command, fun) when is_function(fun, 1) do 400 | fun.(command) 401 | end 402 | 403 | def apply_fun(%{params: params, data: data} = command, fun) when is_function(fun, 3) do 404 | fun.(command, params, data) 405 | end 406 | 407 | def apply_fun(%{params: params, data: data} = command, {m, f}) do 408 | :erlang.apply(m, f, [command, params, data]) 409 | end 410 | 411 | def apply_fun(%{params: params, data: data} = command, {m, f, a}) do 412 | :erlang.apply(m, f, [command, params, data] ++ a) 413 | end 414 | 415 | def __param__(mod, name, opts) do 416 | params = Module.get_attribute(mod, :params) 417 | 418 | if List.keyfind(params, name, 0) do 419 | raise ArgumentError, "param #{inspect(name)} is already set on command" 420 | end 421 | 422 | default = Keyword.get(opts, :default) 423 | Module.put_attribute(mod, :params, {name, default}) 424 | end 425 | 426 | def __data__(mod, name) do 427 | data = Module.get_attribute(mod, :data) 428 | 429 | if List.keyfind(data, name, 0) do 430 | raise ArgumentError, "data #{inspect(name)} is already set on command" 431 | end 432 | 433 | Module.put_attribute(mod, :data, {name, nil}) 434 | end 435 | 436 | def __pipeline__(mod, name) when is_atom(name) do 437 | Module.put_attribute(mod, :pipelines, name) 438 | end 439 | 440 | def __pipeline__(mod, fun) when is_function(fun, 1) do 441 | Module.put_attribute(mod, :pipelines, fun) 442 | end 443 | 444 | def __pipeline__(mod, fun) when is_function(fun, 3) do 445 | Module.put_attribute(mod, :pipelines, fun) 446 | end 447 | 448 | def __pipeline__(mod, {m, f}) do 449 | Module.put_attribute(mod, :pipelines, {m, f}) 450 | end 451 | 452 | def __pipeline__(mod, {m, f, a}) do 453 | Module.put_attribute(mod, :pipelines, {m, f, a}) 454 | end 455 | 456 | def __pipeline__(_mod, name) do 457 | raise ArgumentError, "pipeline #{inspect(name)} is not valid" 458 | end 459 | 460 | defp get_param(params, key, default) do 461 | case Map.get(params, key) do 462 | nil -> 463 | Map.get(params, to_string(key), default) 464 | 465 | val -> 466 | val 467 | end 468 | end 469 | end 470 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Commandex.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/codedge-llc/commandex" 5 | @version "0.5.1" 6 | 7 | def project do 8 | [ 9 | app: :commandex, 10 | deps: deps(), 11 | dialyzer: dialyzer(), 12 | docs: docs(), 13 | elixir: "~> 1.9", 14 | elixirc_paths: elixirc_paths(Mix.env()), 15 | name: "Commandex", 16 | package: package(), 17 | source_url: "https://github.com/codedge-llc/commandex", 18 | start_permanent: Mix.env() == :prod, 19 | test_coverage: test_coverage(), 20 | version: @version 21 | ] 22 | end 23 | 24 | defp deps do 25 | [ 26 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 27 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, 28 | {:ex_doc, "~> 0.31", only: :dev} 29 | ] 30 | end 31 | 32 | defp elixirc_paths(:test), do: ["lib", "test/support"] 33 | defp elixirc_paths(_), do: ["lib"] 34 | 35 | defp dialyzer do 36 | [ 37 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"} 38 | ] 39 | end 40 | 41 | defp docs do 42 | [ 43 | extras: [ 44 | "CHANGELOG.md", 45 | LICENSE: [title: "License"] 46 | ], 47 | formatters: ["html"], 48 | main: "Commandex", 49 | skip_undefined_reference_warnings_on: ["CHANGELOG.md"], 50 | source_ref: "v#{@version}", 51 | source_url: @source_url 52 | ] 53 | end 54 | 55 | defp package do 56 | [ 57 | description: "Make complex actions a first-class data type.", 58 | files: ["lib", "mix.exs", ".formatter.exs", "README*", "LICENSE*", "CHANGELOG*"], 59 | licenses: ["MIT"], 60 | links: %{ 61 | "Changelog" => "https://hexdocs.pm/commandex/changelog.html", 62 | "GitHub" => "https://github.com/codedge-llc/commandex", 63 | "Sponsor" => "https://github.com/sponsors/codedge-llc" 64 | }, 65 | maintainers: ["Henry Popp", "Tyler Hurst"] 66 | ] 67 | end 68 | 69 | defp test_coverage do 70 | [ 71 | ignore_modules: [ 72 | GenerateReport, 73 | RegisterUser 74 | ], 75 | summary: [threshold: 70] 76 | ] 77 | end 78 | 79 | def application do 80 | [ 81 | extra_applications: [:logger] 82 | ] 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [: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", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, 5 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 6 | "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 8 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 9 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [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", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 10 | "excoveralls": {:hex, :excoveralls, "0.12.1", "a553c59f6850d0aff3770e4729515762ba7c8e41eedde03208182a8dc9d0ce07", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 11 | "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, 12 | "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 14 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 15 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [: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", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 18 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 19 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, 20 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 21 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, 22 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"}, 23 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, 24 | } 25 | -------------------------------------------------------------------------------- /test/commandex_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CommandexTest do 2 | use ExUnit.Case 3 | doctest Commandex 4 | 5 | @email "example@example.com" 6 | @password "test1234" 7 | @agree_tos false 8 | 9 | describe "struct assembly" do 10 | test "sets :params map" do 11 | for key <- [:email, :password, :agree_tos] do 12 | assert Map.has_key?(%RegisterUser{}.params, key) 13 | end 14 | end 15 | 16 | test "sets param default if specified" do 17 | assert %RegisterUser{}.params.email == "test@test.com" 18 | end 19 | 20 | test "sets :data map" do 21 | for key <- [:user, :auth] do 22 | assert Map.has_key?(%RegisterUser{}.data, key) 23 | end 24 | end 25 | 26 | test "handles atom-key map params correctly" do 27 | params = %{ 28 | email: @email, 29 | password: @password, 30 | agree_tos: @agree_tos 31 | } 32 | 33 | command = RegisterUser.new(params) 34 | assert_params(command) 35 | end 36 | 37 | test "handles string-key map params correctly" do 38 | params = %{ 39 | email: @email, 40 | password: @password, 41 | agree_tos: @agree_tos 42 | } 43 | 44 | command = RegisterUser.new(params) 45 | assert_params(command) 46 | end 47 | 48 | test "handles keyword list params correctly" do 49 | params = [ 50 | email: @email, 51 | password: @password, 52 | agree_tos: @agree_tos 53 | ] 54 | 55 | command = RegisterUser.new(params) 56 | assert_params(command) 57 | end 58 | end 59 | 60 | describe "param/2 macro" do 61 | test "raises if duplicate defined" do 62 | assert_raise ArgumentError, fn -> 63 | defmodule ExampleParamInvalid do 64 | import Commandex 65 | 66 | command do 67 | param :key_1 68 | param :key_2 69 | param :key_1 70 | end 71 | end 72 | end 73 | end 74 | end 75 | 76 | describe "data/1 macro" do 77 | test "raises if duplicate defined" do 78 | assert_raise ArgumentError, fn -> 79 | defmodule ExampleDataInvalid do 80 | import Commandex 81 | 82 | command do 83 | data :key_1 84 | data :key_2 85 | data :key_1 86 | end 87 | end 88 | end 89 | end 90 | end 91 | 92 | describe "pipeline/1 macro" do 93 | test "accepts valid pipeline arguments" do 94 | try do 95 | defmodule ExamplePipelineValid do 96 | import Commandex 97 | 98 | command do 99 | pipeline :example 100 | pipeline {ExamplePipelineValid, :example} 101 | pipeline {ExamplePipelineValid, :example_args, ["test"]} 102 | pipeline &ExamplePipelineValid.example_single/1 103 | pipeline &ExamplePipelineValid.example/3 104 | end 105 | 106 | def example(command, _params, _data) do 107 | command 108 | end 109 | 110 | def example_single(command) do 111 | command 112 | end 113 | 114 | def example_args(command, _params, _data, _custom_value) do 115 | command 116 | end 117 | end 118 | 119 | ExamplePipelineValid.run() 120 | rescue 121 | FunctionClauseError -> flunk("Should not raise.") 122 | end 123 | end 124 | 125 | test "raises if invalid argument defined" do 126 | assert_raise ArgumentError, fn -> 127 | defmodule ExamplePipelineInvalid do 128 | import Commandex 129 | 130 | command do 131 | pipeline 1234 132 | end 133 | end 134 | end 135 | end 136 | end 137 | 138 | describe "halt/1" do 139 | test "ignores remaining pipelines" do 140 | command = RegisterUser.run(%{agree_tos: false}) 141 | 142 | refute command.success 143 | assert command.errors === %{tos: :not_accepted} 144 | end 145 | end 146 | 147 | describe "run/0" do 148 | test "is defined if no params are defined" do 149 | assert Kernel.function_exported?(GenerateReport, :run, 0) 150 | 151 | command = GenerateReport.run() 152 | assert command.success 153 | assert command.data.total_valid > 0 154 | assert command.data.total_invalid > 0 155 | end 156 | 157 | test "is not defined if params are defined" do 158 | refute Kernel.function_exported?(RegisterUser, :run, 0) 159 | end 160 | end 161 | 162 | defp assert_params(command) do 163 | assert command.params.email == @email 164 | assert command.params.password == @password 165 | # Don't use refute here because nil fails the test. 166 | assert command.params.agree_tos == @agree_tos 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /test/support/generate_report.ex: -------------------------------------------------------------------------------- 1 | defmodule GenerateReport do 2 | @moduledoc """ 3 | Example command that generates fake data. 4 | 5 | Used for testing parameter-less commands. 6 | """ 7 | 8 | import Commandex 9 | 10 | command do 11 | data :total_valid 12 | data :total_invalid 13 | 14 | pipeline :fetch_data 15 | pipeline :calculate_results 16 | end 17 | 18 | def fetch_data(command, _params, _data) do 19 | # Not real. 20 | command 21 | end 22 | 23 | def calculate_results(command, _params, _data) do 24 | command 25 | |> put_data(:total_valid, 183_220) 26 | |> put_data(:total_invalid, 781_215) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/support/register_user.ex: -------------------------------------------------------------------------------- 1 | defmodule RegisterUser do 2 | @moduledoc """ 3 | Example command that registers a user. 4 | """ 5 | 6 | import Commandex 7 | 8 | command do 9 | param :email, default: "test@test.com" 10 | param :password 11 | param :agree_tos 12 | 13 | data :user 14 | data :auth 15 | 16 | pipeline :verify_tos 17 | pipeline :create_user 18 | pipeline :record_auth_attempt 19 | pipeline &IO.inspect/1 20 | end 21 | 22 | def verify_tos(command, %{agree_tos: true} = _params, _data) do 23 | command 24 | end 25 | 26 | def verify_tos(command, %{agree_tos: false} = _params, _data) do 27 | command 28 | |> put_error(:tos, :not_accepted) 29 | |> halt() 30 | end 31 | 32 | def create_user(command, %{password: nil} = _params, _data) do 33 | command 34 | |> put_error(:password, :not_given) 35 | |> halt() 36 | end 37 | 38 | def create_user(command, %{email: email} = _params, _data) do 39 | put_data(command, :user, %{email: email}) 40 | end 41 | 42 | def record_auth_attempt(command, _params, _data) do 43 | put_data(command, :auth, true) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------