VerifiedPubSub (VerifiedPubSub v0.1.0)

Copy Markdown View Source

Compile-time verified PubSub.

Usually, a broadcast and its handler are two string literals in two files, and nothing connects them. When you rename or delete an event, dead handlers and unhandled messages stay behind, and nothing reports them. VerifiedPubSub makes a manifest the one place that declares each topic and event. A change that breaks a call site is then a compile error.

The manifest

defmodule MyApp.Topics do
  use VerifiedPubSub.Manifest, pubsub: MyApp.PubSub

  topic :pets, ["owners", :owner_id, "pets"] do
    message :adopted do
      field :id, :string
      field :name, :string
    end

    message :rehomed do
      field :id, :string
    end
  end
end

:pets is the name that call sites use. The list is the wire topic, so a change to its segments does not change a call site. The segments are joined with :. A string is a literal and an atom is a param. The params come from the segments, so you do not declare them a second time.

Broadcasting

defmodule MyApp.Pets do
  use VerifiedPubSub, manifest: MyApp.Topics

  def create(attrs) do
    # ...
    broadcast!(:pets, %{owner_id: attrs.owner_id}, :adopted, payload)
  end
end

use VerifiedPubSub, manifest: ... imports VerifiedPubSub.Api. The topic and the event are normal arguments, but these are macros, so they must be literal atoms. Because of this, a typo is a compile error. Params can be built at runtime.

A topic with no params takes no params argument, as in broadcast!(:system, :alert, payload) and subscribe(:system). An explicit %{} also works. When a topic takes params and the call site omits them, the compile error names them.

A topic is always followed immediately by its params. The params only fill in the topic segments, so {topic, params} is the address and {event, payload} is the message. Phoenix.PubSub.broadcast/3 uses the same order: first the address, then the message.

To skip the sender, use broadcast_from!/5. It works like Phoenix.PubSub.broadcast_from/4, and from comes first, as it does there:

broadcast_from!(self(), :pets, %{owner_id: id}, :adopted, payload)

Subscribing

defmodule MyApp.Worker do
  use GenServer
  use VerifiedPubSub.Subscriber, manifest: MyApp.Topics

  def init(owner_id) do
    :ok = subscribe(:pets, %{owner_id: owner_id})
    {:ok, owner_id}
  end

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

  ignore_message :pets, :rehomed
end

A subscriber must handle or explicitly ignore each event that a subscribed topic declares. Subscribers often need only some of the events of a topic, so ignore_message/2 exists. Without it, the coverage check would be too strict to use.

To match on topic params, put them where broadcast!/4 takes them: right after the topic. The leading arguments are the same in both. A broadcast builds them, and a handler matches them:

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

The params argument is optional, and a pattern can name a subset of the params. A pattern can also match the whole %VerifiedPubSub.Message{} in the payload position, to get the other data that the message carries.

What is and is not checked

Each of these is a compile error:

  • a subscriber that does not handle or ignore each event of a topic that it subscribes to

  • a subscriber that handles an event that the manifest does not declare, or names a topic that the manifest does not declare

  • duplicate topics, duplicate events on one topic, and topic segments that are not valid

  • two topics whose segments can match the same wire topic

  • a broadcast to an unknown topic, with an unknown event, or with an event of a different topic

  • a literal params map with missing or unexpected keys

The compiler cannot check a params map built at runtime. Instead, Map.fetch!/2 raises KeyError for a missing key.

The library also checks payload shapes. It checks a literal payload map at compile time. It checks a payload built at runtime on every broadcast, in every environment. Both broadcast/4 and broadcast!/4 raise VerifiedPubSub.PayloadError. See "Payload shapes" below.

Not checked:

  • Topic param values. The coverage check counts each {topic, event} pair. If every clause for an event matches only some param values, the event still counts as covered. A message with a different value then raises FunctionClauseError. When clauses match on param values, end with a clause that matches any value.

Payload shapes

Each field declares a key that the payload must carry:

message :adopted do
  field :id, :string
  field :pet, MyApp.Pet
  field :tags, {:list, :string}
  field :note, :string, required: false
end

A type is one of :string, :integer, :float, :boolean, :atom, :map, :list, :any, a {:list, type} tuple, or a struct module. An unknown type is a compile error.

The payload is a map of exactly the declared fields. The library rejects undeclared keys, so the manifest stays an accurate description of the data on the wire. For the same reason, a struct cannot be the payload: it carries __struct__ and all of its own keys. Put the struct in a field instead:

broadcast!(:pets, %{owner_id: id}, :adopted, %{pet: pet})

required: false lets the key be absent, or present as nil.

Topic segments

A topic is a list of segments joined with :, so each param always fills a whole segment. A literal segment cannot be empty or contain :. Each call checks the param values. A value must not be empty and must not contain :, or the call raises VerifiedPubSub.TopicError. The manifest also makes sure that no two topics match the same wire topic. Together, these checks make sure that a wire topic built from params is always the topic that its call site names. See VerifiedPubSub.Topic.

Transport

Broadcasts and subscriptions go through Phoenix.PubSub, so :pubsub names a Phoenix.PubSub that your supervision tree starts:

children = [{Phoenix.PubSub, name: MyApp.PubSub}]

This library has no adapter layer, on purpose. Phoenix.PubSub already has an adapter behaviour, and you configure PG2, Redis, and other transports there. A wrapper here would repeat an extension point that the layer below already has. You would then configure the transport in two places.

Summary

Functions

Imports the atom-first macros in VerifiedPubSub.Api, bound to manifest.

Functions

__using__(opts)

(macro)

Imports the atom-first macros in VerifiedPubSub.Api, bound to manifest.