From 0c732ba586e4e91156fea924901bf6ad82972fa8 Mon Sep 17 00:00:00 2001 From: Guido Tripaldi Date: Tue, 6 Aug 2019 10:16:27 +0200 Subject: [PATCH] Adds support for allowing PubSub subscription and events handling PubSub subscription requires the PID of the main process, but in the Commander the events callbacks are spawned asynchronously and a temporary PID is assigned to them, different from the main one. For this reason we need a synchronous callback to be called in the Commander where to put the call to the PubSub subscription. This new synchronous callback `onload_init`, is called just before calling the `onload` callback. The GenServer `handle_info` PubSub events are handled calling the `handle_info_message/2` callbacks, that is expected to be present in the Commander for any different combination of topic/message subscribed. The `handle_info_message/2` callback in the Commander expect a message parameter, identical to the standard `handle_info/2` one, and a socket parameter, instead of the GenServer `state` parameter. It haven't to return anything. The original `state` parameter will be returned by the internal handling of the GenServer/PubSub event. --- lib/drab.ex | 31 +++++ lib/drab/commander.ex | 110 +++++++++++++++++- lib/drab/commander/config.ex | 1 + test/integration/pubsub_test.exs | 33 ++++++ test/support/lib/backend.ex | 31 +++++ test/support/router.ex | 2 + .../web/commanders/pubsub_commander.ex | 21 ++++ .../web/controllers/element_controller.ex | 2 +- .../web/controllers/pubsub_controller.ex | 10 ++ .../web/templates/pubsub/index.html.drab | 6 + test/support/web/views/pubsub_view.ex | 5 + 11 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 test/integration/pubsub_test.exs create mode 100644 test/support/lib/backend.ex create mode 100644 test/support/web/commanders/pubsub_commander.ex create mode 100644 test/support/web/controllers/pubsub_controller.ex create mode 100644 test/support/web/templates/pubsub/index.html.drab create mode 100644 test/support/web/views/pubsub_view.ex diff --git a/lib/drab.ex b/lib/drab.ex index afa8e60..6528b7f 100644 --- a/lib/drab.ex +++ b/lib/drab.ex @@ -213,6 +213,15 @@ defmodule Drab do {:noreply, state} end + # pass any other handle_info to the Commander `handle_info_message/2` callbacks, + # which expect `message` and `socket` params (no `state` reply). + def handle_info(message, state) do + state.socket + |> get_commander() + |> apply(:handle_info_message, [message, state.socket]) + {:noreply, state} + end + @doc false @spec handle_cast(tuple, t) :: {:noreply, t} def handle_cast({:onconnect, socket, payload}, %Drab{commander: commander} = state) do @@ -240,10 +249,18 @@ defmodule Drab do def handle_cast({:onload, socket, payload}, %Drab{commander: commander} = state) do socket = transform_socket(payload["payload"], socket, state) + # onload_init sync call with arity/0 + handle_callback_sync(commander, commander_config(commander).onload_init) + + # onload async call onload = commander_config(commander).onload handle_callback(socket, commander, onload) for shared_commander <- state.commanders do + # onload_init sync call with arity/0 + handle_callback_sync(commander, commander_config(commander).onload_init) + + # onload async call onload = commander_config(shared_commander).onload handle_callback(socket, shared_commander, onload) end @@ -277,6 +294,7 @@ defmodule Drab do end end) + # Handles callbacks asynchronously @spec handle_callback(Phoenix.Socket.t(), atom, atom) :: Phoenix.Socket.t() defp handle_callback(socket, commander, callback) do if callback do @@ -294,6 +312,19 @@ defmodule Drab do socket end + # Handles callbacks synchronously + # Temporary disabled until it will be needed, for avoiding warning + # @spec handle_callback_sync(Phoenix.Socket.t(), atom, atom) :: Phoenix.Socket.t() + # defp handle_callback_sync(socket, commander, callback) do + # if callback, do: apply(commander, callback, [socket]) + # socket + # end + + @spec handle_callback_sync(atom, atom) :: any + defp handle_callback_sync(commander, callback) do + if callback, do: apply(commander, callback, []) + end + @spec transform_payload(map, t) :: map defp transform_payload(payload, state) do all_modules = DrabModule.all_modules_for(state.commander.__drab__().modules) diff --git a/lib/drab/commander.ex b/lib/drab/commander.ex index 98e113a..30c0923 100644 --- a/lib/drab/commander.ex +++ b/lib/drab/commander.ex @@ -132,6 +132,7 @@ defmodule Drab.Commander do defmodule DrabExample.PageCommander do use Drab.Commander + onload_init: do_init onload :page_loaded onconnect :connected ondisconnect :disconnected @@ -139,6 +140,14 @@ defmodule Drab.Commander do before_handler :check_status after_handler :clean_up, only: [:perform_long_process] + def do_init do + # notice that this callback is called synchronously, so + # it is not suitable for longer operations that could + # slow down the main process. + # Any operation here inherits the main process PID + ... + end + def page_loaded(socket) do ... end @@ -169,6 +178,14 @@ defmodule Drab.Commander do Launched every time client browser connects to the server, including reconnects after server crash, network broken etc + #### `onload_init` + Launched synchronously just before the `onload` callback. Unlike all the others callbacks + that runs in their own process, this callback runs in the main process, and therefore is useful + to call those operations who needs the main process PID to operate correctly, + as e.g. the subscription to PubSub events. + This callback is not suitable for hosting longer or harmful operations, since this + could slow down or broke the main process. Use with caution. + #### `onload` Launched only once after page loaded and connects to the server - exactly the same like `onconnect`, but launches only once, not after every reconnect @@ -191,6 +208,11 @@ defmodule Drab.Commander do Runs after the event handler. Gets return value of the event handler function as a third argument. Can be filtered by `:only` or `:except` options, analogically to `before_handler` + + ### `handle_info_message` + Launched every time a GenServer/PubSub event to which you have subscribed is fired. + See "PubSub support" for more informations. + ### Using callbacks to check user permissions Callbacks are handy for security. You may retrieve controller name and action name from the socket with `controller/1` and `action/1`. @@ -240,6 +262,92 @@ defmodule Drab.Commander do Drab injects function `render_to_string/2` into your Commander. It is a shorthand for `Phoenix.View.render_to_string/3` - Drab automatically chooses the current View. + ## PubSub support + + You can use the PubSub mechanism to automatically update your pages on data changes. + + Suppose for example you have a backend module that interfaces the database, and you + whishes to automatically update your page every time a change occours in a + database table by some operations made in other part of your application. You can + implement + + Suppose you need to automatically update your page every time a change occours in a + database table by some operations made in other part of your application. You can + implement a PubSub mechanism to react from the commander to any events you have + subscribed to: add the subscription to the desidered `PubSub` in the + `onload_init` event handler, then add a `handle_info_message/2` handler for any + event you need to react to. + + Please note that the `handle_info_message/2` handler is a simplified version of the + GenServer/PubSub `handle_info/2` handler, it expects a message parameter, identical + to the standard `handle_info/2` one, and a `socket` parameter, instead of the + `state` parameters, and it doesn't have to return anything. + + For example: + + # the backend: + defmodule MyApp.Backend do + # PubSub stuff + @topic inspect(__MODULE__) + + def subscribe() do + Phoenix.PubSub.subscribe(MyApp.PubSub, @topic) + end + + defp notify_subscribers({:ok, result}, event) do + Phoenix.PubSub.broadcast(MyApp.PubSub, @topic, {__MODULE__, event, result}) + {:ok, result} + end + defp notify_subscribers({:error, reason}, _event), do: {:error, reason} + + # CRUD stuff + ... + def create(...) do + ... + |> Repo.insert() + |> notify_subscribers([:data, :created]) + end + + def update(...) do + ... + |> Repo.update() + |> notify_subscribers([:data, :updated]) + end + + def delete(...) do + ... + |> Repo.delete() + |> notify_subscribers([:data, :deleted]) + end + end + + # the Commander + defmodule MyApp.Commander do + use Drab.Commander + + onload_init(:do_init) + + def do_init() do + # subscribe to any desidered PubSub + MyApp.Backend.subscribe() + end + + ... + + # handles the events + def handle_info_message({MyApp.Backend, [:data, :created], result}, socket) do + poke(socket, data: result) + end + + # handles the events + def handle_info_message({MyApp.Backend, [:data, :updated], result}, socket) do + poke(socket, data: result) + end + + ... + end + + ### Examples: buttons = render_to_string("waiter_example.html", []) @@ -379,7 +487,7 @@ defmodule Drab.Commander do end end - Enum.each([:onload, :onconnect, :ondisconnect], fn macro_name -> + Enum.each([:onload_init, :onload, :onconnect, :ondisconnect], fn macro_name -> @doc """ Sets up the callback for #{macro_name}. Receives handler function name as an atom. diff --git a/lib/drab/commander/config.ex b/lib/drab/commander/config.ex index 06ca1d1..5f725e3 100644 --- a/lib/drab/commander/config.ex +++ b/lib/drab/commander/config.ex @@ -4,6 +4,7 @@ defmodule Drab.Commander.Config do defstruct commander: nil, controller: nil, view: nil, + onload_init: nil, onload: nil, onconnect: nil, ondisconnect: nil, diff --git a/test/integration/pubsub_test.exs b/test/integration/pubsub_test.exs new file mode 100644 index 0000000..08be838 --- /dev/null +++ b/test/integration/pubsub_test.exs @@ -0,0 +1,33 @@ +defmodule DrabTestApp.PubsubTest do + import Drab.Live + use DrabTestApp.IntegrationCase + + defp pubsub_index do + pubsub_url(DrabTestApp.Endpoint, :index) + end + + setup do + pubsub_index() |> navigate_to() + # wait for the Drab to initialize + find_element(:id, "page_loaded_indicator") + [socket: drab_socket()] + end + + describe "PubSub" do + test "Subscribed PubSub events should trigger `handle_info_message` in the Commander" do + socket = drab_socket() + + # Changes some data in the db, this will trigger a PubSub event + DrabTestApp.Backend.append_element(42) + + # Above operation is async, so wait a little bit + # for its completion before checking the result + Process.sleep(500) + + # Check if the `handle_info_message` had changed the + # page assigns according to the new data + assert peek(socket, :status) == {:ok, "updated"} + assert peek(socket, :data) == {:ok, [1, 2, 3, 42]} + end + end +end diff --git a/test/support/lib/backend.ex b/test/support/lib/backend.ex new file mode 100644 index 0000000..d8ca7e7 --- /dev/null +++ b/test/support/lib/backend.ex @@ -0,0 +1,31 @@ +defmodule DrabTestApp.Backend do + @moduledoc false + + # PubSub support + @topic inspect(__MODULE__) + + def subscribe() do + Phoenix.PubSub.subscribe(DrabTestApp.PubSub, @topic) + end + + defp notify_subscribers({:ok, result}, event) do + Phoenix.PubSub.broadcast(DrabTestApp.PubSub, @topic, {__MODULE__, event, result}) + {:ok, result} + end + + defp notify_subscribers({:error, reason}, _event), do: {:error, reason} + + # Fake db access + @fake_db [1, 2, 3] + + def get_data() do + @fake_db + end + + def append_element(element) do + get_data() + |> List.insert_at(-1, element) + |> (&({:ok, &1})).() + |> notify_subscribers([:data, :updated]) + end +end \ No newline at end of file diff --git a/test/support/router.ex b/test/support/router.ex index bd607a8..e6322db 100644 --- a/test/support/router.ex +++ b/test/support/router.ex @@ -49,6 +49,8 @@ defmodule DrabTestApp.Router do get("/tests/live/broadcasting", LiveController, :broadcasting, as: :broadcasting) get("/tests/element", ElementController, :index, as: :element) + + get("/tests/pubsub", PubsubController, :index, as: :pubsub) end # Other scopes may use custom stacks. diff --git a/test/support/web/commanders/pubsub_commander.ex b/test/support/web/commanders/pubsub_commander.ex new file mode 100644 index 0000000..86844db --- /dev/null +++ b/test/support/web/commanders/pubsub_commander.ex @@ -0,0 +1,21 @@ +defmodule DrabTestApp.PubsubCommander do + @moduledoc false + + use Drab.Commander + + onload_init(:do_init) + onload(:page_loaded) + + def do_init() do + DrabTestApp.Backend.subscribe() + end + + def page_loaded(socket) do + DrabTestApp.IntegrationCase.add_page_loaded_indicator(socket) + DrabTestApp.IntegrationCase.add_pid(socket) + end + + def handle_info_message({DrabTestApp.Backend, [:data, :updated], result}, socket) do + poke(socket, status: "updated", data: result ) + end +end diff --git a/test/support/web/controllers/element_controller.ex b/test/support/web/controllers/element_controller.ex index cd0b466..27d4296 100644 --- a/test/support/web/controllers/element_controller.ex +++ b/test/support/web/controllers/element_controller.ex @@ -7,6 +7,6 @@ defmodule DrabTestApp.ElementController do require Logger def index(conn, _params) do - render(conn, "index.html") + render(conn, "index.html") end end diff --git a/test/support/web/controllers/pubsub_controller.ex b/test/support/web/controllers/pubsub_controller.ex new file mode 100644 index 0000000..d976792 --- /dev/null +++ b/test/support/web/controllers/pubsub_controller.ex @@ -0,0 +1,10 @@ +defmodule DrabTestApp.PubsubController do + @moduledoc false + + use DrabTestApp.Web, :controller + require Logger + + def index(conn, _params) do + render(conn, "index.html", status: "initilised", data: DrabTestApp.Backend.get_data()) + end +end diff --git a/test/support/web/templates/pubsub/index.html.drab b/test/support/web/templates/pubsub/index.html.drab new file mode 100644 index 0000000..5c0c2ac --- /dev/null +++ b/test/support/web/templates/pubsub/index.html.drab @@ -0,0 +1,6 @@ + + +
+
+ +
status_: <%= @status %> data: <%= inspect @data %>
diff --git a/test/support/web/views/pubsub_view.ex b/test/support/web/views/pubsub_view.ex new file mode 100644 index 0000000..e6deade --- /dev/null +++ b/test/support/web/views/pubsub_view.ex @@ -0,0 +1,5 @@ +defmodule DrabTestApp.PubsubView do + @moduledoc false + + use DrabTestApp.Web, :view +end