├── test ├── test_helper.exs ├── pluggable │ ├── token │ │ ├── no_halted.exs │ │ └── no_assigns.exs │ ├── token_test.exs │ ├── pipeline_builder_test.exs │ └── step_builder_test.exs ├── support │ └── test_token.ex └── pluggable_test.exs ├── .tool-versions ├── .dialyzer_ignore.exs ├── .github ├── FUNDING.yml ├── renovate.json5 └── workflows │ ├── elixir_matrix.yaml │ └── code_quality.yaml ├── .envrc ├── .formatter.exs ├── .gitignore ├── CHANGELOG.md ├── mix.exs ├── lib ├── pluggable │ ├── pipeline_builder.ex │ ├── token.ex │ └── step_builder.ex └── pluggable.ex ├── mix.lock ├── README.md ├── .credo.exs └── LICENSE /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 28.3 2 | elixir 1.19.4 3 | -------------------------------------------------------------------------------- /.dialyzer_ignore.exs: -------------------------------------------------------------------------------- 1 | [ 2 | ~r/__impl__.*does\ not\ exist\./ 3 | ] 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [mruoss] 4 | -------------------------------------------------------------------------------- /test/pluggable/token/no_halted.exs: -------------------------------------------------------------------------------- 1 | defmodule Pluggable.TokenTestTokenNoHalted do 2 | @derive Pluggable.Token 3 | 4 | defstruct assigns: %{} 5 | end 6 | -------------------------------------------------------------------------------- /test/pluggable/token/no_assigns.exs: -------------------------------------------------------------------------------- 1 | defmodule Pluggable.TokenTestTokenNoAssigns do 2 | @derive Pluggable.Token 3 | 4 | defstruct halted: false 5 | end 6 | -------------------------------------------------------------------------------- /test/support/test_token.ex: -------------------------------------------------------------------------------- 1 | defmodule TestToken do 2 | @moduledoc false 3 | @derive Pluggable.Token 4 | 5 | defstruct halted: false, assigns: %{}, data: %{} 6 | end 7 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | direnv_version 2.30.3 2 | # IEx Persistent History, see https://tylerpachal.medium.com/iex-persistent-history-5d7d64e905d3 3 | export ERL_AFLAGS="-kernel shell_history enabled" 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | locals_without_parens: [step: 1, step: 2], 5 | export: [ 6 | locals_without_parens: [step: 1, step: 2] 7 | ] 8 | ] 9 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | extends: [ 3 | 'config:recommended', 4 | 'config:recommended', 5 | ':automergeMinor', 6 | ':automergePr', 7 | ':label(renovate-update)', 8 | ':rebaseStalePrs', 9 | ':prConcurrentLimit10', 10 | ':maintainLockFilesWeekly', 11 | ], 12 | cdnurl: { 13 | managerFilePatterns: [ 14 | '/^assets/.*\\.tsx?$/', 15 | ], 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/elixir_matrix.yaml: -------------------------------------------------------------------------------- 1 | name: Elixir Compatibility Matrix 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | jobs: 8 | test: 9 | uses: mruoss/action_elixir_run_tests/.github/workflows/elixir-run-tests.yml@v2.1.0 10 | with: 11 | checkout: true 12 | warnings-as-errors: true 13 | secrets: 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /test/pluggable/token_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pluggable.TokenTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "raises if required keys don't exists" do 5 | assert_raise ArgumentError, fn -> 6 | Code.eval_file("test/pluggable/token/no_halted.exs") 7 | end 8 | 9 | assert_raise ArgumentError, fn -> 10 | Code.eval_file("test/pluggable/token/no_assigns.exs") 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /.github/workflows/code_quality.yaml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | jobs: 8 | code_quality: 9 | uses: mruoss/action_elixir_run_tests/.github/workflows/elixir-code-quality.yml@v2.1.0 10 | with: 11 | checkout: true 12 | with-kubernetes: false 13 | check-formatting: true 14 | run-credo: strict 15 | run-dialyzer: true 16 | secrets: 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.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 | pluggable-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # Dialyzer Files 29 | /priv/plts 30 | -------------------------------------------------------------------------------- /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 | ## Unreleased 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ## [1.1.0] - 2024-03-21 17 | 18 | ## Added 19 | 20 | - Add `Pluggable.PipelineBuilder` to create multiple pipelines in the same module ([#58](https://github.com/mruoss/pluggable/pull/58)) 21 | 22 | ## [1.0.1] - 2022-10-25 23 | 24 | ### Added 25 | 26 | - :locals_without_parens export in formatter configuration 27 | 28 | ## [1.0.0] - 2022-10-12 29 | 30 | This is the first release. 31 | -------------------------------------------------------------------------------- /test/pluggable/pipeline_builder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pluggable.PipelineBuilderTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule Module do 5 | import Pluggable.Token 6 | 7 | def init(val) do 8 | {:init, val} 9 | end 10 | 11 | def call(token, opts) do 12 | stack = [{:call, opts} | List.wrap(token.assigns[:stack])] 13 | assign(token, :stack, stack) 14 | end 15 | end 16 | 17 | defmodule Pipeline do 18 | use Pluggable.PipelineBuilder 19 | 20 | pipeline :foo do 21 | step Module, :step2 22 | step Module, :step3 23 | end 24 | end 25 | 26 | defmodule Foo do 27 | def foo(x, y), do: x + y 28 | end 29 | 30 | test "runs the pipeline" do 31 | assert Pluggable.run(%TestToken{}, [&Pipeline.foo(&1, [])]).assigns[:stack] == [ 32 | call: {:init, :step3}, 33 | call: {:init, :step2} 34 | ] 35 | end 36 | 37 | test "raises" do 38 | assert_raise( 39 | ArgumentError, 40 | "cannot define pipeline named :foo because there is an import from Pluggable.PipelineBuilderTest.Foo with the same name", 41 | fn -> 42 | defmodule FaultyPipeline do 43 | use Pluggable.PipelineBuilder 44 | 45 | import Foo 46 | 47 | pipeline :foo do 48 | step Module, :step2 49 | step Module, :step3 50 | end 51 | end 52 | end 53 | ) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/pluggable_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PluggableTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ExUnit.CaptureLog 5 | 6 | #  Module under Test 7 | alias Pluggable, as: MUT 8 | 9 | defmodule Halter do 10 | def init(:opts), do: :inited 11 | def call(token, :inited), do: %{token | halted: true} 12 | end 13 | 14 | defmodule NotStep do 15 | def init(:opts), do: :inited 16 | def call(_token, :inited), do: %{} 17 | end 18 | 19 | defmodule Test.Step.AddFooToData do 20 | def init(opts), do: opts 21 | 22 | def call(%TestToken{data: data} = token, opts), 23 | do: %{token | data: Map.put(data, :foo, Keyword.get(opts, :value, :bar))} 24 | end 25 | 26 | def add_bar_to_data(%TestToken{data: data} = token, opts), 27 | do: %{token | data: Map.put(data, :bar, Keyword.get(opts, :value, :foo))} 28 | 29 | describe "run" do 30 | test "invokes steps" do 31 | token = MUT.run(%TestToken{}, [{Test.Step.AddFooToData, []}]) 32 | assert token.data == %{foo: :bar} 33 | 34 | token = 35 | MUT.run(%TestToken{}, [{Test.Step.AddFooToData, []}, &add_bar_to_data(&1, value: :bar)]) 36 | 37 | assert token.data == %{foo: :bar, bar: :bar} 38 | end 39 | 40 | test "does not invoke stepss if halted" do 41 | token = MUT.run(%{%TestToken{} | halted: true}, [&raise(inspect(&1))]) 42 | assert token.halted 43 | end 44 | 45 | test "aborts if step halts" do 46 | token = MUT.run(%TestToken{}, [&%{&1 | halted: true}, &raise(inspect(&1))]) 47 | assert token.halted 48 | end 49 | 50 | test "logs when halting" do 51 | assert capture_log(fn -> 52 | assert MUT.run(%TestToken{}, [{Halter, :opts}], log_on_halt: :error).halted 53 | end) =~ "[error] Pluggable pipeline halted in PluggableTest.Halter.call/2" 54 | 55 | halter = &%{&1 | halted: true} 56 | 57 | assert capture_log(fn -> 58 | assert MUT.run(%TestToken{}, [halter], log_on_halt: :error).halted 59 | end) =~ "[error] Pluggable pipeline halted in #{inspect(halter)}" 60 | end 61 | 62 | test "raise exception with invalid return" do 63 | msg = "expected PluggableTest.NotStep to return Pluggable.Token, got: %{}" 64 | 65 | assert_raise RuntimeError, msg, fn -> 66 | MUT.run(%TestToken{}, [{NotStep, :opts}]) 67 | end 68 | 69 | not_step = fn _ -> %{} end 70 | msg = ~r/expected #Function.* to return Pluggable.Token, got: %{}/ 71 | 72 | assert_raise RuntimeError, msg, fn -> 73 | MUT.run(%TestToken{}, [not_step]) 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Pluggable.MixProject do 2 | use Mix.Project 3 | 4 | @app :pluggable 5 | @source_url "https://github.com/mruoss/#{@app}" 6 | @version "1.1.0" 7 | 8 | def project do 9 | [ 10 | app: @app, 11 | description: "A Plug-like pipeline creator", 12 | version: @version, 13 | elixir: "~> 1.12", 14 | start_permanent: Mix.env() == :prod, 15 | deps: deps(), 16 | docs: docs(), 17 | package: package(), 18 | elixirc_paths: elixirc_paths(Mix.env()), 19 | test_coverage: [tool: ExCoveralls], 20 | preferred_cli_env: cli_env(), 21 | consolidate_protocols: Mix.env() != :test, 22 | dialyzer: dialyzer() 23 | ] 24 | end 25 | 26 | defp elixirc_paths(:test), do: ["lib", "test/support"] 27 | defp elixirc_paths(_), do: ["lib"] 28 | 29 | # Run "mix help compile.app" to learn about applications. 30 | def application do 31 | [ 32 | extra_applications: [:logger] 33 | ] 34 | end 35 | 36 | # Run "mix help deps" to learn about dependencies. 37 | defp deps do 38 | [ 39 | {:dialyxir, "~> 1.4.0", only: [:dev, :test], runtime: false}, 40 | # {:ex_doc, "~> 0.29", only: :dev}, 41 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 42 | {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, 43 | 44 | # Test deps 45 | {:excoveralls, "~> 0.18", only: :test} 46 | ] 47 | end 48 | 49 | defp docs do 50 | [ 51 | # The main page in the docs 52 | main: "Pluggable.Token", 53 | source_ref: "v#{@version}", 54 | source_url: @source_url, 55 | extras: [ 56 | "README.md", 57 | "CHANGELOG.md" 58 | ] 59 | ] 60 | end 61 | 62 | defp cli_env do 63 | [ 64 | coveralls: :test, 65 | "coveralls.detail": :test, 66 | "coveralls.post": :test, 67 | "coveralls.html": :test, 68 | "coveralls.travis": :test, 69 | "coveralls.github": :test, 70 | "coveralls.xml": :test, 71 | "coveralls.json": :test 72 | ] 73 | end 74 | 75 | defp package do 76 | [ 77 | name: @app, 78 | maintainers: ["Michael Ruoss"], 79 | licenses: ["Apache-2.0"], 80 | links: %{ 81 | "GitHub" => @source_url, 82 | "Changelog" => "https://hexdocs.pm/#{@app}/changelog.html", 83 | "Sponsor" => "https://github.com/sponsors/mruoss" 84 | }, 85 | files: ["lib", "mix.exs", "README.md", "LICENSE", "CHANGELOG.md", ".formatter.exs"] 86 | ] 87 | end 88 | 89 | defp dialyzer do 90 | [ 91 | ignore_warnings: ".dialyzer_ignore.exs", 92 | plt_core_path: "priv/plts", 93 | plt_file: {:no_warn, "priv/plts/#{@app}.plt"} 94 | ] 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/pluggable/pipeline_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Pluggable.PipelineBuilder do 2 | @moduledoc """ 3 | Build pluggable steps as pipelines of other steps. Use this only if you need 4 | to define and run multiple distinct pipelines in the same module. 5 | 6 | ## Examples 7 | defmodule MyPipeline do 8 | pipeline :foo do 9 | plug SomePluggableStep 10 | plug :inline_step 11 | end 12 | 13 | pipeline :bar do 14 | plug AnotherPluggableStep 15 | plug :inline_step 16 | end 17 | end 18 | 19 | These pipelines can be run from within the same module: 20 | 21 | Pluggable.run(token, [&foo(&1, [])]) 22 | Pluggable.run(another_token, [&bar(&1, [])]) 23 | 24 | Or they can be run from outside 25 | 26 | Pluggable.run(token, [&MyPipeline.foo(&1, [])]) 27 | Pluggable.run(another_token, [&MyPipeline.bar(&1, [])]) 28 | """ 29 | 30 | defmacro __using__(_opts) do 31 | quote do 32 | @pluggable_pipeline nil 33 | 34 | import Pluggable.Token 35 | import Pluggable.PipelineBuilder, only: [step: 1, step: 2, pipeline: 2] 36 | end 37 | end 38 | 39 | @doc """ 40 | Defines a step inside a pipeline. 41 | 42 | See module doc for more information. 43 | """ 44 | defmacro step(step, opts \\ []) do 45 | quote do 46 | if pipeline = @pluggable_pipeline do 47 | @pluggable_pipeline [{unquote(step), unquote(opts), true} | pipeline] 48 | else 49 | raise "cannot define step at the PipelineBuilder level, step must be defined inside a pipeline" 50 | end 51 | end 52 | end 53 | 54 | @doc """ 55 | Defines a pluggable step as a pipeline of other steps. 56 | 57 | See module doc for more information. 58 | """ 59 | defmacro pipeline(step, do: block) do 60 | with true <- is_atom(step), 61 | imports = __CALLER__.macros ++ __CALLER__.functions, 62 | {mod, _} <- Enum.find(imports, fn {_, imports} -> {step, 2} in imports end) do 63 | raise ArgumentError, 64 | "cannot define pipeline named #{inspect(step)} " <> 65 | "because there is an import from #{inspect(mod)} with the same name" 66 | end 67 | 68 | block = 69 | quote do 70 | step = unquote(step) 71 | @pluggable_pipeline [] 72 | unquote(block) 73 | end 74 | 75 | compiler = 76 | quote unquote: false do 77 | {token, body} = Pluggable.StepBuilder.compile(__ENV__, @pluggable_pipeline, []) 78 | 79 | def unquote(step)(unquote(token), _), do: unquote(body) 80 | 81 | @pluggable_pipeline nil 82 | end 83 | 84 | quote do 85 | try do 86 | unquote(block) 87 | unquote(compiler) 88 | after 89 | :ok 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [: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", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, 4 | "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 6 | "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, 7 | "ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [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", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"}, 8 | "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, 9 | "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 10 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 11 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 12 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 15 | } 16 | -------------------------------------------------------------------------------- /lib/pluggable/token.ex: -------------------------------------------------------------------------------- 1 | defprotocol Pluggable.Token do 2 | @moduledoc """ 3 | Token protocol to be used with the `Pluggable` and `Pluggable.Builder` modules. 4 | 5 | When implementing pipelines using this library, a token holds the state and is 6 | passed on from step to step within the pipeline. 7 | 8 | ## Deriving Pluggable.Token 9 | 10 | The simplest way to use this library is to define a token module which derives 11 | `Pluggable.Token` and defines a struct which, among others defines the keys: 12 | 13 | * `:halted` - the boolean status on whether the pipeline was halted 14 | * `:assigns` - shared user data as a map 15 | 16 | Example: 17 | 18 | defmodule MyPipeline.Token do 19 | @derive Pluggable.Token 20 | defstruct [ 21 | halted: false, 22 | assigns: %{}, 23 | # other state 24 | ] 25 | end 26 | 27 | If the fields holding these two states are named differently, pass the fields 28 | as options to `@derive`: 29 | 30 | defmodule MyPipeline.Token do 31 | @derive {Pluggable.Token, halted_key: :stopped, assigns_key: :shared_state} 32 | defstruct [ 33 | stopped: false, 34 | shared_state: %{}, 35 | # other state 36 | ] 37 | end 38 | 39 | ## Implementing Pluggable.Token 40 | 41 | `Pluggable.Token` can be implemented. The following is the default implementation 42 | when deriving `Pluggable.Token` 43 | 44 | defmodule MyPipeline.Token do 45 | defstruct [ 46 | halted: nil, 47 | assigns: %{}, 48 | # other state 49 | ] 50 | end 51 | 52 | defimpl Pluggable.Token, for: MyPipeline.Token do 53 | def halted?(token), do: token.halted 54 | 55 | def halt(token), do: %{token | halted: true} 56 | 57 | def assign(%MyPipeline.Token{assigns: assigns} = token, key, value) when is_atom(key) do 58 | %{token | assigns: Map.put(assigns, key, value)} 59 | end 60 | end 61 | """ 62 | 63 | @doc """ 64 | Returns the boolean status on whether the pipeline was halted 65 | """ 66 | @spec halted?(t()) :: boolean() 67 | def halted?(token) 68 | 69 | @doc """ 70 | Halts the Pluggable pipeline by preventing further steps downstream from being 71 | invoked. See the docs for `Pluggable.Builder` for more information on halting a 72 | Pluggable pipeline. 73 | """ 74 | @spec halt(t()) :: t() 75 | def halt(token) 76 | 77 | @doc """ 78 | Assigns a value to a key in the shared user data map. 79 | """ 80 | @spec assign(t(), atom(), term()) :: t() 81 | def assign(token, key, value) 82 | end 83 | 84 | defimpl Pluggable.Token, for: Any do 85 | defmacro __deriving__(module, struct, options) do 86 | halted_key = Keyword.get(options, :halted_key, :halted) 87 | assigns_key = Keyword.get(options, :assigns_key, :assigns) 88 | 89 | if !Map.has_key?(struct, halted_key), 90 | do: 91 | raise(ArgumentError, 92 | message: 93 | "Key #{inspect(halted_key)} does not exist in struct #{inspect(struct)}. Please define a key describing the :halted state." 94 | ) 95 | 96 | if !Map.has_key?(struct, assigns_key), 97 | do: 98 | raise(ArgumentError, 99 | message: 100 | "Key #{inspect(assigns_key)} does not exist in struct #{inspect(struct)}. Please define a key holding assigns." 101 | ) 102 | 103 | quote do 104 | defimpl Pluggable.Token, for: unquote(module) do 105 | def halted?(token), do: token.unquote(halted_key) 106 | 107 | def halt(token), do: %{token | unquote(halted_key) => true} 108 | 109 | def assign(%module{unquote(assigns_key) => assigns} = token, key, value) 110 | when is_atom(key) do 111 | %{token | unquote(assigns_key) => Map.put(assigns, key, value)} 112 | end 113 | end 114 | end 115 | end 116 | 117 | # coveralls-ignore-start No logic to test 118 | def halted?(_token), do: false 119 | def halt(token), do: token 120 | def assign(token, _key, _value), do: token 121 | # coveralls-ignore-stop 122 | end 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pluggable 2 | 3 | [![Module Version](https://img.shields.io/hexpm/v/pluggable.svg)](https://hex.pm/packages/pluggable) 4 | [![Coverage Status](https://coveralls.io/repos/github/mruoss/pluggable/badge.svg?branch=main)](https://coveralls.io/github/mruoss/pluggable?branch=main) 5 | [![Last Updated](https://img.shields.io/github/last-commit/mruoss/pluggable.svg)](https://github.com/mruoss/pluggable/commits/main) 6 | 7 | [![Build Status CI](https://github.com/mruoss/pluggable/actions/workflows/ci.yaml/badge.svg)](https://github.com/mruoss/pluggable/actions/workflows/ci.yaml) 8 | [![Build Status Elixir](https://github.com/mruoss/pluggable/actions/workflows/elixir_matrix.yaml/badge.svg)](https://github.com/mruoss/pluggable/actions/workflows/elixir_matrix.yaml) 9 | 10 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/pluggable/) 11 | [![Total Download](https://img.shields.io/hexpm/dt/pluggable.svg)](https://hex.pm/packages/pluggable) 12 | [![License](https://img.shields.io/hexpm/l/pluggable.svg)](https://github.com/mruoss/pluggable/blob/main/LICENSE) 13 | 14 | Pluggable helps to define `Plug` like pipelines but with arbitrary tokens. 15 | The library comes with almost exact copies of the module `Plug` and 16 | `Plug.Builder`. However, instead of passing around a `%Plug.Conn{}` struct, 17 | this library passes around a Token you define in your project. 18 | 19 | ## Credits 20 | 21 | Most of the code in this module was copied from the 22 | [`:plug`](https://github.com/elixir-plug/plug/) library so credits go to the 23 | creators and maintainers of `:plug`. 24 | 25 | ## Installation 26 | 27 | The package can be installed by adding `pluggable` to your list of dependencies 28 | in `mix.exs`: 29 | 30 | ```elixir 31 | def deps do 32 | [ 33 | {:pluggable, "~> 1.0"} 34 | ] 35 | end 36 | ``` 37 | 38 | ## Usage 39 | 40 | To use this library, you first have to define your token. Once that 41 | is done, use `Pluggable.StepBuilder` to build steps and pipelines. 42 | 43 | ### Deriving Pluggable.Token 44 | 45 | The easiest way to define a token is to create a module which derives 46 | `Pluggable.Token` and defines a struct which, among others defines the keys: 47 | 48 | - `:halted` - the boolean status on whether the pipeline was halted 49 | - `:assigns` - shared user data as a map 50 | 51 | Example: 52 | 53 | ```elixir 54 | defmodule MyPipeline.Token do 55 | @derive Pluggable.Token 56 | defstruct [ 57 | halted: false, 58 | assigns: %{}, 59 | # other state 60 | ] 61 | end 62 | ``` 63 | 64 | If the fields holding these two states are named differently, pass the fields 65 | as options to `@derive`: 66 | 67 | ```elixir 68 | defmodule MyPipeline.Token do 69 | @derive {Pluggable.Token, halted_key: :stopped, assigns_key: :shared_state} 70 | defstruct [ 71 | stopped: false, 72 | shared_state: %{}, 73 | # other state 74 | ] 75 | end 76 | ``` 77 | 78 | ### Implementing Pluggable.Token 79 | 80 | `Pluggable.Token` can be implemented. The following is the default implementation 81 | when deriving `Pluggable.Token` 82 | 83 | ```elixir 84 | defmodule MyPipeline.Token do 85 | defstruct [ 86 | halted: nil, 87 | assigns: %{}, 88 | # other state 89 | ] 90 | end 91 | 92 | defimpl Pluggable.Token, for: MyPipeline.Token do 93 | def halted?(token), do: token.halted 94 | 95 | def halt(token), do: %{token | halted: true} 96 | 97 | def assign(%MyPipeline.Token{assigns: assigns} = token, key, value) when is_atom(key) do 98 | %{token | assigns: Map.put(assigns, key, value)} 99 | end 100 | end 101 | ``` 102 | 103 | ## Building Pipelines 104 | 105 | `Pluggable.StepBuilder` works just like `Plug.Builder`. See the 106 | module documentation for instructions. 107 | 108 | `Pluggable.PipelineBuilder` can be used to define and run multiple pipelines in 109 | the same module. See the module documentation for instructions. 110 | 111 | ## Code Formatting 112 | 113 | When using the `Pluggable.StepBuilder`, you might want to format the usage 114 | of the `step` macro without parens. To configure the formatter not to add 115 | parens, add this to your `.formatter.exs`: 116 | 117 | ```elixir 118 | # .formatter.exs 119 | [ 120 | import_deps: [:pluggable] 121 | ] 122 | ``` 123 | -------------------------------------------------------------------------------- /lib/pluggable.ex: -------------------------------------------------------------------------------- 1 | defmodule Pluggable do 2 | @moduledoc """ 3 | The step specification. 4 | 5 | There are two kind of steps: function steps and module steps. 6 | 7 | #### Function steps 8 | 9 | A function step is any function that receives a token and a set of 10 | options and returns a token. Its type signature must be: 11 | 12 | (Pluggable.Token.t, Pluggable.opts) :: Pluggable.Token.t 13 | 14 | #### Module steps 15 | 16 | A module step is an extension of the function step. It is a module that must 17 | export: 18 | 19 | * a `c:call/2` function with the signature defined above 20 | * an `c:init/1` function which takes a set of options and initializes it. 21 | 22 | The result returned by `c:init/1` is passed as second argument to `c:call/2`. Note 23 | that `c:init/1` may be called during compilation and as such it must not return 24 | pids, ports or values that are specific to the runtime. 25 | 26 | The API expected by a module step is defined as a behaviour by the 27 | `Pluggable` module (this module). 28 | 29 | ## Examples 30 | 31 | Here's an example of a function step: 32 | 33 | def json_header_step(token, _opts) do 34 | My.Token.put_data(token, "some_data") 35 | end 36 | 37 | Here's an example of a module step: 38 | 39 | defmodule PutSomeData do 40 | def init(opts) do 41 | opts 42 | end 43 | 44 | def call(token, _opts) do 45 | My.Token.put_data(token, "some_data") 46 | end 47 | end 48 | 49 | ## The Pluggable Step pipeline 50 | 51 | The `Pluggable.StepBuilder` module provides conveniences for building 52 | pluggable step pipelines. 53 | """ 54 | 55 | @type opts :: 56 | binary 57 | | tuple 58 | | atom 59 | | integer 60 | | float 61 | | [opts] 62 | | %{optional(opts) => opts} 63 | | MapSet.t() 64 | 65 | @callback init(opts) :: opts 66 | @callback call(token :: Pluggable.Token.t(), opts) :: Pluggable.Token.t() 67 | 68 | require Logger 69 | 70 | @doc """ 71 | Run a series of pluggable steps at runtime. 72 | 73 | The steps given here can be either a tuple, representing a module step 74 | and their options, or a simple function that receives a token and 75 | returns a token. 76 | 77 | If any of the steps halt, the remaining steps are not invoked. If the 78 | given token was already halted, none of the steps are invoked 79 | either. 80 | 81 | While `Pluggable.StepBuilder` works at compile-time, this is a 82 | straight-forward alternative that works at runtime. 83 | 84 | ## Examples 85 | 86 | Pluggable.run(token, [{My.Step, []}, &IO.inspect/1]) 87 | 88 | ## Options 89 | 90 | * `:log_on_halt` - a log level to be used if a pipeline halts 91 | 92 | """ 93 | @spec run( 94 | Pluggable.Token.t(), 95 | [{module, opts} | (Pluggable.Token.t() -> Pluggable.Token.t())], 96 | Keyword.t() 97 | ) :: 98 | Pluggable.Token.t() 99 | def run(token, steps, opts \\ []) do 100 | if Pluggable.Token.halted?(token), 101 | do: token, 102 | else: do_run(token, steps, Keyword.get(opts, :log_on_halt)) 103 | end 104 | 105 | defp do_run(token, [{mod, opts} | steps], level) when is_atom(mod) do 106 | next_token = mod.call(token, mod.init(opts)) 107 | 108 | if !Pluggable.Token.impl_for(next_token), 109 | do: raise("expected #{inspect(mod)} to return Pluggable.Token, got: #{inspect(next_token)}") 110 | 111 | if Pluggable.Token.halted?(next_token) do 112 | level && Logger.log(level, "Pluggable pipeline halted in #{inspect(mod)}.call/2") 113 | next_token 114 | else 115 | do_run(next_token, steps, level) 116 | end 117 | end 118 | 119 | defp do_run(token, [fun | steps], level) when is_function(fun, 1) do 120 | next_token = fun.(token) 121 | 122 | if !Pluggable.Token.impl_for(next_token), 123 | do: raise("expected #{inspect(fun)} to return Pluggable.Token, got: #{inspect(next_token)}") 124 | 125 | if Pluggable.Token.halted?(next_token) do 126 | level && Logger.log(level, "Pluggable pipeline halted in #{inspect(fun)}") 127 | next_token 128 | else 129 | do_run(next_token, steps, level) 130 | end 131 | end 132 | 133 | defp do_run(token, [], _level), do: token 134 | end 135 | -------------------------------------------------------------------------------- /test/pluggable/step_builder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Pluggable.StepBuilderTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ExUnit.CaptureLog 5 | 6 | defmodule Module do 7 | import Pluggable.Token 8 | 9 | def init(val) do 10 | {:init, val} 11 | end 12 | 13 | def call(token, opts) do 14 | stack = [{:call, opts} | List.wrap(token.assigns[:stack])] 15 | assign(token, :stack, stack) 16 | end 17 | end 18 | 19 | defmodule Sample do 20 | use Pluggable.StepBuilder, copy_opts_to_assign: :stack 21 | 22 | step :fun, :step1 23 | step Module, :step2 24 | step Module, :step3 25 | 26 | def fun(token, opts) do 27 | stack = [{:fun, opts} | token.assigns[:stack]] 28 | assign(token, :stack, stack) 29 | end 30 | end 31 | 32 | defmodule Overridable do 33 | use Pluggable.StepBuilder 34 | 35 | def call(token, opts) do 36 | super(token, opts) 37 | catch 38 | :throw, {:not_found, token} -> assign(token, :not_found, :caught) 39 | end 40 | 41 | step :boom 42 | 43 | def boom(token, _opts) do 44 | token = assign(token, :entered_stack, true) 45 | throw({:not_found, token}) 46 | end 47 | end 48 | 49 | defmodule Halter do 50 | use Pluggable.StepBuilder 51 | 52 | step :set_step, :first 53 | step :set_step, :second 54 | step :authorize 55 | step :set_step, :end_of_chain_reached 56 | 57 | def set_step(token, step), do: assign(token, step, true) 58 | 59 | def authorize(token, _) do 60 | token 61 | |> assign(:authorize_reached, true) 62 | |> halt() 63 | end 64 | end 65 | 66 | defmodule FaultyModuleStep do 67 | defmodule FaultyStep do 68 | def init([]), do: [] 69 | 70 | # Doesn't return a Pluggable.Token 71 | def call(_token, _opts), do: "foo" 72 | end 73 | 74 | use Pluggable.StepBuilder 75 | step FaultyStep 76 | end 77 | 78 | defmodule FaultyFunctionStep do 79 | use Pluggable.StepBuilder 80 | step :faulty_function 81 | 82 | # Doesn't return a Pluggable.Token 83 | def faulty_function(_token, _opts), do: "foo" 84 | end 85 | 86 | test "exports the init/1 function" do 87 | assert Sample.init(:ok) == :ok 88 | end 89 | 90 | test "builds step stack in the order" do 91 | token = %TestToken{} 92 | 93 | assert Sample.call(token, []).assigns[:stack] == [ 94 | call: {:init, :step3}, 95 | call: {:init, :step2}, 96 | fun: :step1 97 | ] 98 | 99 | assert Sample.call(token, [:initial]).assigns[:stack] == [ 100 | {:call, {:init, :step3}}, 101 | {:call, {:init, :step2}}, 102 | {:fun, :step1}, 103 | :initial 104 | ] 105 | end 106 | 107 | test "allows call/2 to be overridden with super" do 108 | token = Overridable.call(%TestToken{}, []) 109 | assert token.assigns[:not_found] == :caught 110 | assert token.assigns[:entered_stack] == true 111 | end 112 | 113 | test "halt/2 halts the step stack" do 114 | token = Halter.call(%TestToken{}, []) 115 | assert token.halted 116 | assert token.assigns[:first] 117 | assert token.assigns[:second] 118 | assert token.assigns[:authorize_reached] 119 | refute token.assigns[:end_of_chain_reached] 120 | end 121 | 122 | test "an exception is raised if a step doesn't return a stepection" do 123 | assert_raise RuntimeError, fn -> 124 | FaultyModuleStep.call(%TestToken{}, []) 125 | end 126 | 127 | assert_raise RuntimeError, fn -> 128 | FaultyFunctionStep.call(%TestToken{}, []) 129 | end 130 | end 131 | 132 | test "an exception is raised at compile time if a step with no call/2 function is plugged" do 133 | assert_raise ArgumentError, fn -> 134 | defmodule BadStep do 135 | defmodule Bad do 136 | def init(opts), do: opts 137 | end 138 | 139 | use Pluggable.StepBuilder 140 | step Bad 141 | end 142 | end 143 | end 144 | 145 | test "compile and runtime init modes" do 146 | {:ok, _agent} = Agent.start_link(fn -> :compile end, name: :plug_init) 147 | 148 | defmodule Assigner do 149 | use Pluggable.StepBuilder 150 | 151 | def init(agent), do: {:init, Agent.get(agent, & &1)} 152 | 153 | def call(token, opts), do: assign(token, :opts, opts) 154 | end 155 | 156 | defmodule CompileInit do 157 | use Pluggable.StepBuilder 158 | 159 | var = :plug_init 160 | step Assigner, var 161 | end 162 | 163 | defmodule RuntimeInit do 164 | use Pluggable.StepBuilder, init_mode: :runtime 165 | 166 | var = :plug_init 167 | step Assigner, var 168 | end 169 | 170 | :ok = Agent.update(:plug_init, fn :compile -> :runtime end) 171 | 172 | assert CompileInit.call(%TestToken{}, :plug_init).assigns.opts == {:init, :compile} 173 | assert RuntimeInit.call(%TestToken{}, :plug_init).assigns.opts == {:init, :runtime} 174 | end 175 | 176 | test "raises for other init modes" do 177 | assert_raise ArgumentError, fn -> 178 | Code.eval_string(""" 179 | defmodule OtherInit do 180 | use Pluggable.StepBuilder, init_mode: :other 181 | end 182 | """) 183 | end 184 | end 185 | 186 | defmodule LogOnModuleHalt do 187 | use Pluggable.StepBuilder, log_on_halt: :error 188 | 189 | step Halter 190 | end 191 | 192 | defmodule LogOnFunctionHalt do 193 | use Pluggable.StepBuilder, log_on_halt: :error 194 | 195 | step :halter 196 | 197 | def halter(token, _opts), do: halt(token) 198 | end 199 | 200 | test "log on halt" do 201 | assert capture_log([level: :error], fn -> 202 | assert LogOnModuleHalt.call(%TestToken{}, []).halted 203 | end) =~ 204 | "[error] Pluggable.StepBuilderTest.LogOnModuleHalt halted in Pluggable.StepBuilderTest.Halter.call/2" 205 | 206 | assert capture_log([level: :error], fn -> 207 | assert LogOnFunctionHalt.call(%TestToken{}, []).halted 208 | end) =~ 209 | "[error] Pluggable.StepBuilderTest.LogOnFunctionHalt halted in :halter/2" 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /.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 config using `mix credo -C `. If no config 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: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: %{ 68 | enabled: [ 69 | # 70 | ## Consistency Checks 71 | # 72 | {Credo.Check.Consistency.ExceptionNames, []}, 73 | {Credo.Check.Consistency.LineEndings, []}, 74 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 75 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 76 | {Credo.Check.Consistency.SpaceInParentheses, []}, 77 | {Credo.Check.Consistency.TabsOrSpaces, []}, 78 | 79 | # 80 | ## Design Checks 81 | # 82 | # You can customize the priority of any check 83 | # Priority values are: `low, normal, high, higher` 84 | # 85 | {Credo.Check.Design.AliasUsage, 86 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 87 | # You can also customize the exit_status of each check. 88 | # If you don't want TODO comments to cause `mix credo` to fail, just 89 | # set this value to 0 (zero). 90 | # 91 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 92 | {Credo.Check.Design.TagFIXME, []}, 93 | 94 | # 95 | ## Readability Checks 96 | # 97 | {Credo.Check.Readability.AliasOrder, []}, 98 | {Credo.Check.Readability.FunctionNames, []}, 99 | {Credo.Check.Readability.LargeNumbers, []}, 100 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 101 | {Credo.Check.Readability.ModuleAttributeNames, []}, 102 | {Credo.Check.Readability.ModuleDoc, []}, 103 | {Credo.Check.Readability.ModuleNames, []}, 104 | {Credo.Check.Readability.ParenthesesInCondition, []}, 105 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 106 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 107 | {Credo.Check.Readability.PredicateFunctionNames, []}, 108 | {Credo.Check.Readability.PreferImplicitTry, []}, 109 | {Credo.Check.Readability.RedundantBlankLines, []}, 110 | {Credo.Check.Readability.Semicolons, []}, 111 | {Credo.Check.Readability.SpaceAfterCommas, []}, 112 | {Credo.Check.Readability.StringSigils, []}, 113 | {Credo.Check.Readability.TrailingBlankLine, []}, 114 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 115 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 116 | {Credo.Check.Readability.VariableNames, []}, 117 | {Credo.Check.Readability.WithSingleClause, []}, 118 | 119 | # 120 | ## Refactoring Opportunities 121 | # 122 | {Credo.Check.Refactor.Apply, []}, 123 | {Credo.Check.Refactor.CondStatements, []}, 124 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 125 | {Credo.Check.Refactor.FunctionArity, []}, 126 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 127 | {Credo.Check.Refactor.MatchInCondition, []}, 128 | {Credo.Check.Refactor.MapJoin, []}, 129 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 130 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 131 | {Credo.Check.Refactor.Nesting, []}, 132 | {Credo.Check.Refactor.UnlessWithElse, []}, 133 | {Credo.Check.Refactor.WithClauses, []}, 134 | {Credo.Check.Refactor.FilterFilter, []}, 135 | {Credo.Check.Refactor.RejectReject, []}, 136 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 137 | 138 | # 139 | ## Warnings 140 | # 141 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 142 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 143 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 144 | {Credo.Check.Warning.IExPry, []}, 145 | {Credo.Check.Warning.IoInspect, []}, 146 | {Credo.Check.Warning.OperationOnSameValues, []}, 147 | {Credo.Check.Warning.OperationWithConstantResult, []}, 148 | {Credo.Check.Warning.RaiseInsideRescue, []}, 149 | {Credo.Check.Warning.SpecWithStruct, []}, 150 | {Credo.Check.Warning.WrongTestFileExtension, []}, 151 | {Credo.Check.Warning.UnusedEnumOperation, []}, 152 | {Credo.Check.Warning.UnusedFileOperation, []}, 153 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 154 | {Credo.Check.Warning.UnusedListOperation, []}, 155 | {Credo.Check.Warning.UnusedPathOperation, []}, 156 | {Credo.Check.Warning.UnusedRegexOperation, []}, 157 | {Credo.Check.Warning.UnusedStringOperation, []}, 158 | {Credo.Check.Warning.UnusedTupleOperation, []}, 159 | {Credo.Check.Warning.UnsafeExec, []} 160 | ], 161 | disabled: [ 162 | # 163 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 164 | 165 | # 166 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 167 | # and be sure to use `mix credo --strict` to see low priority checks) 168 | # 169 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 170 | {Credo.Check.Consistency.UnusedVariableNames, []}, 171 | {Credo.Check.Design.DuplicatedCode, []}, 172 | {Credo.Check.Design.SkipTestWithoutComment, []}, 173 | {Credo.Check.Readability.AliasAs, []}, 174 | {Credo.Check.Readability.BlockPipe, []}, 175 | {Credo.Check.Readability.ImplTrue, []}, 176 | {Credo.Check.Readability.MultiAlias, []}, 177 | {Credo.Check.Readability.NestedFunctionCalls, []}, 178 | {Credo.Check.Readability.SeparateAliasRequire, []}, 179 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 180 | {Credo.Check.Readability.SinglePipe, []}, 181 | {Credo.Check.Readability.Specs, []}, 182 | {Credo.Check.Readability.StrictModuleLayout, []}, 183 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 184 | {Credo.Check.Refactor.ABCSize, []}, 185 | {Credo.Check.Refactor.AppendSingleItem, []}, 186 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 187 | {Credo.Check.Refactor.FilterReject, []}, 188 | {Credo.Check.Refactor.IoPuts, []}, 189 | {Credo.Check.Refactor.MapMap, []}, 190 | {Credo.Check.Refactor.ModuleDependencies, []}, 191 | {Credo.Check.Refactor.NegatedIsNil, []}, 192 | {Credo.Check.Refactor.PipeChainStart, []}, 193 | {Credo.Check.Refactor.RejectFilter, []}, 194 | {Credo.Check.Refactor.VariableRebinding, []}, 195 | {Credo.Check.Warning.LazyLogging, []}, 196 | {Credo.Check.Warning.LeakyEnvironment, []}, 197 | {Credo.Check.Warning.MapGetUnsafePass, []}, 198 | {Credo.Check.Warning.MixEnv, []}, 199 | {Credo.Check.Warning.UnsafeToAtom, []} 200 | 201 | # {Credo.Check.Refactor.MapInto, []}, 202 | 203 | # 204 | # Custom checks can be created using `mix credo.gen.check`. 205 | # 206 | ] 207 | } 208 | } 209 | ] 210 | } 211 | -------------------------------------------------------------------------------- /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 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /lib/pluggable/step_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Pluggable.StepBuilder do 2 | @moduledoc """ 3 | Conveniences for building pipelines. 4 | 5 | defmodule MyApp do 6 | use Pluggable.StepBuilder 7 | 8 | step SomeLibrary.Logger 9 | step :hello, upper: true 10 | 11 | # A function from another module can be plugged too, provided it's 12 | # imported into the current module first. 13 | import AnotherModule, only: [interesting_step: 2] 14 | step :interesting_step 15 | 16 | def hello(token, opts) do 17 | body = if opts[:upper], do: "WORLD", else: "world" 18 | send_resp(token, 200, body) 19 | end 20 | end 21 | 22 | Multiple steps can be defined with the `step/2` macro, forming a pipeline. 23 | The steps in the pipeline will be executed in the order they've been added 24 | through the `step/2` macro. In the example above, `SomeLibrary.Logger` will 25 | be called first and then the `:hello` function step will be called on the 26 | resulting token. 27 | 28 | ## Options 29 | 30 | When used, the following options are accepted by `Pluggable.StepBuilder`: 31 | 32 | * `:init_mode` - the environment to initialize the step's options, one of 33 | `:compile` or `:runtime`. Defaults `:compile`. 34 | 35 | * `:log_on_halt` - accepts the level to log whenever the request is halted 36 | 37 | * `:copy_opts_to_assign` - an `atom` representing an assign. When supplied, 38 | it will copy the options given to the Step initialization to the given 39 | token assign 40 | 41 | ## step behaviour 42 | 43 | Internally, `Pluggable.StepBuilder` implements the `Pluggable` behaviour, which 44 | means both the `init/1` and `call/2` functions are defined. 45 | 46 | By implementing the Pluggable API, `Pluggable.StepBuilder` guarantees this module 47 | is a pluggable step and can be run via `Pluggable.run/3` or used as part of 48 | another pipeline. 49 | 50 | ## Overriding the default Pluggable API functions 51 | 52 | Both the `init/1` and `call/2` functions defined by `Pluggable.StepBuilder` can 53 | be manually overridden. For example, the `init/1` function provided by 54 | `Pluggable.StepBuilder` returns the options that it receives as an argument, but 55 | its behaviour can be customized: 56 | 57 | defmodule StepWithCustomOptions do 58 | use Pluggable.StepBuilder 59 | step SomeLibrary.Logger 60 | 61 | def init(opts) do 62 | opts 63 | end 64 | end 65 | 66 | The `call/2` function that `Pluggable.StepBuilder` provides is used internally to 67 | execute all the steps listed using the `step` macro, so overriding the 68 | `call/2` function generally implies using `super` in order to still call the 69 | step chain: 70 | 71 | defmodule StepWithCustomCall do 72 | use Pluggable.StepBuilder 73 | step SomeLibrary.Logger 74 | step SomeLibrary.AddMeta 75 | 76 | def call(token, opts) do 77 | token 78 | |> super(opts) # calls SomeLibrary.Logger and SomeLibrary.AddMeta 79 | |> assign(:called_all_steps, true) 80 | end 81 | end 82 | 83 | ## Halting a pluggable step pipeline 84 | 85 | A pluggable step pipeline can be halted with `Pluggable.Token.halt/1`. The builder 86 | will prevent further steps downstream from being invoked and return the 87 | current token. In the following example, the `SomeLibrary.Logger` step never 88 | gets called: 89 | 90 | defmodule StepUsingHalt do 91 | use Pluggable.StepBuilder 92 | 93 | step :stopper 94 | step SomeLibrary.Logger 95 | 96 | def stopper(token, _opts) do 97 | halt(token) 98 | end 99 | end 100 | """ 101 | 102 | @type step :: module | atom 103 | 104 | @doc false 105 | defmacro __using__(opts) do 106 | quote do 107 | @behaviour Pluggable 108 | @pluggable_builder_opts unquote(opts) 109 | 110 | @impl Pluggable 111 | def init(opts) do 112 | opts 113 | end 114 | 115 | @impl Pluggable 116 | def call(token, opts) do 117 | pluggable_builder_call(token, opts) 118 | end 119 | 120 | defoverridable Pluggable 121 | 122 | import Pluggable.Token 123 | import Pluggable.StepBuilder, only: [step: 1, step: 2] 124 | 125 | Module.register_attribute(__MODULE__, :steps, accumulate: true) 126 | @before_compile Pluggable.StepBuilder 127 | end 128 | end 129 | 130 | @spec __before_compile__(Macro.Env.t()) :: {:__block__, [], maybe_improper_list} 131 | @doc false 132 | defmacro __before_compile__(env) do 133 | steps = Module.get_attribute(env.module, :steps) 134 | builder_opts = Module.get_attribute(env.module, :pluggable_builder_opts) 135 | {token, body} = Pluggable.StepBuilder.compile(env, steps, builder_opts) 136 | 137 | compile_time = 138 | if builder_opts[:init_mode] == :runtime do 139 | [] 140 | else 141 | for triplet <- steps, 142 | {step, _, _} = triplet, 143 | module_step?(step) do 144 | quote(do: unquote(step).__info__(:module)) 145 | end 146 | end 147 | 148 | pluggable_builder_call = 149 | if assign = builder_opts[:copy_opts_to_assign] do 150 | quote do 151 | defp pluggable_builder_call(token, opts) do 152 | unquote(token) = Pluggable.Token.assign(token, unquote(assign), opts) 153 | unquote(body) 154 | end 155 | end 156 | else 157 | quote do 158 | defp pluggable_builder_call(unquote(token), opts), do: unquote(body) 159 | end 160 | end 161 | 162 | quote do 163 | unquote_splicing(compile_time) 164 | unquote(pluggable_builder_call) 165 | end 166 | end 167 | 168 | @doc """ 169 | A macro that stores a new step. `opts` will be passed unchanged to the new 170 | step. 171 | 172 | This macro doesn't add any guards when adding the new step to the pipeline; 173 | for more information about adding steps with guards see `compile/3`. 174 | 175 | ## Examples 176 | 177 | step SomeLibrary.Logger # step module 178 | step :foo, some_options: true # step function 179 | 180 | """ 181 | defmacro step(step, opts \\ []) do 182 | # We always expand it but the @before_compile callback adds compile 183 | # time dependencies back depending on the builder's init mode. 184 | step = expand_alias(step, __CALLER__) 185 | 186 | # If we are sure we don't have a module step, the options are all 187 | # runtime options too. 188 | opts = 189 | if is_atom(step) and not module_step?(step) and Macro.quoted_literal?(opts) do 190 | Macro.prewalk(opts, &expand_alias(&1, __CALLER__)) 191 | else 192 | opts 193 | end 194 | 195 | quote do 196 | @steps {unquote(step), unquote(opts), true} 197 | end 198 | end 199 | 200 | defp expand_alias({:__aliases__, _, _} = alias, env), 201 | do: Macro.expand(alias, %{env | function: {:init, 1}}) 202 | 203 | defp expand_alias(other, _env), do: other 204 | 205 | @doc """ 206 | Compiles a pluggable step pipeline. 207 | 208 | Each element of the pluggable step pipeline (according to the type signature of this 209 | function) has the form: 210 | 211 | {step_name, options, guards} 212 | 213 | Note that this function expects a reversed pipeline (with the last step that 214 | has to be called coming first in the pipeline). 215 | 216 | The function returns a tuple with the first element being a quoted reference 217 | to the token and the second element being the compiled quoted pipeline. 218 | 219 | ## Examples 220 | 221 | Pluggable.StepBuilder.compile(env, [ 222 | {SomeLibrary.Logger, [], true}, # no guards, as added by the Pluggable.StepBuilder.step/2 macro 223 | {SomeLibrary.AddMeta, [], quote(do: a when is_binary(a))} 224 | ], []) 225 | 226 | """ 227 | @spec compile(Macro.Env.t(), [{step, Pluggable.opts(), Macro.t()}], Keyword.t()) :: 228 | {Macro.t(), Macro.t()} 229 | def compile(env, pipeline, builder_opts) do 230 | token = quote do: token 231 | init_mode = builder_opts[:init_mode] || :compile 232 | 233 | unless init_mode in [:compile, :runtime] do 234 | raise ArgumentError, """ 235 | invalid :init_mode when compiling #{inspect(env.module)}. 236 | 237 | Supported values include :compile or :runtime. Got: #{inspect(init_mode)} 238 | """ 239 | end 240 | 241 | ast = 242 | Enum.reduce(pipeline, token, fn {step, opts, guards}, acc -> 243 | {step, opts, guards} 244 | |> init_step(init_mode) 245 | |> quote_step(init_mode, acc, env, builder_opts) 246 | end) 247 | 248 | {token, ast} 249 | end 250 | 251 | defp module_step?(step), do: match?(~c"Elixir." ++ _, Atom.to_charlist(step)) 252 | 253 | # Initializes the options of a step in the configured init_mode. 254 | defp init_step({step, opts, guards}, init_mode) do 255 | if module_step?(step) do 256 | init_module_step(step, opts, guards, init_mode) 257 | else 258 | init_fun_step(step, opts, guards) 259 | end 260 | end 261 | 262 | defp init_module_step(step, opts, guards, :compile) do 263 | initialized_opts = step.init(opts) 264 | 265 | if function_exported?(step, :call, 2) do 266 | {:module, step, escape(initialized_opts), guards} 267 | else 268 | raise ArgumentError, "#{inspect(step)} step must implement call/2" 269 | end 270 | end 271 | 272 | defp init_module_step(step, opts, guards, :runtime) do 273 | {:module, step, quote(do: unquote(step).init(unquote(escape(opts)))), guards} 274 | end 275 | 276 | defp init_fun_step(step, opts, guards) do 277 | {:function, step, escape(opts), guards} 278 | end 279 | 280 | defp escape(opts) do 281 | Macro.escape(opts, unquote: true) 282 | end 283 | 284 | defp quote_step({:module, step, opts, guards}, :compile, acc, env, builder_opts) do 285 | # require no longer adds a compile time dependency, which is 286 | # required by stepgable.Builder. So we build the alias an we expand it. 287 | parts = [:"Elixir" | Enum.map(Module.split(step), &String.to_atom/1)] 288 | alias = {:__aliases__, [line: env.line], parts} 289 | _ = Macro.expand(alias, env) 290 | 291 | quote_step(:module, step, opts, guards, acc, env, builder_opts) 292 | end 293 | 294 | defp quote_step({step_type, step, opts, guards}, _init_mode, acc, env, builder_opts) do 295 | quote_step(step_type, step, opts, guards, acc, env, builder_opts) 296 | end 297 | 298 | # `acc` is a series of nested step calls in the form of step3(step2(step1(token))). 299 | # `quote_step` wraps a new step around that series of calls. 300 | defp quote_step(step_type, step, opts, guards, acc, env, builder_opts) do 301 | call = quote_step_call(step_type, step, opts) 302 | 303 | error_message = 304 | case step_type do 305 | :module -> "expected #{inspect(step)}.call/2 to return a Pluggable.Token" 306 | :function -> "expected #{step}/2 to return a Pluggable.Token" 307 | end <> ", all steps must receive a token and return a token" 308 | 309 | quote generated: true do 310 | token = unquote(compile_guards(call, guards)) 311 | 312 | if !Pluggable.Token.impl_for(token), 313 | do: raise(unquote(error_message) <> ", got: #{inspect(token)}") 314 | 315 | if Pluggable.Token.halted?(token) do 316 | unquote(log_halt(step_type, step, env, builder_opts)) 317 | token 318 | else 319 | unquote(acc) 320 | end 321 | end 322 | end 323 | 324 | defp quote_step_call(:function, step, opts) do 325 | quote do: unquote(step)(token, unquote(opts)) 326 | end 327 | 328 | defp quote_step_call(:module, step, opts) do 329 | quote do: unquote(step).call(token, unquote(opts)) 330 | end 331 | 332 | defp compile_guards(call, true) do 333 | call 334 | end 335 | 336 | defp compile_guards(call, guards) do 337 | quote do 338 | case true do 339 | true when unquote(guards) -> unquote(call) 340 | true -> token 341 | end 342 | end 343 | end 344 | 345 | defp log_halt(step_type, step, env, builder_opts) do 346 | if level = builder_opts[:log_on_halt] do 347 | message = 348 | case step_type do 349 | :module -> "#{inspect(env.module)} halted in #{inspect(step)}.call/2" 350 | :function -> "#{inspect(env.module)} halted in #{inspect(step)}/2" 351 | end 352 | 353 | quote do 354 | require Logger 355 | # Matching, to make Dialyzer happy on code executing Pluggable.StepBuilder.compile/3 356 | _ = Logger.unquote(level)(unquote(message)) 357 | end 358 | else 359 | nil 360 | end 361 | end 362 | end 363 | --------------------------------------------------------------------------------