VerifiedPubSub.Subscriber (VerifiedPubSub v0.1.0)

Copy Markdown View Source

Declares that a module subscribes to topics of a manifest, and defines its handlers.

defmodule MyAppWeb.PetsLive do
  use MyAppWeb, :live_view
  use VerifiedPubSub.Subscriber, manifest: MyApp.Topics

  def mount(_params, _session, socket) do
    if connected?(socket), do: subscribe(:pets, %{owner_id: socket.assigns.id})
    {:ok, socket}
  end

  handle_message :pets, :adopted, payload, socket do
    {:noreply, stream_insert(socket, :pets, payload)}
  end

  ignore_message :pets, :rehomed
end

The handle_message/5 and ignore_message/2 calls of the module set the topics that it subscribes to. You keep no separate list of topics. The module must handle or dismiss each event that those topics declare, or the module does not compile.

use also imports VerifiedPubSub.Api. The module can then call subscribe/2, broadcast!/4, and the other macros without a separate use VerifiedPubSub, manifest: ....

Generated code

use generates two handle_info/2 clauses at the use site. The first clause matches a VerifiedPubSub.Message from the manifest of this module. It passes the message to private __verified_pubsub_dispatch__/4 clauses, which the module defines at @before_compile. The second clause matches a message from any other manifest and raises VerifiedPubSub.ManifestMismatchError. See "Messages from another manifest" below.

handle_message/5 therefore generates no code of its own. It adds the clause to a module attribute. The coverage check needs every clause, and the module has every clause only at @before_compile. The attribute does not exist to avoid the clause-grouping warning of Elixir, as an earlier version of this documentation said. A clause that a macro generates carries generated: true metadata, and Elixir gives no such warning for it.

The handle_info/2 clauses come from the use site, not from @before_compile, so your own handle_info/2 clauses still work. Elixir tries your clauses after the generated clauses. A catch-all clause of yours thus receives each message that is not verified, and verified messages still reach the generated clauses.

Messages from another manifest

An application can declare more than one manifest. Two manifests can produce the same wire topic when they share a :pubsub and declare topics that build the same string. The disjointness check works only within one manifest, because a manifest cannot see the other manifests while it compiles.

In that case, Phoenix.PubSub delivers the message, and its topic and event atoms can match a clause here. But its payload follows the field declarations of the sender. The compiler never checked that payload against the handler. Thus the generated clause raises VerifiedPubSub.ManifestMismatchError, and the handler does not run.

That clause comes before each handle_info/2 clause that you write, on purpose. A catch-all clause of yours thus does not receive a message from another manifest. A catch-all clause is the documented way to ignore unrelated messages. If it received this message, nothing would ever report the collision.

For full isolation, give each manifest its own Phoenix.PubSub. Each instance has its own registry, so a message never goes from one instance to the other:

children = [
  Supervisor.child_spec({Phoenix.PubSub, name: Pets.PubSub}, id: :pets_pubsub),
  Supervisor.child_spec({Phoenix.PubSub, name: Treats.PubSub}, id: :treats_pubsub)
]

Which modules can bind which manifest is a separate question of dependency boundaries. A manifest is a plain module, so mix xref or the boundary package can enforce those boundaries better than this library can.

Messages this module does not expect

A module that defines a handle_info/2 clause loses the default clause that use GenServer and use Phoenix.LiveView add. An unexpected message then raises FunctionClauseError, and nothing logs it. Each GenServer with its own handle_info/2 has this behavior. This module cannot prevent it: Elixir 1.20 made super/2 for GenServer callbacks an error, so no code can call the default body.

When your process receives messages that are not verified, add your own catch-all clause:

handle_message :pets, :adopted, payload, state do
  {:noreply, state}
end

# Matched after the generated clauses, so verified messages still reach them.
def handle_info(_other, state), do: {:noreply, state}

Summary

Functions

Handles one event on one topic.

Handles one event on one topic, and matches the params of the topic.

Declares that this module does nothing with an event, or with a list of events.

Functions

handle_message(topic, event, pattern, state, list)

(macro)

Handles one event on one topic.

pattern matches the payload of the message. When pattern is a %VerifiedPubSub.Message{} pattern in the source, it matches the whole message instead, so it can match on params.

handle_message(topic, params, event, pattern, state, list)

(macro)

Handles one event on one topic, and matches the params of the topic.

The first arguments are the arguments of broadcast!/4, in the same order, but here they match and do not build. Unlike a broadcast, the pattern can name a subset of the params. A param that the topic does not declare is a compile error, because that pattern can never match.

Usage

broadcast!     :pets, %{owner_id: id},    :adopted, payload
handle_message :pets, %{owner_id: owner}, :adopted, payload, socket

The clause binds owner from the params, without a match on the whole message. A literal limits the clause to that value:

handle_message :pets, %{owner_id: "7"}, :adopted, payload, socket

ignore_message(topic, event_or_events)

(macro)

Declares that this module does nothing with an event, or with a list of events.

ignore_message :pets, :rehomed
ignore_message :pets, [:renamed, :rehomed]

The call satisfies the coverage check without a handler. The generated clause returns {:noreply, state}, which is correct for both a GenServer and a LiveView.