From 984ac15084dcff4b8e696621c1afcfd19dddfce7 Mon Sep 17 00:00:00 2001 From: Maciej Szlosarczyk Date: Sat, 28 Feb 2026 16:19:35 +0200 Subject: [PATCH] Prepare to join servers --- lib/birdy_chat/application.ex | 2 + lib/birdy_chat/identity.ex | 7 ++- lib/birdy_chat/message.ex | 17 ++++++ lib/birdy_chat/peer_client.ex | 9 +++ lib/birdy_chat/peer_supervisor.ex | 17 ++++++ lib/birdy_chat_web/api/messages/controller.ex | 23 ++++++- lib/birdy_chat_web/channels/server_channel.ex | 7 +++ test/birdy_chat/identity_test.exs | 4 +- test/birdy_chat/peer_test.exs | 3 + test/birdy_chat_web/api/messages_test.exs | 61 ++++++++++++++++--- .../channels/server_channel_test.exs | 2 +- .../controllers/page_controller_test.exs | 2 +- 12 files changed, 137 insertions(+), 17 deletions(-) create mode 100644 lib/birdy_chat/peer_client.ex create mode 100644 lib/birdy_chat/peer_supervisor.ex create mode 100644 test/birdy_chat/peer_test.exs diff --git a/lib/birdy_chat/application.ex b/lib/birdy_chat/application.ex index 11c9fcb..dab31ec 100644 --- a/lib/birdy_chat/application.ex +++ b/lib/birdy_chat/application.ex @@ -10,6 +10,8 @@ defmodule BirdyChat.Application do children = [ BirdyChatWeb.Telemetry, BirdyChat.Identity, + BirdyChat.PeerSupervisor, + {Registry, keys: :unique, name: BirdyChat.PeerRegistry}, {DNSCluster, query: Application.get_env(:birdy_chat, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: BirdyChat.PubSub}, # Start a worker by calling: BirdyChat.Worker.start_link(arg) diff --git a/lib/birdy_chat/identity.ex b/lib/birdy_chat/identity.ex index 080386b..ae46c1d 100644 --- a/lib/birdy_chat/identity.ex +++ b/lib/birdy_chat/identity.ex @@ -49,7 +49,12 @@ defmodule BirdyChat.Identity do peers = System.get_env("BIRDY_CHAT_PEERS") case {identity, peers} do - {nil, nil} -> %__MODULE__{identity: "test", peers: [], mode: :singleton} + {nil, nil} -> + %__MODULE__{ + identity: "test1", + peers: %{"test2" => "http://localhost:4001"}, + mode: :test + } end end end diff --git a/lib/birdy_chat/message.ex b/lib/birdy_chat/message.ex index 3c86d43..7383466 100644 --- a/lib/birdy_chat/message.ex +++ b/lib/birdy_chat/message.ex @@ -19,4 +19,21 @@ defmodule BirdyChat.Message do {:error, changeset} end end + + def find_peer(%{to: to}) do + identity = BirdyChat.Identity.identity() + + if String.starts_with?(to, identity) do + {:ok, :local} + else + result = + BirdyChat.Identity.peers() + |> Enum.find(fn {name, _url} -> String.starts_with?(to, name) end) + + case result do + {name, _url} -> {:ok, name} + nil -> {:error, :not_found} + end + end + end end diff --git a/lib/birdy_chat/peer_client.ex b/lib/birdy_chat/peer_client.ex new file mode 100644 index 0000000..49ed51e --- /dev/null +++ b/lib/birdy_chat/peer_client.ex @@ -0,0 +1,9 @@ +defmodule BirdyChat.PeerClient do + use Slipstream, restart: :temporary + + def start_link(args) do + name = args[:peer_name] + name = {:via, BirdyChat.PeerRegistry, {:peers, name}} + Slipstream.start_link(__MODULE__, args, name: name) + end +end diff --git a/lib/birdy_chat/peer_supervisor.ex b/lib/birdy_chat/peer_supervisor.ex new file mode 100644 index 0000000..0439df8 --- /dev/null +++ b/lib/birdy_chat/peer_supervisor.ex @@ -0,0 +1,17 @@ +defmodule BirdyChat.PeerSupervisor do + use DynamicSupervisor + + def start_link(init_arg) do + DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + def start_child(peer_name, peer_address) do + spec = {BirdyChat.PeerClient, peer_name: peer_name, peer_address: peer_address} + DynamicSupervisor.start_child(__MODULE__, spec) + end + + @impl true + def init(init_arg) do + DynamicSupervisor.init(strategy: :one_for_one, extra_arguments: [init_arg]) + end +end diff --git a/lib/birdy_chat_web/api/messages/controller.ex b/lib/birdy_chat_web/api/messages/controller.ex index 79afd64..1c09fa5 100644 --- a/lib/birdy_chat_web/api/messages/controller.ex +++ b/lib/birdy_chat_web/api/messages/controller.ex @@ -4,11 +4,30 @@ defmodule BirdyChatWeb.Api.Messages.Controller do def create(conn, params) do case BirdyChat.Message.validate(params) do {:ok, changeset} -> - case BirdyChat.MessageWriter.write(changeset.changes) do - :ok -> + case BirdyChat.Message.find_peer(changeset.changes) do + {:ok, :local} -> + case BirdyChat.MessageWriter.write(changeset.changes) do + :ok -> + conn + |> put_status(:created) + |> render(:create, message: changeset.changes) + end + + {:ok, peer_name} -> + BirdyChatWeb.ServerChannel.broadcast!(peer_name, changeset.changes) + conn |> put_status(:created) |> render(:create, message: changeset.changes) + + {:error, :not_found} -> + changeset = + changeset + |> Ecto.Changeset.add_error(:to, "Unknown 'to' server") + + conn + |> put_status(:unprocessable_entity) + |> render(:error, changeset: changeset) end {:error, changeset} -> diff --git a/lib/birdy_chat_web/channels/server_channel.ex b/lib/birdy_chat_web/channels/server_channel.ex index cb83c2c..009aa1b 100644 --- a/lib/birdy_chat_web/channels/server_channel.ex +++ b/lib/birdy_chat_web/channels/server_channel.ex @@ -24,6 +24,13 @@ defmodule BirdyChatWeb.ServerChannel do end end + # This is how we send messages + def broadcast!(peer, message) do + channel = "server:#{peer}" + encoded_message = :erlang.term_to_binary(message) + BirdyChatWeb.Endpoint.broadcast!(channel, "new_message", {:binary, encoded_message}) + end + # Simple token-based authentication. Servers should use the same Phoenix secret key so they will # have the basis for generating tokens. defp authorised?(%{"token" => token}, server_id) do diff --git a/test/birdy_chat/identity_test.exs b/test/birdy_chat/identity_test.exs index 33bd710..e424e3a 100644 --- a/test/birdy_chat/identity_test.exs +++ b/test/birdy_chat/identity_test.exs @@ -1,5 +1,5 @@ defmodule BirdyChat.IdentityTest do - use BirdyChat.DataCase + use BirdyChat.DataCase, async: true setup do registry = start_supervised!({Registry, keys: :unique, name: __MODULE__}) @@ -11,7 +11,7 @@ defmodule BirdyChat.IdentityTest do name = {:via, Registry, {__MODULE__, "identity"}} {:ok, process} = BirdyChat.Identity.start_link(name: name) mode = BirdyChat.Identity.mode(process) - assert mode == :singleton + assert mode == :test end test "can be started by giving values like environment variables" do diff --git a/test/birdy_chat/peer_test.exs b/test/birdy_chat/peer_test.exs new file mode 100644 index 0000000..600db31 --- /dev/null +++ b/test/birdy_chat/peer_test.exs @@ -0,0 +1,3 @@ +defmodule BirdyChat.PeerTest do + use BirdyChat.DataCase, async: true +end diff --git a/test/birdy_chat_web/api/messages_test.exs b/test/birdy_chat_web/api/messages_test.exs index ec2b102..ba12e7d 100644 --- a/test/birdy_chat_web/api/messages_test.exs +++ b/test/birdy_chat_web/api/messages_test.exs @@ -1,10 +1,12 @@ defmodule BirdyChatWeb.Api.MessagesTest do - use BirdyChatWeb.ConnCase + use BirdyChatWeb.ConnCase, async: true + + import Phoenix.ChannelTest setup %{conn: conn} do url = ~p"/api/messages" - path = Application.app_dir(:birdy_chat, ["priv", "messages", "test-user.txt"]) + path = Application.app_dir(:birdy_chat, ["priv", "messages", "test1-user.txt"]) on_exit(fn -> File.rm(path) end) conn = @@ -14,6 +16,45 @@ defmodule BirdyChatWeb.Api.MessagesTest do %{conn: conn, url: url} end + describe "POST /api/messages to other server" do + setup do + server_id = "test2" + token = Phoenix.Token.sign(BirdyChatWeb.Endpoint, "serverAuth", server_id) + + {:ok, _, socket} = + BirdyChatWeb.ServerSocket + |> socket("server_socket", %{server_id: server_id}) + |> subscribe_and_join(BirdyChatWeb.ServerChannel, "server:#{server_id}", %{token: token}) + + %{socket: socket} + end + + test "returns error when a peer is unknown", %{conn: conn, url: url} do + message = %{from: "test1-user", to: "fake-user", message: "123"} + + payload = Jason.encode!(message) + conn = post(conn, url, payload) + assert result = json_response(conn, :unprocessable_entity) + + expected_result = %{"errors" => %{"to" => ["Unknown 'to' server"]}} + + assert result == expected_result + end + + test "broadcasts message to websocket", %{conn: conn, url: url} do + message = %{from: "test1-user", to: "test2-user", message: "123"} + string_message = %{"from" => "test1-user", "message" => "123", "to" => "test2-user"} + + payload = Jason.encode!(message) + conn = post(conn, url, payload) + assert result = json_response(conn, :created) + assert result == string_message + + assert_broadcast "new_message", {:binary, encoded_message} + assert :erlang.binary_to_term(encoded_message, [:safe]) == message + end + end + describe "POST /api/messages" do test "returns errors for invalid message", %{conn: conn, url: url} do payload = Jason.encode!(%{}) @@ -32,7 +73,7 @@ defmodule BirdyChatWeb.Api.MessagesTest do end test "returns message and 201 when successful", %{conn: conn, url: url} do - message = %{"from" => "2-user", "to" => "1-user", "message" => "123"} + message = %{"from" => "test2-user", "to" => "test1-user", "message" => "123"} payload = Jason.encode!(message) conn = post(conn, url, payload) assert result = json_response(conn, :created) @@ -40,30 +81,30 @@ defmodule BirdyChatWeb.Api.MessagesTest do end test "writes message to file", %{conn: conn, url: url} do - message = %{"from" => "2-user", "to" => "test-user", "message" => "123"} + message = %{"from" => "test2-user", "to" => "test1-user", "message" => "123"} payload = Jason.encode!(message) conn = post(conn, url, payload) assert result = json_response(conn, :created) assert result == message - path = Application.app_dir(:birdy_chat, ["priv", "messages", "test-user.txt"]) + path = Application.app_dir(:birdy_chat, ["priv", "messages", "test1-user.txt"]) contents = File.read!(path) - assert contents == "2-user: 123\n" + assert contents == "test2-user: 123\n" end test "appends message to file", %{conn: conn, url: url} do - message = %{"from" => "2-user", "to" => "test-user", "message" => "123"} + message = %{"from" => "test2-user", "to" => "test1-user", "message" => "123"} payload = Jason.encode!(message) post(conn, url, payload) - message = %{"from" => "2-user", "to" => "test-user", "message" => "456"} + message = %{"from" => "test2-user", "to" => "test1-user", "message" => "456"} payload = Jason.encode!(message) conn = post(conn, url, payload) assert json_response(conn, :created) - path = Application.app_dir(:birdy_chat, ["priv", "messages", "test-user.txt"]) + path = Application.app_dir(:birdy_chat, ["priv", "messages", "test1-user.txt"]) contents = File.read!(path) - assert contents == "2-user: 123\n2-user: 456\n" + assert contents == "test2-user: 123\ntest2-user: 456\n" end end end diff --git a/test/birdy_chat_web/channels/server_channel_test.exs b/test/birdy_chat_web/channels/server_channel_test.exs index 5a4fe8d..e57613d 100644 --- a/test/birdy_chat_web/channels/server_channel_test.exs +++ b/test/birdy_chat_web/channels/server_channel_test.exs @@ -1,5 +1,5 @@ defmodule BirdyChatWeb.ServerChannelTest do - use BirdyChatWeb.ChannelCase + use BirdyChatWeb.ChannelCase, async: true setup do server_id = "test2" diff --git a/test/birdy_chat_web/controllers/page_controller_test.exs b/test/birdy_chat_web/controllers/page_controller_test.exs index dd8766c..5b5e8c5 100644 --- a/test/birdy_chat_web/controllers/page_controller_test.exs +++ b/test/birdy_chat_web/controllers/page_controller_test.exs @@ -1,5 +1,5 @@ defmodule BirdyChatWeb.PageControllerTest do - use BirdyChatWeb.ConnCase + use BirdyChatWeb.ConnCase, async: true test "GET /", %{conn: conn} do conn = get(conn, ~p"/")