Call an event and use its result
v0.1.16
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.16"}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:
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.
args is a keyword list. Anything else raises an ArgumentError before any plugin runs.
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:
added =
case MishkaInstaller.Event.Hook.call(@event, %{cards: @core}) do
%{cards: cards} when is_list(cards) -> Enum.filter(cards, &card?/1)
_nothing -> []
endPass 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.
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. private is a map or a keyword list, and either merges into a map result or a keyword list result, also inside {:ok, _}. A map merged into a keyword list needs atom keys. For any other result shape, private is ignored. A private that is neither a map nor a keyword list raises an ArgumentError that names the option, in the caller.
Hook.call("transform_evt", %{n: 5}, private: %{tag: "x"})
# %{n: 12, tag: "x"} when the plugins turn n into 12Keep your input with return
When you pass return: true, the plugins still run, but the call returns the data exactly as you gave it. Use it for events that only announce something:
MishkaInstaller.Event.Hook.call("after_register", record,
meta: meta,
return: true
)With return: false, or with no return, the call returns what the plugins produced. Any other value raises an ArgumentError that names the option. In 0.1.15 and earlier every value but nil, false included, returned the input.
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:
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:
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, %{}}
endTo 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:
alias MishkaInstaller.Event.ModuleStateCompiler
def listening?(event) do
case ModuleStateCompiler.event_module(event) do
nil -> false
module -> module.mode() == :ok and module.initialize().plugins != []
end
endAn 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 chain, and the plugins after it do not run. The library logs a plugin of event ... raised: with the stacktrace, so the log shows which plugin failed, and the call returns the data you gave it, or the value of raised:. 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, or pass raised:.
Pass raised: where a failure must not look like an answer. It works with cannot_run:, so a caller can tell the three outcomes apart:
case Hook.call("import:item", item, cannot_run: :cannot_run, raised: :raised) do
:cannot_run -> {:skipped, "plugins_cannot_run"}
:raised -> {:skipped, "plugins_failed"}
%{} = item -> {:ok, item}
endraised: wins over return and private, and a call whose plugins all reply ignores it. An exit or a throw from a plugin, such as a GenServer.call/2 timeout inside its call/1, is not caught and reaches the caller.
Next steps
- Hook reference: every argument and the full table of result shapes.
- How events and plugins work: how the chain is built and run.
- Event reference: the registry functions, the compiled module and the errors.
- Manage plugins while the app runs: check health and profile a chain.