├── .formatter.exs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── NOTICE ├── README.md ├── lib ├── pipeline.ex └── pipeline │ ├── application.ex │ ├── pipeline_error.ex │ ├── state.ex │ ├── transform_error.ex │ └── types.ex ├── mix.exs ├── mix.lock └── test ├── pipeline └── state_test.exs ├── pipeline_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | include: 13 | - elixir: 1.12.x 14 | otp: 23 15 | - elixir: 1.12.x 16 | otp: 24 17 | - elixir: 1.12.x 18 | otp: 25 19 | - elixir: 1.13.x 20 | otp: 23 21 | - elixir: 1.13.x 22 | otp: 24 23 | - elixir: 1.13.x 24 | otp: 25 25 | lint: true 26 | env: 27 | MIX_ENV: test 28 | 29 | steps: 30 | - uses: actions/checkout@v2 31 | 32 | - uses: erlef/setup-beam@v1 33 | with: 34 | otp-version: ${{matrix.otp}} 35 | elixir-version: ${{matrix.elixir}} 36 | 37 | - run: mix deps.get 38 | 39 | - run: mix format --check-formatted 40 | if: ${{ matrix.lint }} 41 | 42 | - run: mix deps.unlock --check-unused 43 | if: ${{ matrix.lint }} 44 | 45 | - run: mix deps.compile 46 | 47 | - run: mix compile --warnings-as-errors 48 | if: ${{ matrix.lint }} 49 | 50 | - run: mix test 51 | -------------------------------------------------------------------------------- /.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 | ex_pipeline-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # ElixirLS files. 29 | /.elixir_ls/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog for `ex_pipeline` 2 | ## v0.2.0 3 | 4 | ### New Features 5 | - Async hooks: functions ending with `_async_hook` will be executed asynchronously, right after steps. 6 | - `Pipeline.State` now tracks the steps that were executed 7 | - The library now exports an application spec, `ex_pipeline` 8 | - Examples were added into some functions 9 | - Updated docs 10 | 11 | ### Changes 12 | - Callbacks are now called "hooks" 13 | - They must end with `_hook` instead of `_callback` 14 | - Tests are now async 15 | - The `Pipeline.State.invalidate/2` will now add a generic error message into the state. 16 | - Removed `Pipeline.State.callback/3` 17 | - Removed `errors` from `Pipeline.State` struct, since we only track the last error anyways. 18 | - The callbacks `__pipeline_steps__` and `__pipeline_callbacks` were merged into a single callback, `__pipeline__` 19 | that returns a tuple with all the information we need. 20 | 21 | 22 | ## v0.1.0 23 | 24 | First version! 25 | 26 | ### New Features 27 | - Basic pipeline building and state management -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | rms.mrcs [at] gmail.com. All complaints will be reviewed and investigated 65 | promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Marcos Ramos 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | https://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ExPipeline 2 | 3 | ![Build Status](https://github.com/msramos/ex_pipeline/actions/workflows/ci.yml/badge.svg?branch=main) 4 | [![Module Version](https://img.shields.io/hexpm/v/ex_pipeline.svg)](https://hex.pm/packages/ex_pipeline) 5 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/ex_pipeline/) 6 | [![License](https://img.shields.io/hexpm/l/ex_pipeline.svg)](https://github.com/msramos/ex_pipeline/blob/main/LICENSE) 7 | 8 | ExPipeline is an opinionated library to build better pipelines. 9 | 10 | A pipeline is set of functions that must be executed in a specific order to transform an initial state into a desired 11 | state. For example, a "login pipeline" uses the request body as its initial state and generates an authentication token. 12 | 13 | It allows a feature to expressed as a set of functions, like the following snippet: 14 | 15 | ```elixir 16 | defmodule MyFeature do 17 | use Pipeline 18 | 19 | def parse_step(value, options) do 20 | ... 21 | end 22 | 23 | def fetch_address_step(value, options) do 24 | ... 25 | end 26 | 27 | def final_step(value, options) do 28 | ... 29 | end 30 | 31 | def reporter_async_hook(%Pipeline.State{} = state, options) do 32 | ... 33 | end 34 | end 35 | ``` 36 | 37 | Later on, you can execute this feature by calling the generated `execute/2` function or the `Pipeline.execute/3` 38 | function: 39 | 40 | ```elixir 41 | MyFeature.execute(some_value, some_options) 42 | # or 43 | Pipeline.execute(MyPipeline, some_value, some_options) 44 | ``` 45 | 46 | These functions will return an ok/error tuple, so you can execute them with a `case` block , for example: 47 | 48 | ```elixir 49 | case MyFeature.execute(params, options) do 50 | {:ok, succesful_result} -> 51 | ... 52 | 53 | {:error, error_description} -> 54 | ... 55 | end 56 | ``` 57 | 58 | ## Creating Pipelines 59 | 60 | To create a pipeline, the target module **must** `use Pipeline`, and the functions must follow some patterns. 61 | 62 | * Functions that are part of the pipeline must end with `_step`, `_hook` or `_async_hook`. 63 | * They must accepts two parameters 64 | 65 | ### Steps 66 | 67 | Each step modify a _state_. The result of one step is given to the next step, until the last step. Then the result 68 | is evaluated and returned. 69 | 70 | * Steps are executed in the same order that they are declared. 71 | * The first parameter is whatever was passed to the pipeline, and each step transforms this value to the next value. 72 | * The second parameter is an optional and immutable keyword list that is passed to all steps. 73 | * A step **must** return an on/error tuple - `{:ok, any}` or `{:error, any}`. 74 | * If one step fails, the next steps are not executed. 75 | 76 | ### Hooks and Async Hooks 77 | 78 | Hooks and async hooks are executed after all steps have completed, regardless of their result. 79 | 80 | * Async hooks are functions whose name end with `_async_hook` and hooks are functions whose name end with `_hook`. 81 | * Both types of hooks **must** accept two parameters. The difference is that hooks receive the final `Pipeline.State` 82 | struct with the execution result. Hooks return are ignored. 83 | * The first parameter is the last version of the `Pipeline.State` struct from the evaluation of the last step. 84 | * The second parameter is the same optional and immutable keyword list that is passed to all step. 85 | * After all steps are executed, the pipeline will launch all __async hooks__ on isolated processes, and run them in 86 | parallel. 87 | * After all steps are executed, the pipeline will execute all __hooks__, in the same order that they were declared. 88 | 89 | 90 | ## Why? 91 | 92 | As features get more complex with time, Elixir pipes and `with` blocks can become harder to understand. Also, functions 93 | that are added to them over time don't really have a spec to follow. 94 | 95 | Let's take this simple checkout code as example: 96 | 97 | ```elixir 98 | with %Payment{} = payment <- fetch_payment_information(params), 99 | {:ok, user} <- Session.get(conn, :user), 100 | address when !is_nil(address) <- fetch_address(user, params), 101 | {:ok, order} <- create_order(user, payment, address) do 102 | conn 103 | |> put_flash(:info, "Order completed!") 104 | |> render("checkout.html") 105 | else 106 | {:error, :payment_failed} -> 107 | handle_error(conn, "Payment Error") 108 | 109 | %Store.OrderError{message: message} -> 110 | handle_error(conn, "Order Error") 111 | 112 | error -> 113 | handle_error(conn, "Unprocessable order") 114 | end 115 | ``` 116 | 117 | We can make it look better by applying some code styles and get somethig like this: 118 | 119 | ```elixir 120 | options = %{conn: conn} 121 | 122 | with {:ok, payment} <- fetch_payment_information(params, options), 123 | {:ok, user} <- fetch_user(conn), 124 | {:ok, address} <- fetch_address(%{user: user, params: params}, options), 125 | {:ok, order} <- create_order(%{user: user, address: address, payment: payment}, options) 126 | do 127 | conn 128 | |> put_flash(:info, "Order completed!") 129 | |> redirect(to: Routes.order_path(conn, order)) 130 | else 131 | {:error, error_description} -> 132 | conn 133 | |> put_flash(:error, parse_error(error_description)) 134 | |> render("checkout.html") 135 | end 136 | ``` 137 | 138 | This is definitely easier to understand, but since the code style is not enforced, it may not look like this for too 139 | long, specially if it's something that's being actively maintained. 140 | 141 | Using `ex_pipeline`, we can express this `with` block like this: 142 | 143 | ```elixir 144 | case Checkout.execute(params, conn: conn) do 145 | {:ok, order} -> 146 | conn 147 | |> put_flash(:info, "Order completed!") 148 | |> redirect(to: Routes.order_path(conn, order)) 149 | 150 | {:error, error_description} -> 151 | conn 152 | |> put_flash(:error, parse_error(error_description)) 153 | |> render("checkout.html") 154 | end 155 | ``` 156 | 157 | Inside `Checkout`, all functions will look the same, and any modifications must also follow the same pattern. 158 | 159 | ## Installation 160 | 161 | Add the [Hex package](https://hex.pm/packages/ex_pipeline) by adding `ex_pipeline` to your list of dependencies in 162 | `mix.exs`: 163 | 164 | ```elixir 165 | def deps do 166 | [ 167 | {:ex_pipeline, "~> 0.2.0"} 168 | ] 169 | end 170 | ``` 171 | 172 | Then make sure the `ex_pipeline` application is being loaded. 173 | 174 | ## Code of Conduct 175 | 176 | This project uses Contributor Covenant version 2.1. Check [CODE_OF_CONDUCT.md](/CODE_OF_CONDUCT.md) file for more information. 177 | 178 | ## License 179 | 180 | `ex_pipeline` source code is released under Apache License 2.0. 181 | 182 | Check [NOTICE](/NOTICE) and [LICENSE](/LICENSE) files for more information. -------------------------------------------------------------------------------- /lib/pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule Pipeline do 2 | @moduledoc """ 3 | Pipeline definition and execution. 4 | 5 | ## What is a "pipeline"? 6 | 7 | A pipeline is set of functions that must be executed in a specific order to transform an initial state into a desired 8 | state. For example, a "login pipeline" uses the request body as its initial state and generates an authentication 9 | token. 10 | 11 | ## Creating a pipeline 12 | 13 | To create a new feature as a pipeline, you can simply `use Pipeline` in the target module and start writing 14 | functions: steps and hooks. 15 | 16 | ### Pipeline Steps 17 | 18 | - Steps are executed in the same order that they are declared in the module. 19 | - Any function that ends with `_step` and accepts two parameters is considered a step in the pipeline. 20 | - A step accepts a value and must return an ok tuple with the updated value or an error tuple with the error 21 | description. If one step failes, the following steps are not executed. 22 | - The first parameter is the value that's being transformed by each step 23 | - The second parameter are optional values and it's immutable 24 | 25 | ### Pipeline Hooks 26 | 27 | - Hooks are executed in the same order that they are declared in the module. 28 | - Any function that ends with `_hook` and accepts two parameters is considered a hook in the pipeline. 29 | - Hooks receive the final state of the pipeline, and they are always executed after all steps. 30 | - The first parameter is the final state as defined by the `%Pipeline.State{}` struct. 31 | - The second parameter are optional values and it's immutable, the same used by the steps. 32 | 33 | ### Async Hooks 34 | - They're just like hooks, but the function name must end with `_async_hook` 35 | - They are launched on isolated processes to processed asynchronously, after all steps are done and before the 36 | hooks start being executed. 37 | 38 | ## Example 39 | 40 | ```elixir 41 | defmodule StringToNumber do 42 | use Pipeline 43 | 44 | def detect_binary_step(value, _options) do 45 | cond do 46 | is_binary(value) -> 47 | {:ok, value} 48 | 49 | true -> 50 | {:error, "Not a string"} 51 | end 52 | end 53 | 54 | def cleanup_step(value, _options) do 55 | {:ok, String.trim(value)} 56 | end 57 | 58 | def parse_step(value, _options) do 59 | case Float.parse(value) do 60 | {number, _} -> 61 | {:ok, number} 62 | 63 | :error -> 64 | {:error, "Invalid number"} 65 | end 66 | end 67 | end 68 | ``` 69 | 70 | To execute this pipeline, you can use `StringToNumber.execute/2` or `Pipeline.execute/3` 71 | 72 | """ 73 | alias Pipeline.PipelineError 74 | alias Pipeline.State 75 | alias Pipeline.Types 76 | 77 | @doc """ 78 | Returns a list of tuple with three elements. 79 | 80 | The first element is a list of functions to be used as steps of a pipeline. These steps will be executed in the same 81 | order that they appear on this list. 82 | 83 | The second element is a list of functions to be used as hooks of a pipeline. These hooks will be executed in 84 | the same order that they appear on this list. 85 | 86 | The third element is a list of functions to be used as async hooks of a pipeline. The order of execution is not 87 | guaranteed. 88 | """ 89 | @callback __pipeline__() :: {[Types.reducer()], [Types.hook()], [Types.async_hook()]} 90 | 91 | @doc false 92 | defmacro __using__(_) do 93 | quote do 94 | @before_compile unquote(__MODULE__) 95 | end 96 | end 97 | 98 | # "Injects the Pipeline behaviour, the two required callbacks and an `execute/2` function" 99 | @doc false 100 | defmacro __before_compile__(env) do 101 | definitions = Module.definitions_in(env.module, :def) 102 | {steps, definitions} = filter_functions(env.module, definitions, "_step", 2) 103 | {async_hooks, definitions} = filter_functions(env.module, definitions, "_async_hook", 2) 104 | {hooks, _definitions} = filter_functions(env.module, definitions, "_hook", 2) 105 | 106 | quote do 107 | @behaviour unquote(__MODULE__) 108 | 109 | @impl unquote(__MODULE__) 110 | def __pipeline__, do: {unquote(steps), unquote(hooks), unquote(async_hooks)} 111 | 112 | @spec execute(Pipeline.Types.args(), Pipeline.Types.options()) :: Pipeline.Types.result() 113 | def execute(value, options \\ []) do 114 | apply(unquote(__MODULE__), :execute, [__MODULE__, value, options]) 115 | end 116 | end 117 | end 118 | 119 | defp filter_functions(module, definitions, suffix, expected_arity) do 120 | {filtered, remaining} = 121 | Enum.reduce(definitions, {[], []}, fn {function, arity} = fa, {acc, remaining} -> 122 | valid_name? = 123 | function 124 | |> Atom.to_string() 125 | |> String.ends_with?(suffix) 126 | 127 | has_expected_args? = arity == expected_arity 128 | 129 | cond do 130 | valid_name? and has_expected_args? -> 131 | {_, _, [line: line], _} = Module.get_definition(module, {function, arity}) 132 | {[{module, function, line} | acc], remaining} 133 | 134 | valid_name? -> 135 | raise( 136 | PipelineError, 137 | "Function #{function} does not accept #{expected_arity} parameters." 138 | ) 139 | 140 | true -> 141 | {acc, [fa | remaining]} 142 | end 143 | end) 144 | 145 | filtered = 146 | filtered 147 | # order by line number 148 | |> Enum.sort(fn {_, _, a}, {_, _, b} -> a <= b end) 149 | # drop line number 150 | |> Enum.map(fn {m, f, _l} -> {m, f} end) 151 | 152 | {filtered, remaining} 153 | end 154 | 155 | @doc """ 156 | Executes the pipeline defined by `module` with the given `value` and `options`. 157 | 158 | First, all steps are executed in the same order that they were declared on their module. If one step fails, all 159 | the steps that come after it will not be executed. The returned value from one step will be passed to the next step, 160 | along with the given `options`. 161 | 162 | Then all async hooks are triggered and executed asynchronously in their own process. They will receive the final 163 | `%Pipeline.State{}` along with the given `options`. Their return values are ignored. 164 | 165 | After that, all hooks are executed in the same order that they were declared on their module. They will 166 | receive the final `%Pipeline.State{}` along with the given `options`. Their return values are ignored. 167 | 168 | Once steps and hooks are executed, the state is evaluated and then this function will returns an ok or error 169 | tuple, depending wether or not the state is valid. 170 | 171 | If the given `module` does not implement the required callbacks from `Pipeline` behaviour, a `PipelineError` will 172 | be thrown. 173 | """ 174 | @spec execute(module(), Types.args(), Types.options()) :: Types.result() 175 | def execute(module, value, options \\ []) do 176 | ensure_valid_pipeline!(module) 177 | 178 | initial_state = State.new(value) 179 | {steps, hooks, async_hooks} = apply(module, :__pipeline__, []) 180 | 181 | # Process state 182 | final_state = 183 | Enum.reduce(steps, initial_state, fn reducer, curent_state -> 184 | State.update(curent_state, reducer, options) 185 | end) 186 | 187 | # Launch async hooks 188 | Enum.each(async_hooks, fn {mod, fun} -> 189 | Task.Supervisor.async_nolink(Pipeline.TaskSupervisor, fn -> 190 | apply(mod, fun, [final_state, options]) 191 | end) 192 | end) 193 | 194 | # Execute hooks 195 | Enum.each(hooks, fn {mod, fun} -> 196 | apply(mod, fun, [final_state, options]) 197 | end) 198 | 199 | case final_state do 200 | %State{valid?: true, value: value} -> 201 | {:ok, value} 202 | 203 | %State{error: error} -> 204 | {:error, error} 205 | end 206 | end 207 | 208 | defp ensure_valid_pipeline!(module) do 209 | exports_pipeline_meta? = function_exported?(module, :__pipeline__, 0) 210 | 211 | unless exports_pipeline_meta? do 212 | raise(PipelineError, "Module #{module} is not a valid pipeline.") 213 | end 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /lib/pipeline/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Pipeline.Application do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | children = [ 6 | {Task.Supervisor, name: Pipeline.TaskSupervisor} 7 | ] 8 | 9 | Supervisor.start_link(children, strategy: :one_for_one) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/pipeline/pipeline_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Pipeline.PipelineError do 2 | @moduledoc """ 3 | Error thrown when buidling a pipeline with invalid steps or hooks. 4 | 5 | This error is thrown at compile time when, inside a module that requires the `Pipeline` using `use Pipeline`, a 6 | function is declared if the suffix `_step`, `_hook` or `_async_hook` but do not accept two parameters. 7 | """ 8 | defexception [:message] 9 | end 10 | -------------------------------------------------------------------------------- /lib/pipeline/state.ex: -------------------------------------------------------------------------------- 1 | defmodule Pipeline.State do 2 | @moduledoc """ 3 | Pipeline state management. 4 | 5 | This module defines a struct that is used to keep track of the state of a pipeline: the initial value, the current 6 | value, it's still valid (or not) and any error that may have occurred. 7 | 8 | It also has functions to create and manipulate a state. 9 | 10 | You probably won't need to interact with this module too often, since it's all managed by the pipeline engine. The 11 | only part of a pipeline where this module is accessible is on callback functions. 12 | """ 13 | defstruct [:initial_value, :value, :valid?, :error, :executed_steps] 14 | 15 | @typedoc """ 16 | A struct that wraps metadata information about a pipeline. 17 | 18 | * `initial_value` is the first ever value that is passed to the first step on a pipeline. 19 | * `value` is the current value of the pipeline 20 | * `valid?` is boolean indicating wether the pipeline is still valid (true) or not (false). 21 | * `error` the error that may have happened during the execution of the pipeline. 22 | * `executed_steps` a list of all steps that were executed 23 | """ 24 | @type t :: %__MODULE__{ 25 | initial_value: any(), 26 | value: any(), 27 | valid?: boolean(), 28 | error: any(), 29 | executed_steps: [{module, atom}] 30 | } 31 | 32 | alias Pipeline.TransformError 33 | alias Pipeline.Types 34 | 35 | @doc """ 36 | Creates a new, valid, `%Pipeline.State{}` struct with the given initial value 37 | 38 | ## Examples 39 | 40 | iex> Pipeline.State.new(%{id: 1}) 41 | %Pipeline.State{error: nil, executed_steps: [], initial_value: %{id: 1}, valid?: true, value: %{id: 1}} 42 | """ 43 | @spec new(any()) :: t() 44 | def new(initial_value) do 45 | %__MODULE__{ 46 | initial_value: initial_value, 47 | value: initial_value, 48 | valid?: true, 49 | error: nil, 50 | executed_steps: [] 51 | } 52 | end 53 | 54 | @doc """ 55 | Updates a state with the given reducer. 56 | 57 | If everything goes well and the function returns an ok tuple, it will return an updated `%Pipeline.State{}` struct. 58 | 59 | If the function returns an error tuple, it will call `invalidate/1` or `invalidate/2` and return an updated and 60 | invalid `%Pipeline.State{}` struct. 61 | 62 | Note that the function must return an ok/error tuple, otherwise a `Transform.Error` error is thrown. 63 | """ 64 | @spec update(t(), Types.reducer(), Types.options()) :: t() 65 | def update(state, transform, options \\ []) 66 | 67 | def update(%__MODULE__{valid?: true, value: value} = state, {module, fun} = reducer, options) do 68 | updated_state = 69 | module 70 | |> apply(fun, [value, options]) 71 | |> check_update(state) 72 | 73 | %__MODULE__{updated_state | executed_steps: state.executed_steps ++ [reducer]} 74 | end 75 | 76 | def update(%__MODULE__{valid?: false} = state, _transform, _options), do: state 77 | 78 | # Check if the transformation is valid 79 | defp check_update(new_value, state) do 80 | case new_value do 81 | {:ok, value} -> 82 | %__MODULE__{state | value: value} 83 | 84 | {:error, error} -> 85 | invalidate(state, error) 86 | 87 | :error -> 88 | invalidate(state) 89 | 90 | unexpected -> 91 | raise(TransformError, "expected an ok or error tuple, got #{inspect(unexpected)}") 92 | end 93 | end 94 | 95 | @doc """ 96 | Marks the given state as invalid. 97 | 98 | Since no errors are specified, the `error` field on the state becomes a generic error string. 99 | 100 | ## Examples 101 | 102 | iex> %Pipeline.State{valid?: true, error: nil} |> Pipeline.State.invalidate() 103 | %Pipeline.State{error: "an error occured during the execution of the pipeline", valid?: false} 104 | """ 105 | @spec invalidate(t()) :: t() 106 | def invalidate(%__MODULE__{} = state) do 107 | %__MODULE__{ 108 | state 109 | | valid?: false, 110 | error: "an error occured during the execution of the pipeline" 111 | } 112 | end 113 | 114 | @doc """ 115 | Marks the given state as invalid and adds an error to the state. 116 | 117 | The `error` field on the state will have the same value from the given error. 118 | 119 | ## Examples 120 | 121 | iex> %Pipeline.State{valid?: true, error: nil} |> Pipeline.State.invalidate(:bad_thing) 122 | %Pipeline.State{error: :bad_thing, valid?: false} 123 | """ 124 | @spec invalidate(t(), any()) :: t() 125 | def invalidate(%__MODULE__{} = state, error) do 126 | %__MODULE__{state | valid?: false, error: error} 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/pipeline/transform_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Pipeline.TransformError do 2 | @moduledoc """ 3 | Error thrown at runtime when steps return invalid values. 4 | 5 | This error is thrown if a step returns a value that is not an ok/error tuple, as defined by `Pipeline.Types.result()` 6 | typespec. 7 | """ 8 | defexception [:message] 9 | end 10 | -------------------------------------------------------------------------------- /lib/pipeline/types.ex: -------------------------------------------------------------------------------- 1 | defmodule Pipeline.Types do 2 | @moduledoc """ 3 | Shared type definitions for all modules 4 | """ 5 | 6 | @typedoc """ 7 | The result of the execution of a step or the final result of an entire pipeline. 8 | """ 9 | @type result :: {:ok, any} | {:error, any} 10 | 11 | @typedoc """ 12 | The arguments that are used by the first step of a pipeline and well - the initial state of the pipeline. 13 | """ 14 | @type args :: any 15 | 16 | @typedoc """ 17 | The optional arguments that are passed to steps and callbacks of a pipeline. 18 | """ 19 | @type options :: Keyword.t() 20 | 21 | @typedoc """ 22 | A function that transforms a value into something else. 23 | """ 24 | @type reducer :: (args(), options() -> result()) 25 | 26 | @typedoc """ 27 | A function that is always called after the pipeline finishes the execution. 28 | """ 29 | @type hook :: (Pipeline.State.t(), options -> any()) 30 | 31 | @typedoc """ 32 | A function that is always called after the pipeline finishes the execution, but asynchronously. 33 | """ 34 | @type async_hook :: hook() 35 | end 36 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExPipeline.MixProject do 2 | use Mix.Project 3 | 4 | @app :ex_pipeline 5 | @version "0.2.0" 6 | @source_url "https://github.com/msramos/ex_pipeline" 7 | 8 | def project do 9 | [ 10 | app: @app, 11 | version: @version, 12 | description: "A simple and opinionated pipeline builder", 13 | elixir: "~> 1.12", 14 | start_permanent: Mix.env() == :prod, 15 | deps: deps(), 16 | docs: docs(), 17 | package: package() 18 | ] 19 | end 20 | 21 | def application do 22 | [ 23 | mod: {Pipeline.Application, []}, 24 | extra_applications: [:logger] 25 | ] 26 | end 27 | 28 | defp deps do 29 | [ 30 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 31 | ] 32 | end 33 | 34 | defp docs do 35 | [ 36 | source_ref: "v#{@version}", 37 | canonical: "http://hexdocs.pm/ex_pipeline", 38 | source_url: @source_url, 39 | extras: [ 40 | "README.md", 41 | "CHANGELOG.md": [filename: "changelog", title: "Changelog"], 42 | "CODE_OF_CONDUCT.md": [filename: "code_of_conduct", title: "Code of Conduct"], 43 | LICENSE: [filename: "license", title: "License"], 44 | NOTICE: [filename: "notice", title: "Notice"] 45 | ] 46 | ] 47 | end 48 | 49 | defp package do 50 | [ 51 | maintainers: ["Marcos Ramos"], 52 | licenses: ["Apache-2.0"], 53 | links: %{"GitHub" => @source_url} 54 | ] 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.26", "f4291134583f373c7d8755566122908eb9662df4c4b63caa66a0eabe06569b0a", [:mix], [], "hexpm", "48d460899f8a0c52c5470676611c01f64f3337bad0b26ddab43648428d94aabc"}, 3 | "ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"}, 4 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 5 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 6 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 7 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 8 | } 9 | -------------------------------------------------------------------------------- /test/pipeline/state_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pipeline.StateTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Pipeline.State 5 | alias Pipeline.TransformError 6 | 7 | doctest State 8 | 9 | describe "new/2" do 10 | test "creates a valid state with initial and current value" do 11 | state = State.new([1, 2, 3]) 12 | 13 | assert state.initial_value == [1, 2, 3] 14 | assert state.value == [1, 2, 3] 15 | assert state.error == nil 16 | assert state.valid? == true 17 | assert state.executed_steps == [] 18 | end 19 | end 20 | 21 | describe "update/3" do 22 | defmodule __MODULE__.Example1 do 23 | def good(value, options) do 24 | {:ok, [value, options]} 25 | end 26 | 27 | def error_message(_value, _options) do 28 | {:error, "Something is not good"} 29 | end 30 | 31 | def error(_value, _options) do 32 | :error 33 | end 34 | 35 | def bad_return(value, _) do 36 | value 37 | end 38 | end 39 | 40 | test "updates an state succesfully" do 41 | state = %State{valid?: true, value: 10, initial_value: 0, error: nil, executed_steps: []} 42 | 43 | updated_state = State.update(state, {__MODULE__.Example1, :good}, 2) 44 | 45 | assert updated_state.error == nil 46 | assert updated_state.value == [10, 2] 47 | assert updated_state.initial_value == 0 48 | assert updated_state.valid? == true 49 | assert updated_state.executed_steps == [{__MODULE__.Example1, :good}] 50 | end 51 | 52 | test "invalidates the state if step returns an error tuple" do 53 | state = %State{valid?: true, value: 10, initial_value: 0, error: nil, executed_steps: []} 54 | 55 | updated_state = State.update(state, {__MODULE__.Example1, :error_message}, 2) 56 | 57 | assert updated_state.error == "Something is not good" 58 | assert updated_state.value == 10 59 | assert updated_state.initial_value == 0 60 | assert updated_state.valid? == false 61 | assert updated_state.executed_steps == [{__MODULE__.Example1, :error_message}] 62 | end 63 | 64 | test "invalidates the state if step returns an :error atom" do 65 | state = %State{valid?: true, value: 10, initial_value: 0, error: nil, executed_steps: []} 66 | 67 | updated_state = State.update(state, {__MODULE__.Example1, :error}, 2) 68 | 69 | assert updated_state.error == "an error occured during the execution of the pipeline" 70 | assert updated_state.value == 10 71 | assert updated_state.initial_value == 0 72 | assert updated_state.valid? == false 73 | assert updated_state.executed_steps == [{__MODULE__.Example1, :error}] 74 | end 75 | 76 | test "does not perform any action if the state is invalid" do 77 | state = %State{ 78 | valid?: false, 79 | value: 10, 80 | initial_value: 0, 81 | error: :oh_no, 82 | executed_steps: [] 83 | } 84 | 85 | updated_state = State.update(state, {__MODULE__.Example1, :good}) 86 | 87 | assert updated_state == state 88 | assert updated_state.executed_steps == [] 89 | end 90 | 91 | test "uses empty options by default" do 92 | state = %State{valid?: true, value: 10, initial_value: 0, error: nil, executed_steps: []} 93 | 94 | updated_state = State.update(state, {__MODULE__.Example1, :good}) 95 | 96 | assert updated_state.error == nil 97 | assert updated_state.value == [10, []] 98 | assert updated_state.initial_value == 0 99 | assert updated_state.valid? == true 100 | assert updated_state.executed_steps == [{__MODULE__.Example1, :good}] 101 | end 102 | 103 | test "raises an error if transform does not return a tuple" do 104 | state = %State{valid?: true, value: 10, initial_value: 0, error: nil, executed_steps: []} 105 | 106 | assert_raise TransformError, 107 | "expected an ok or error tuple, got 10", 108 | fn -> 109 | State.update(state, {__MODULE__.Example1, :bad_return}, 2) 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/pipeline_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PipelineTest do 2 | use ExUnit.Case, async: true 3 | doctest Pipeline 4 | 5 | defmodule GoodPipeline do 6 | use Pipeline 7 | 8 | def first_step(value, _options) do 9 | send(self(), {:first, value}) 10 | {:ok, value + 1} 11 | end 12 | 13 | def some_function, do: :ok 14 | 15 | def second_step(value, _options) do 16 | send(self(), {:second, value}) 17 | {:ok, value + 1} 18 | end 19 | 20 | def another_function, do: :ok 21 | 22 | def single_hook(state, _options) do 23 | send(self(), {:hook, state}) 24 | end 25 | 26 | def single_async_hook(state, options) do 27 | send(options[:parent_pid], {:async_hook, state}) 28 | end 29 | end 30 | 31 | defmodule PipelineWithError do 32 | use Pipeline 33 | 34 | def first_step(value, _options) do 35 | send(self(), {:first, value}) 36 | {:error, "Some error"} 37 | end 38 | 39 | def second_step(value, _options) do 40 | send(self(), {:second, value}) 41 | {:ok, value + 1} 42 | end 43 | 44 | def single_hook(state, _options) do 45 | send(self(), {:hook, state}) 46 | end 47 | 48 | def single_async_hook(state, options) do 49 | send(options[:parent_pid], {:async_hook, state}) 50 | end 51 | end 52 | 53 | defmodule BadPipeline do 54 | end 55 | 56 | describe "code injection" do 57 | test "target module has the __pipeline__/0 and execute/2 functions injected" do 58 | assert function_exported?(__MODULE__.GoodPipeline, :__pipeline__, 0) 59 | assert function_exported?(__MODULE__.GoodPipeline, :execute, 2) 60 | end 61 | 62 | test "the injected __pipeline__ returns only the matched functions for steps, hooks and async hooks" do 63 | {steps, hooks, async_hooks} = __MODULE__.GoodPipeline.__pipeline__() 64 | 65 | assert steps == [ 66 | {__MODULE__.GoodPipeline, :first_step}, 67 | {__MODULE__.GoodPipeline, :second_step} 68 | ] 69 | 70 | assert hooks == [ 71 | {__MODULE__.GoodPipeline, :single_hook} 72 | ] 73 | 74 | assert async_hooks == [ 75 | {__MODULE__.GoodPipeline, :single_async_hook} 76 | ] 77 | end 78 | end 79 | 80 | describe "execute/3" do 81 | test "executes all steps and hooks in a module" do 82 | result = Pipeline.execute(__MODULE__.GoodPipeline, 10, parent_pid: self()) 83 | assert result == {:ok, 12} 84 | assert_receive {:first, 10} 85 | assert_receive {:second, 11} 86 | 87 | assert_receive {:hook, 88 | %Pipeline.State{valid?: true, initial_value: 10, value: 12, error: nil}} 89 | 90 | assert_receive {:async_hook, 91 | %Pipeline.State{valid?: true, initial_value: 10, value: 12, error: nil}} 92 | end 93 | 94 | test "do not execute steps after a failure, but still call hooks anyways" do 95 | result = Pipeline.execute(__MODULE__.PipelineWithError, 0, parent_pid: self()) 96 | assert result == {:error, "Some error"} 97 | 98 | assert_receive {:first, 0} 99 | refute_receive {:second, 1} 100 | 101 | assert_receive {:hook, 102 | %Pipeline.State{ 103 | valid?: false, 104 | initial_value: 0, 105 | value: 0, 106 | error: "Some error" 107 | }} 108 | 109 | assert_receive {:async_hook, 110 | %Pipeline.State{ 111 | valid?: false, 112 | initial_value: 0, 113 | value: 0, 114 | error: "Some error" 115 | }} 116 | end 117 | 118 | test "fails if pipeline is invalid" do 119 | assert_raise Pipeline.PipelineError, fn -> 120 | Pipeline.execute(__MODULE__.BadPipeline, 10) 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------