{"id":20072227,"url":"https://github.com/cloud8421/recipe","last_synced_at":"2025-05-05T20:33:10.960Z","repository":{"id":57542049,"uuid":"95877577","full_name":"cloud8421/recipe","owner":"cloud8421","description":"An Elixir library to compose multi-step, reversible workflows.","archived":false,"fork":false,"pushed_at":"2018-02-16T10:31:03.000Z","size":62,"stargazers_count":50,"open_issues_count":4,"forks_count":1,"subscribers_count":2,"default_branch":"master","last_synced_at":"2024-09-15T14:05:05.824Z","etag":null,"topics":["elixir"],"latest_commit_sha":null,"homepage":null,"language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/cloud8421.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2017-06-30T10:29:12.000Z","updated_at":"2023-10-17T09:28:59.000Z","dependencies_parsed_at":"2022-09-08T23:51:38.261Z","dependency_job_id":null,"html_url":"https://github.com/cloud8421/recipe","commit_stats":null,"previous_names":[],"tags_count":8,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cloud8421%2Frecipe","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cloud8421%2Frecipe/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cloud8421%2Frecipe/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cloud8421%2Frecipe/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/cloud8421","download_url":"https://codeload.github.com/cloud8421/recipe/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":224468184,"owners_count":17316325,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["elixir"],"created_at":"2024-11-13T14:39:04.630Z","updated_at":"2024-11-13T14:39:05.766Z","avatar_url":"https://github.com/cloud8421.png","language":"Elixir","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Recipe\n\n[![Build Status](https://travis-ci.org/cloud8421/recipe.svg?branch=master)](https://travis-ci.org/cloud8421/recipe)\n[![Docs Status](https://inch-ci.org/github/cloud8421/recipe.svg?branch=inch-ci-support)](https://inch-ci.org/github/cloud8421/recipe)\n[![Coverage Status](https://coveralls.io/repos/github/cloud8421/recipe/badge.svg?branch=coverage)](https://coveralls.io/github/cloud8421/recipe?branch=coverage)\n\n## Intro\n\nThe `Recipe` module allows implementing multi-step, reversible workflows.\n\nFor example, you may wanna parse some incoming data, write to two different\ndata stores and then push some notifications. If anything fails, you wanna\nrollback specific changes in different data stores. `Recipe` allows you to do\nthat.\n\nIn addition, a recipe doesn't enforce any constraint around which processes\nexecute which step. You can assume that unless you explicitly involve other\nprocesses, all code that builds a recipe is executed by default by the\ncalling process.\n\nIdeal use cases are:\n\n- multi-step operations where you need basic transactional properties, e.g.\n  saving data to Postgresql and Redis, rolling back the change in Postgresql if\n  the Redis write fails\n- interaction with services that simply don't support transactions\n- composing multiple workflows that can share steps (with the\n  help of `Kernel.defdelegate/2`)\n- trace workflows execution via a correlation id\n\nYou can avoid using this library if:\n\n- A simple `with` macro will do\n- You don't care about failure semantics and just want your operation to\n  crash the calling process\n- Using Ecto, you can express your workflow with `Ecto.Multi`\n\nHeavily inspired by the `ktn_recipe` module included in [inaka/erlang-katana](https://github.com/inaka/erlang-katana).\n\n## Core ideas\n\n- A workflow is as a set of discreet steps\n- Each step can have a specific error handling scenario\n- Each step is a separate function that receives a state\n  with the result of all previous steps\n- Each step should be easily testable in isolation\n- Each workflow needs to be easily audited via logs or an event store\n\n## Installation\n\nIf [available in Hex](https://hex.pm/docs/publish), the package can be installed\nby adding `recipe` to your list of dependencies in `mix.exs`:\n\n```elixir\ndef deps do\n  [{:recipe, \"~\u003e 0.4.0\"}]\nend\n```\n\n## Example\n\nThe example below outlines a possible workflow where a user creates a new\nconversation, passing an initial message.\n\nEach step is named in `steps/0`. Each step definition uses data added to the\nworkflow state and performs a specific task.\n\nAny error shortcuts the workflow to `handle_error/3`, where a specialized\nclause for `:create_initial_message` deletes the conversation if the system\nfailes to create the initial message (therefore simulating a transaction).\n\n```elixir\ndefmodule StartNewConversation do\n  use Recipe\n\n  ### Public API\n\n  def run(user_id, initial_message_text) do\n    state = Recipe.initial_state\n            |\u003e Recipe.assign(:user_id, user_id)\n            |\u003e Recipe.assign(:initial_message_text, initial_message_text)\n\n    Recipe.run(__MODULE__, state)\n  end\n\n  ### Callbacks\n\n  def steps, do: [:validate,\n                  :create_conversation,\n                  :create_initial_message,\n                  :broadcast_new_conversation,\n                  :broadcast_new_message]\n\n  def handle_result(state) do\n    state.assigns.conversation\n  end\n\n  def handle_error(:create_initial_message, _error, state) do\n    Service.Conversation.delete(state.conversation.id)\n  end\n  def handle_error(_step, error, _state), do: error\n\n  ### Steps\n\n  def validate(state) do\n    text = state.assigns.initial_message_text\n    if MessageValidator.valid_text?(text) do\n      {:ok, state}\n    else\n      {:error, :empty_message_text}\n    end\n  end\n\n  def create_conversation(state) do\n    case Service.Conversation.create(state.assigns.user_id) do\n      {:ok, conversation} -\u003e\n        {:ok, Recipe.assign(state, :conversation, conversation)}\n      error -\u003e\n        error\n    end\n  end\n\n  def create_initial_message(state) do\n    %{user_id: user_id,\n      conversation: conversation,\n      initial_message_text: text} = state.assigns\n    case Service.Message.create(user_id, conversation.id, text) do\n      {:ok, message} -\u003e\n        {:ok, Recipe.assign(state, :initial_message, message)}\n      error -\u003e\n        error\n    end\n  end\n\n  def broadcast_new_conversation(state) do\n    Dispatcher.broadcast(\"conversation-created\", state.assigns.conversation)\n    {:ok, state}\n  end\n\n  def broadcast_new_message(state) do\n    Dispatcher.broadcast(\"message-created\", state.assigns.initial_message)\n    {:ok, state}\n  end\nend\n```\n\nFor more examples, see: \u003chttps://github.com/cloud8421/recipe/tree/master/examples\u003e.\n\n## Telemetry\n\nA recipe run can be instrumented with callbacks for start, end and each step execution.\n\nTo instrument a recipe run, it's sufficient to call:\n\n```elixir\nRecipe.run(module, initial_state, enable_telemetry: true)\n```\n\nThe default setting for telemetry is to use the `Recipe.Debug` module, but you can implement\nyour own by using the `Recipe.Telemetry` behaviour, definining the needed callbacks and run\nthe recipe as follows:\n\n```elixir\nRecipe.run(module, initial_state, enable_telemetry: true, telemetry_module: MyModule)\n```\n\nAn example of a compliant module can be:\n\n```elixir\ndefmodule Recipe.Debug do\n  use Recipe.Telemetry\n\n  def on_start(state) do\n    IO.inspect(state)\n  end\n\n  def on_finish(state) do\n    IO.inspect(state)\n  end\n\n  def on_success(step, state, elapsed_microseconds) do\n    IO.inspect([step, state, elapsed_microseconds])\n  end\n\n  def on_error(step, error, state, elapsed_microseconds) do\n    IO.inspect([step, error, state, elapsed_microseconds])\n  end\nend\n```\n\n## Application-wide telemetry configuration\n\nIf you wish to control telemetry application-wide, you can do that by\ncreating an application-specific wrapper for `Recipe` as follows:\n\n```elixir\ndefmodule MyApp.Recipe do\n  def run(recipe_module, initial_state, run_opts \\\\ []) do\n    final_run_opts = Keyword.put_new(run_opts,\n                                     :enable_telemetry,\n                                     telemetry_enabled?())\n\n    Recipe.run(recipe_module, initial_state, final_run_opts)\n  end\n\n  def telemetry_on! do\n    Application.put_env(:recipe, :enable_telemetry, true)\n  end\n\n  def telemetry_off! do\n    Application.put_env(:recipe, :enable_telemetry, false)\n  end\n\n  defp telemetry_enabled? do\n    Application.get_env(:recipe, :enable_telemetry, false)\n  end\nend\n```\n\nThis module supports using a default setting which can be toggled\nat runtime with `telemetry_on!/0` and `telemetry_off!/0`, overridable\non a per-run basis by passing `enable_telemetry: false` as a third\nargument to `MyApp.Recipe.run/3`.\n\nYou can also add static configuration to `config/config.exs`:\n\n```elixir\nconfig :recipe,\n  enable_telemetry: true\n```\n\n## Type specifications\n\nIf you use type specifications via Dialyzer, you can extend the types defined\nby Recipe to have better guarantees around your individual steps.\n\nIn the example below specifications and types are added for steps and values\ninside assigns, so that it's possible for Dialyzer to provide more accurate results.\n\n```elixir\ndefmodule Recipe.Example do\n  @moduledoc false\n\n  use Recipe\n\n  @type step :: :double\n  @type steps :: [step]\n  @type assigns :: %{number: integer}\n  @type state :: %Recipe{assigns: assigns}\n\n  @spec run(integer) :: {:ok, integer} | {:error, :not_an_integer}\n  def run(number) do\n    initial_state = Recipe.initial_state\n                    |\u003e Recipe.assign(:number, number)\n\n    Recipe.run(__MODULE__, initial_state)\n  end\n\n  def steps, do: [:double]\n\n  @spec double(state) :: {:ok, state} | {:error, :not_an_integer}\n  def double(state) do\n    if is_integer(state.assigns.number) do\n      {:ok, Recipe.assign(state, :number, state.assigns.number * 2)}\n    else\n      {:error, :not_an_integer}\n    end\n  end\n\n  @spec handle_error(step, term, state) :: :ok\n  def handle_error(_step, error, _state), do: error\n\n  @spec handle_result(state) :: :ok\n  def handle_result(_state), do: :ok\nend\n```\n\n## Development/Test\n\n- Initial setup can be done with `mix deps.get`\n- Run tests with `mix test`\n- Run dialyzer with `mix dialyzer`\n- Run credo with `mix credo`\n- Build docs with `MIX_ENV=docs mix docs`\n\n## Docker support\n\nYou can run all of commands above via Docker:\n\n`docker run -it --rm -v \"$PWD\":/usr/src/recipe -w /usr/src/recipe elixir \u003cyour-mix-command\u003e`\n\nFor example you can run tests with:\n\n`docker run -it --rm -v \"$PWD\":/usr/src/recipe -w /usr/src/recipe elixir mix do local.hex --force, deps.get \u0026\u0026 mix test`\n\n## Special thanks\n\nSpecial thanks go to the following people for their help in the initial design phase for this library:\n\n- Ju Liu ([@arkham](https://github.com/Arkham))\n- Emanuel Mota ([@emanuel](https://github.com/emanuel))\n- Miguel Pinto ([@firewalkr](https://github.com/firewalkr))\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcloud8421%2Frecipe","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcloud8421%2Frecipe","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcloud8421%2Frecipe/lists"}