Call an event and use its result

v0.1.14

Calling an event runs every active plugin attached to it, in order, and returns what they produced. This guide shows how to call an event, pass context to the plugins, keep your own input, choose the answer for an event whose plugins cannot run, ask whether an event has plugins, and read the result without trusting its shape.

Before you begin

  • Add {:mishka_installer, "~> 0.1.14"} to the project that fires the event.
  • Pick an event name, a string. Plugins attach to it with use MishkaInstaller.Event.Hook, event: "...". See Write a plugin for an event.

Call an event

Call Hook.call/3 with the event name, the data, and an optional keyword list of arguments:

elixir
alias MishkaInstaller.Event.Hook

Hook.call("after_register", %{email: "[email protected]"})

The call returns the result of the plugin chain. When the event has no compiled chain on this node, because no plugin has ever started for it, the call returns the data you gave it.

Hook.call!/3 takes the same arguments but skips the check for a compiled chain. It raises UndefinedFunctionError for an event that was never compiled. Use it only where you know a plugin is attached.

The plugins run in the process that makes the call, one after another. A slow plugin delays the caller.

Read the result without trusting its shape

You get your own data back when no plugin is attached, and a plugin may return a different shape from the one you sent. Match the shape you need and fall back when it is missing. This is how Mishka CMS collects the cards of the account security tab:

elixir
added =
  case MishkaInstaller.Event.Hook.call(@event, %{cards: @core}) do
    %{cards: cards} when is_list(cards) -> Enum.filter(cards, &card?/1)
    _nothing -> []
  end

Pass read-only context with meta

The meta argument reaches the plugins that define call/2 as their second argument. It is never merged into the result. Plugins that define only call/1 do not see it.

elixir
Hook.call("web:request:after_site_resolve", conn, meta: %{host: host})

This line is from the site lookup plug of Mishka CMS. A plugin that wants the host defines call(conn, meta) and reads meta.host.

Merge fixed data with private

The private argument is merged into the result after the plugins have run, so no plugin can overwrite it. It merges into a map with Map.merge/2, and into a keyword list with Keyword.merge/2. Give it the same shape as the result. A map or a keyword list inside {:ok, _} is merged too. For any other result shape, private is ignored.

elixir
Hook.call("transform_evt", %{n: 5}, private: %{tag: "x"})
# %{n: 12, tag: "x"} when the plugins turn n into 12

Keep your input with return

When you pass return: with any value other than nil, the plugins still run, but the call returns the data exactly as you gave it. Use it for events that only announce something. Mishka CMS announces a saved record this way:

elixir
MishkaInstaller.Event.Hook.call(event_name(resource, action.name), record,
  meta: meta,
  return: false
)

The library tests the option only against nil, so return: false gives the same result as return: true: the input comes back.

Choose the answer when plugins cannot run

An event cannot run when one of its plugins is started but its module is not loaded. Then no plugin of that event runs. The call answers the value of cannot_run:, or your data when you pass none. An event whose plugins all run ignores the option.

Before 0.1.13 such a call answered {:error, [%{action: :call, field: :event, plugins: [...]}]}. The changes documentation shows how to update code that matched that error.

Pass cannot_run: when "no plugin ran" must not look like "no plugin objected". A decision that plugins take part in should refuse:

elixir
Hook.call("auth:gate:check", gate, cannot_run: %{gate | allowed?: false})

Mishka CMS asks which producers hold share cards the same way, and turns the answer into an error it can report:

elixir
case Hook.call(@targets_event, asked, cannot_run: :cannot_run) do
  %{targets: %{} = targets} ->
    {:ok, Map.filter(targets, fn {name, list} -> is_binary(name) and is_list(list) end)}

  :cannot_run ->
    {:error, :cannot_run}

  _not_answered ->
    {:ok, %{}}
end

To see whether an event is in this state, read mode from Hook.event_health/2, or mode/0 of the compiled module as the next part shows. It is :error for an event that cannot run. Each time the library compiles such an event, it also logs cannot run with the names of the plugins it waits for.

Ask whether an event has plugins

Firing an event only to learn whether anything listens runs the plugins, and a plugin may write something. Read the registry or the compiled chain instead. Neither runs a plugin.

Event.get(:event, name) reads the registry. It returns every plugin record of the event, whatever its status, and [] when there are none. A :stopped or :held plugin is in the list, but it is not in the chain.

The compiled module answers what Hook.call/3 would run on this node. Its initialize/0 returns the chain, and its mode/0 says whether the chain can run:

elixir
alias MishkaInstaller.Event.ModuleStateCompiler

def listening?(event) do
  module = ModuleStateCompiler.module_event_name(event)

  Code.ensure_loaded?(module) and function_exported?(module, :initialize, 0) and
    module.mode() == :ok and module.initialize().plugins != []
end

An event that was never compiled has no module, so the function answers false. An event that cannot run still lists its plugins in initialize/0, which is why the function reads mode/0 as well. Mishka CMS asks this way, without the mode/0 test, to learn whether anything is registered to store the submissions of a page form.

Hook.event_health/2 answers more. Its map has compiled?, mode and plugin_count, and it runs the health probe of every plugin, which can take as long as the probe timeout of one second. Use it for a dashboard, not for a check on every request.

What a failing plugin does

A plugin that raises, or returns something other than {:reply, state} or {:reply, :halt, state}, ends the call. The library logs plugin pipeline raised in event and returns the data you gave it. The work of the plugins that ran before the failure is not in the result. Design the caller so that unchanged data is a safe answer.

Next steps