Rebus.Object behaviour (rebus v0.4.0)

View Source

A behaviour for exported objects, run as a GenServer.

Rebus.Service hands each call on an exported object to a handler process as a message, and leaves the process to answer it. use Rebus.Object packages that for the common case: start_link/3 starts a GenServer that exports the object and turns each call into a callback, answering it from the callback's return value.

Example

defmodule Calculator do
  use Rebus.Object

  @calculator Rebus.Interface.new!("org.example.Calculator",
                methods: [{"Add", in: [a: "i", b: "i"], out: [sum: "i"]}],
                properties: [{"Precision", "u", :readwrite}]
              )

  @impl true
  def interfaces, do: [@calculator]

  @impl true
  def init(precision), do: {:ok, %{precision: precision}}

  @impl true
  def handle_method(%{member: "Add"}, [a, b], state), do: {:reply, [a + b], state}

  @impl true
  def handle_get_property(_interface, "Precision", state),
    do: {:ok, state.precision, state}

  @impl true
  def handle_set_property(_interface, "Precision", precision, state) when precision <= 16,
    do: {:ok, %{state | precision: precision}}

  def handle_set_property(_interface, "Precision", _precision, state),
    do: {:error, "org.freedesktop.DBus.Error.InvalidArgs", "Precision above 16", state}
end

{:ok, conn} = Rebus.connect(:session)
{:ok, _pid} = Rebus.Object.start_link(Calculator, 2, conn: conn, path: "/org/example/Calculator")

use Rebus.Object also defines child_spec/1, which takes {arg, opts} as start_link/3 takes them, so the object can be supervised:

children = [{Calculator, {2, conn: conn, path: "/org/example/Calculator"}}]

The child is :transient. An object that crashes is restarted and exports itself again. An object whose connection stops exits with {:shutdown, :disconnected}, which a supervisor treats as a normal exit, so it is not restarted: a restart would try to export on the same stopped connection and fail.

:temporary is the only other sensible value, for an object that should never be restarted, such as one started on demand under a DynamicSupervisor. Avoid :permanent. It restarts an object after its connection stops, every restart fails, and the supervisor gives up after its restart limit and exits, taking its other children with it.

use Rebus.Object takes the keys of Supervisor.child_spec/0, such as :restart or :id, as use GenServer does.

Lifetime

The object exports itself before its init/1 runs, and its export lives as long as the process. When the process exits, the connection fails every call still waiting for its answer with org.freedesktop.DBus.Error.Failed, and when a supervisor restarts it, the new process exports the object again.

Exceptions in callbacks are not rescued. The process crashes, the caller receives Failed, and the exception's detail stays off the wire.

The object stops with {:shutdown, :disconnected} when its connection stops, since its export is gone with the connection.

Methods

Each call to a declared method reaches handle_method/3 with its arguments, which have the declared types. The callback answers it by returning {:reply, body, state} or {:error, name, message, state}. Returning {:noreply, state} leaves the call open, to be answered later by any process holding the Rebus.Service.Call:

@impl true
def handle_method(%{member: "SlowAdd"} = call, [a, b], state) do
  Task.start(fn -> Rebus.Service.reply(call, [a + b]) end)
  {:noreply, state}
end

A reply that cannot be sent while the call is still open, such as a body that does not fit the method's out-signature, is a bug in the object, which stops with {:invalid_reply, reason}. Replying to a call that was already answered or has expired, or a reply whose delivery timed out, is not a bug, and the object carries on.

Properties

Get reaches handle_get_property/3, whose value is typed by the property's declaration. GetAll calls it for every readable property of the interface, in name order, and answers with the values together, or with the first error returned. Set reaches handle_set_property/4 with the new value, already checked against the declared type.

After a successful Set, the object emits org.freedesktop.DBus.Properties.PropertiesChanged as the property's emits_changed asks: with the new value when it is true, with the property's name only when it is :invalidates, and not at all when it is false or :const. A write-only property is never announced, since that would broadcast a value no one may read. A callback that returns {:ok, state, emit: false} suppresses the signal, for example when it stores a value other than the one it was given, and can emit its own with Rebus.Service.properties_changed/6.

The callbacks cannot carry Unix file descriptors, so an object whose properties have a type containing h serves them through Rebus.Service instead.

An object that declares readable properties without defining handle_get_property/3, or writable ones without handle_set_property/4, answers those calls with org.freedesktop.DBus.Error.NotSupported.

Keeping your own loop

A GenServer that exports itself with Rebus.Service.export/4 can still map its calls to these callbacks by passing them to dispatch/4 from its own handle_info/2:

@impl GenServer
def handle_info({_ref, %Rebus.Service.Call{}} = call, state),
  do: Rebus.Object.dispatch(call, __MODULE__, state)

Such a module defines handle_method/3 and the property callbacks it needs as plain functions. It cannot declare @behaviour Rebus.Object beside GenServer, since both behaviours have init/1, handle_info/2 and terminate/2 callbacks.

Summary

Types

The object's own state, as its callbacks return it.

Callbacks

Reads the property property of interface, for Get and GetAll.

Handles any other message, as GenServer.handle_info/2 does.

Handles a call to a declared method, whose arguments are args.

Writes value to the property property of interface, for Set.

Initialises the object's state from the arg given to start_link/3.

The interfaces the object exports, unless start_link/3 is given :interfaces.

Called when the object stops, as GenServer.terminate/2 is.

Functions

Answers a call by passing it to module's callbacks with state.

Starts an object of module, exports it, and runs module.init(arg).

Types

method_result()

@type method_result() ::
  {:reply, body :: [term()], state()}
  | {:reply, body :: [term()], state(), [{:fds, [Rebus.Message.unix_fd()]}]}
  | {:error, name :: binary(), message :: binary(), state()}
  | {:noreply, state()}
  | {:stop, reason :: term(), state()}

What handle_method/3 returns.

state()

@type state() :: term()

The object's own state, as its callbacks return it.

Callbacks

handle_get_property(interface, property, state)

(optional)
@callback handle_get_property(interface :: binary(), property :: binary(), state()) ::
  {:ok, value :: term(), state()}
  | {:error, name :: binary(), message :: binary(), state()}

Reads the property property of interface, for Get and GetAll.

Only readable properties of exported interfaces reach this callback.

handle_info(msg, state)

(optional)
@callback handle_info(msg :: term(), state()) ::
  {:noreply, state()} | {:stop, reason :: term(), state()}

Handles any other message, as GenServer.handle_info/2 does.

handle_method(call, args, state)

@callback handle_method(call :: Rebus.Service.Call.t(), args :: [term()], state()) ::
  method_result()

Handles a call to a declared method, whose arguments are args.

  • {:reply, body, state} answers with body, encoded with the method's out-signature. A fourth element, fds: fds, sends descriptors with the reply, as Rebus.Service.reply/3 does.
  • {:error, name, message, state} answers with the D-Bus error name.
  • {:noreply, state} leaves the call open, for Rebus.Service.reply/3 or Rebus.Service.reply_error/4 to answer later, from any process.
  • {:stop, reason, state} stops the object, which fails the call.

handle_set_property(interface, property, value, state)

(optional)
@callback handle_set_property(
  interface :: binary(),
  property :: binary(),
  value :: term(),
  state()
) ::
  {:ok, state()}
  | {:ok, state(), [{:emit, boolean()}]}
  | {:error, name :: binary(), message :: binary(), state()}

Writes value to the property property of interface, for Set.

Only writable properties of exported interfaces reach this callback, and value has the declared type. {:ok, state} answers the call and emits PropertiesChanged as the property declares; {:ok, state, emit: false} answers without emitting it.

init(arg)

@callback init(arg :: term()) :: {:ok, state()} | {:stop, reason :: term()} | :ignore

Initialises the object's state from the arg given to start_link/3.

The object is already exported when this runs, so calls that arrive in the meantime wait in the mailbox. Returning {:stop, reason} or :ignore withdraws the export.

interfaces()

@callback interfaces() :: [Rebus.Interface.t()]

The interfaces the object exports, unless start_link/3 is given :interfaces.

terminate(reason, state)

(optional)
@callback terminate(reason :: term(), state()) :: term()

Called when the object stops, as GenServer.terminate/2 is.

Functions

dispatch(arg, module, state, opts \\ [])

@spec dispatch({reference(), Rebus.Service.Call.t()}, module(), state(), keyword()) ::
  {:noreply, state()} | {:stop, reason :: term(), state()}

Answers a call by passing it to module's callbacks with state.

This is what the process start_link/3 starts does with each call, for a GenServer that keeps its own loop. Give it the {ref, call} message the connection sent, and return its result from handle_info/2: either {:noreply, state} or {:stop, reason, state}. module defines handle_method/3, and handle_get_property/3 and handle_set_property/4 as its properties need.

Options

  • :interfaces - the exported interfaces, which property calls are typed by. Defaults to module.interfaces(), which is only called for a property call.

start_link(module, arg, opts)

@spec start_link(module(), term(), keyword()) :: GenServer.on_start()

Starts an object of module, exports it, and runs module.init(arg).

Options

  • :conn - the connection to export on. Required.
  • :path - the object path to export at. Required.
  • :interfaces - the interfaces to export. Defaults to module.interfaces().
  • :subtree, :call_timeout and :timeout - as for Rebus.Service.export/4. :timeout bounds the export, not the GenServer's init/1.
  • :name - a name for the process in the VM, as for GenServer.start_link/3: an atom, {:global, term} or {:via, module, term}. It is not a bus name; clients find the object by its :path, under a well-known name the connection requests with Rebus.Bus.request_name/4.
  • :debug, :spawn_opt and :hibernate_after - passed to GenServer.start_link/3.

Any other option raises ArgumentError.

Return values

As for GenServer.start_link/3, with {:error, reason} for any Rebus.Service.export/4 error, such as {:error, {:already_exported, path, interface}}.