Write a plugin for an event
v0.1.14
A plugin is a module that attaches to one event. When code calls that event with Hook.call/3, the plugin receives the data, may change it, and passes it on. This guide shows how to declare a plugin, set its place in the order, write call/1 or call/2, stop the chain when the plugin owns the answer, and start the plugin so it registers.
Before you begin
- Add
{:mishka_installer, "~> 0.1.14"}to the project that holds the plugin. A plugin can live in the application that fires the event or in a separate library. - Know the event name and the shape of the data it carries. The code that calls the event decides both. Mishka CMS names its events with colons, such as
runtime:page:seo, but the library accepts any string.
For the full list of options and generated functions, see the Hook reference.
Declare the plugin
Call use MishkaInstaller.Event.Hook and name the event. The module itself is the plugin name.
defmodule MishkaCms.Landing.Plugins.PageSeo do
use MishkaInstaller.Event.Hook, event: "runtime:page:seo", initial: %{priority: 60}
@impl true
def call(seo), do: {:reply, seo}
endCompilation stops with an error if the module names no event, or defines neither call/1 nor call/2.
Set the priority and the dependencies
The initial option takes a map with up to three keys:
priorityis an integer from0to100. The default is100. Plugins with a smaller number run first, and plugins with the same number run in order of module name. A value outside the range fails registration. The library logs the error and the plugin stays unregistered.dependsis a list of other plugin modules. A plugin always runs after the plugins it depends on, whatever their priorities, and it does not run while any of them is not active. A dependency cycle is refused at registration.extrais a list of maps stored with the plugin record. The library does not read it. Mishka CMS uses it to carry the label of an installable extension.
The library applies these three keys again on every boot, so a changed priority reaches installs that registered the plugin before. The stored status stays as it was. See Plugin statuses and dependencies for what happens to a plugin whose dependency stops.
Write call/1 or call/2
The first argument is the event's data. A call/2 plugin also receives meta, the read-only context the caller passed with meta:. It is nil when the caller passed none, so give call/2 a fallback clause.
Every plugin of an event sees every call. Answer the calls that are yours and hand every other one on untouched:
@impl true
def call(%{site_id: site_id, workspace: %{slug: slug}, actions: actions} = state)
when is_list(actions) do
case ChelekomDocsPage.chelekom_document?(site_id, slug) do
true -> {:reply, %{state | actions: actions ++ [action()]}}
false -> {:reply, state}
end
end
def call(state), do: {:reply, state}This is MishkaCms.Landing.Plugins.ChelekomVersions, a plugin on the document:workspace:admin_actions event. It adds one action to the list for one document and returns every other state as it came.
Stop the chain
A plugin returns one of two shapes:
{:reply, state}passesstateto the next plugin.{:reply, :halt, state}ends the chain. The later plugins do not run, andstatebecomes the result of the call.
Any other return value, and any exception, is logged as plugin pipeline raised, and the call answers the data it was first given. Halt only when your plugin owns the answer. The blog's SEO plugin owns the pages that show its own records:
def call(%{record: "blog:post"} = seo), do: {:reply, :halt, post(seo)}
def call(%{record: "blog:category"} = seo), do: {:reply, :halt, category(seo)}
def call(seo), do: {:reply, seo}This is MishkaBlog.Plugins.PageSeo, shortened. A plugin that halts should have a low priority number so it runs before the plugins it silences.
Start the plugin
use MishkaInstaller.Event.Hook makes the module a GenServer that restarts only after an abnormal exit. Put it in a supervision tree. The plugin registers itself when its process starts, so a plugin that nothing starts never reaches its event.
children = [
MyApp.Plugins.PageSeo
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)On the first start the plugin is registered and then started. On later boots it keeps the status stored for it: a plugin an operator stopped stays stopped.
Check that it registered
Read the plugin's record from the registry:
alias MishkaInstaller.Event.Event
case Event.get(:name, MyApp.Plugins.PageSeo) do
nil -> :not_registered
plugin -> plugin.status
endThe status is :started once the plugin is part of its event. It is :held when a dependency is not active. The call returns nil when the plugin has not registered, which means its process is not running, the library has not finished starting, or its registration failed and the log shows failed to register. The sample answers :not_registered then.
Example: a plugin that adds a card
Mishka CMS draws the cards of an account's security settings from the account:security:cards event. An extension adds its own card by answering with the list extended:
use MishkaInstaller.Event.Hook, event: "account:security:cards"
def call(%{cards: cards} = data),
do: {:reply, %{data | cards: cards ++ [%{id: "passkeys", priority: 30, module: MyApp.Passkeys}]}}The code that fires the event keeps the cards it trusts and filters the rest, so the plugin only adds.
Next steps
- Call an event and use its result: what the caller can pass and receive.
- Manage plugins while the app runs: stop, enable and restart.
- Override what a plugin does on start, restart and stop: run your own code around a plugin's start, stop and removal, and report its health.
- Test plugins and events: start the library in ExUnit, wait until it is ready and call the event.
- How events and plugins work: why a chain call is fast and where plugin code runs.