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.

elixir
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}
end

Compilation 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:

  • priority is an integer from 0 to 100. The default is 100. 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.
  • depends is 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.
  • extra is 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:

elixir
@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} passes state to the next plugin.
  • {:reply, :halt, state} ends the chain. The later plugins do not run, and state becomes 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:

elixir
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.

elixir
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:

elixir
alias MishkaInstaller.Event.Event

case Event.get(:name, MyApp.Plugins.PageSeo) do
  nil -> :not_registered
  plugin -> plugin.status
end

The 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:

elixir
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