Skip to content

Commit cffea5b

Browse files
committed
Add Mezzanine transport abstraction
1 parent 5157c12 commit cffea5b

6 files changed

Lines changed: 348 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
defmodule AppKit.Bridges.MezzanineBridge.Transport do
2+
@moduledoc """
3+
Caller-owned transport contract for AppKit calls into Mezzanine.
4+
5+
The same AppKit bridge can use an in-process Mezzanine facade, a local
6+
distributed facade, or deterministic fixtures without changing the product
7+
surface DTOs that AppKit owns.
8+
"""
9+
10+
@type result :: {:ok, map()} | {:error, map()}
11+
12+
@callback submit_work(request :: map(), opts :: keyword()) :: result()
13+
@callback readback(ref :: String.t(), opts :: keyword()) :: result()
14+
end
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
defmodule AppKit.Bridges.MezzanineBridge.Transport.Direct do
2+
@moduledoc """
3+
In-process Mezzanine transport for monolith mode.
4+
5+
The target facade is supplied explicitly through `:target` or `:module`.
6+
This keeps runtime authority out of ambient application environment.
7+
"""
8+
9+
@behaviour AppKit.Bridges.MezzanineBridge.Transport
10+
11+
@impl true
12+
def submit_work(request, opts) when is_map(request) and is_list(opts) do
13+
call(opts, :submit_work, [request, opts])
14+
end
15+
16+
@impl true
17+
def readback(ref, opts) when is_binary(ref) and is_list(opts) do
18+
call(opts, :readback, [ref, opts])
19+
end
20+
21+
defp call(opts, callback, args) do
22+
with {:ok, target} <- fetch_target(opts),
23+
{:ok, function} <- fetch_function(opts, callback),
24+
{:ok, apply_args} <- apply_args(target, function, args) do
25+
target
26+
|> apply(function, apply_args)
27+
|> normalize_result()
28+
end
29+
end
30+
31+
defp fetch_target(opts) do
32+
case Keyword.get(opts, :target, Keyword.get(opts, :module)) do
33+
target when is_atom(target) -> {:ok, target}
34+
_other -> {:error, error(:missing_direct_target)}
35+
end
36+
end
37+
38+
defp fetch_function(opts, callback) do
39+
function = Keyword.get(opts, function_option(callback), callback)
40+
41+
if is_atom(function) do
42+
{:ok, function}
43+
else
44+
{:error, error(:invalid_direct_function)}
45+
end
46+
end
47+
48+
defp apply_args(target, function, args) do
49+
args_without_opts = Enum.drop(args, -1)
50+
51+
cond do
52+
function_exported?(target, function, length(args)) ->
53+
{:ok, args}
54+
55+
function_exported?(target, function, length(args_without_opts)) ->
56+
{:ok, args_without_opts}
57+
58+
true ->
59+
{:error,
60+
error(:direct_target_unavailable, %{
61+
"target" => inspect(target),
62+
"function" => Atom.to_string(function)
63+
})}
64+
end
65+
end
66+
67+
defp function_option(:submit_work), do: :submit_work_function
68+
defp function_option(:readback), do: :readback_function
69+
70+
defp normalize_result({:ok, result}) when is_map(result), do: {:ok, result}
71+
defp normalize_result({:error, reason}) when is_map(reason), do: {:error, reason}
72+
defp normalize_result(result) when is_map(result), do: {:ok, result}
73+
74+
defp normalize_result(reason),
75+
do: {:error, error(:invalid_direct_response, %{"reason" => inspect(reason)})}
76+
77+
defp error(code, attrs \\ %{}) do
78+
Map.merge(%{"code" => Atom.to_string(code), "transport" => "direct"}, attrs)
79+
end
80+
end
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
defmodule AppKit.Bridges.MezzanineBridge.Transport.Distributed do
2+
@moduledoc """
3+
Local distributed Mezzanine transport for StackLab proof topology.
4+
5+
This module uses bounded `:erpc` calls to owner facades. It is a transport
6+
adapter, not a domain business API.
7+
"""
8+
9+
@behaviour AppKit.Bridges.MezzanineBridge.Transport
10+
11+
@default_timeout 5_000
12+
13+
@impl true
14+
def submit_work(request, opts) when is_map(request) and is_list(opts) do
15+
remote_call(opts, :submit_work, [request, opts])
16+
end
17+
18+
@impl true
19+
def readback(ref, opts) when is_binary(ref) and is_list(opts) do
20+
remote_call(opts, :readback, [ref, opts])
21+
end
22+
23+
defp remote_call(opts, callback, args) do
24+
with {:ok, node} <- fetch_node(opts),
25+
{:ok, module} <- fetch_module(opts),
26+
{:ok, function} <- fetch_function(opts, callback) do
27+
timeout = Keyword.get(opts, :timeout, @default_timeout)
28+
29+
try do
30+
node
31+
|> :erpc.call(module, function, args, timeout)
32+
|> normalize_result()
33+
rescue
34+
exception -> {:error, error(:unreachable, %{"reason" => Exception.message(exception)})}
35+
catch
36+
:exit, reason -> {:error, error(classify_exit(reason), %{"reason" => inspect(reason)})}
37+
end
38+
end
39+
end
40+
41+
defp fetch_node(opts) do
42+
case Keyword.get(opts, :node) do
43+
node when is_atom(node) -> {:ok, node}
44+
_other -> {:error, error(:missing_node)}
45+
end
46+
end
47+
48+
defp fetch_module(opts) do
49+
case Keyword.get(opts, :facade_module, Keyword.get(opts, :module)) do
50+
module when is_atom(module) -> {:ok, module}
51+
_other -> {:error, error(:missing_facade_module)}
52+
end
53+
end
54+
55+
defp fetch_function(opts, callback) do
56+
function = Keyword.get(opts, function_option(callback), callback)
57+
58+
if is_atom(function) do
59+
{:ok, function}
60+
else
61+
{:error, error(:invalid_remote_function)}
62+
end
63+
end
64+
65+
defp function_option(:submit_work), do: :submit_work_function
66+
defp function_option(:readback), do: :readback_function
67+
68+
defp normalize_result({:ok, result}) when is_map(result), do: {:ok, result}
69+
defp normalize_result({:error, reason}) when is_map(reason), do: {:error, reason}
70+
defp normalize_result(result) when is_map(result), do: {:ok, result}
71+
72+
defp normalize_result(reason),
73+
do: {:error, error(:invalid_remote_response, %{"reason" => inspect(reason)})}
74+
75+
defp classify_exit(reason) do
76+
reason
77+
|> inspect()
78+
|> String.downcase()
79+
|> String.contains?("timeout")
80+
|> case do
81+
true -> :timeout
82+
false -> :unreachable
83+
end
84+
end
85+
86+
defp error(code, attrs \\ %{}) do
87+
Map.merge(%{"code" => Atom.to_string(code), "transport" => "distributed"}, attrs)
88+
end
89+
end
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
defmodule AppKit.Bridges.MezzanineBridge.Transport.Fixture do
2+
@moduledoc """
3+
Deterministic AppKit-to-Mezzanine transport for tests.
4+
"""
5+
6+
@behaviour AppKit.Bridges.MezzanineBridge.Transport
7+
8+
@impl true
9+
def submit_work(request, opts) when is_map(request) and is_list(opts) do
10+
reply(opts, :submit_work, [request, opts], fn ->
11+
{:ok,
12+
%{
13+
"status" => "accepted",
14+
"accepted_ref" => Map.get(request, "idempotency_key", "work://fixture/accepted"),
15+
"correlation_ref" => Map.get(request, "correlation_ref", "correlation://fixture/appkit")
16+
}}
17+
end)
18+
end
19+
20+
@impl true
21+
def readback(ref, opts) when is_binary(ref) and is_list(opts) do
22+
reply(opts, :readback, [ref, opts], fn ->
23+
{:ok, %{"status" => "completed", "accepted_ref" => ref}}
24+
end)
25+
end
26+
27+
defp reply(opts, callback, args, default) do
28+
opts
29+
|> configured_response(callback)
30+
|> case do
31+
nil -> default.()
32+
fun when is_function(fun, length(args)) -> apply(fun, args)
33+
fun when is_function(fun, length(args) - 1) -> apply(fun, Enum.drop(args, -1))
34+
result -> result
35+
end
36+
|> normalize_result()
37+
end
38+
39+
defp configured_response(opts, callback) do
40+
responses = Keyword.get(opts, :responses, %{})
41+
42+
Keyword.get(opts, callback) || Map.get(responses, callback) ||
43+
Map.get(responses, Atom.to_string(callback))
44+
end
45+
46+
defp normalize_result({:ok, result}) when is_map(result), do: {:ok, result}
47+
defp normalize_result({:error, reason}) when is_map(reason), do: {:error, reason}
48+
defp normalize_result(result) when is_map(result), do: {:ok, result}
49+
50+
defp normalize_result(reason),
51+
do: {:error, error(:invalid_fixture_response, %{"reason" => inspect(reason)})}
52+
53+
defp error(code, attrs) do
54+
Map.merge(%{"code" => Atom.to_string(code), "transport" => "fixture"}, attrs)
55+
end
56+
end
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
defmodule AppKit.Bridges.MezzanineBridge.Transport.RuntimeDeps do
2+
@moduledoc """
3+
Explicit runtime dependency holder for AppKit's Mezzanine transport.
4+
"""
5+
6+
alias AppKit.Bridges.MezzanineBridge.Transport
7+
8+
defstruct transport: Transport.Direct, transport_opts: []
9+
10+
@type t :: %__MODULE__{transport: module(), transport_opts: keyword()}
11+
12+
@spec new(keyword()) :: {:ok, t()} | {:error, map()}
13+
def new(opts \\ []) when is_list(opts) do
14+
transport = Keyword.get(opts, :transport, Transport.Direct)
15+
transport_opts = Keyword.get(opts, :transport_opts, [])
16+
17+
with :ok <- validate_transport(transport),
18+
:ok <- validate_transport_opts(transport_opts) do
19+
{:ok, %__MODULE__{transport: transport, transport_opts: transport_opts}}
20+
end
21+
end
22+
23+
@spec submit_work(t(), map(), keyword()) :: Transport.result()
24+
def submit_work(%__MODULE__{} = deps, request, opts \\ [])
25+
when is_map(request) and is_list(opts) do
26+
deps.transport.submit_work(request, Keyword.merge(deps.transport_opts, opts))
27+
end
28+
29+
@spec readback(t(), String.t(), keyword()) :: Transport.result()
30+
def readback(%__MODULE__{} = deps, ref, opts \\ []) when is_binary(ref) and is_list(opts) do
31+
deps.transport.readback(ref, Keyword.merge(deps.transport_opts, opts))
32+
end
33+
34+
defp validate_transport(transport) when is_atom(transport) do
35+
case Code.ensure_loaded(transport) do
36+
{:module, ^transport} ->
37+
if function_exported?(transport, :submit_work, 2) and
38+
function_exported?(transport, :readback, 2) do
39+
:ok
40+
else
41+
{:error, %{"code" => "invalid_transport", "transport" => inspect(transport)}}
42+
end
43+
44+
_other ->
45+
{:error, %{"code" => "invalid_transport", "transport" => inspect(transport)}}
46+
end
47+
end
48+
49+
defp validate_transport(_transport), do: {:error, %{"code" => "invalid_transport"}}
50+
51+
defp validate_transport_opts(opts) when is_list(opts), do: :ok
52+
defp validate_transport_opts(_opts), do: {:error, %{"code" => "invalid_transport_opts"}}
53+
end
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
defmodule AppKit.Bridges.MezzanineBridge.TransportTest do
2+
use ExUnit.Case, async: true
3+
4+
alias AppKit.Bridges.MezzanineBridge.Transport
5+
6+
defmodule DirectTarget do
7+
def submit_work(request, opts) do
8+
{:ok, %{"mode" => "direct", "request" => request, "timeout" => opts[:timeout]}}
9+
end
10+
11+
def readback(ref), do: {:ok, %{"mode" => "direct", "ref" => ref}}
12+
end
13+
14+
test "direct transport calls an explicitly supplied in-process target" do
15+
assert {:ok, result} =
16+
Transport.Direct.submit_work(%{"work" => "one"}, target: DirectTarget, timeout: 25)
17+
18+
assert result["mode"] == "direct"
19+
assert result["timeout"] == 25
20+
21+
assert {:ok, %{"ref" => "work://1"}} =
22+
Transport.Direct.readback("work://1", target: DirectTarget)
23+
end
24+
25+
test "distributed transport calls an explicitly supplied owner facade" do
26+
assert {:ok, result} =
27+
Transport.Distributed.submit_work(%{"work" => "one"},
28+
node: Node.self(),
29+
facade_module: DirectTarget,
30+
timeout: 1_000
31+
)
32+
33+
assert result["mode"] == "direct"
34+
end
35+
36+
test "fixture transport is deterministic and overridable" do
37+
assert {:ok, %{"accepted_ref" => "idem-1"}} =
38+
Transport.Fixture.submit_work(%{"idempotency_key" => "idem-1"}, [])
39+
40+
assert {:ok, %{"status" => "custom"}} =
41+
Transport.Fixture.readback("work://1",
42+
responses: %{readback: fn ref -> {:ok, %{"status" => "custom", "ref" => ref}} end}
43+
)
44+
end
45+
46+
test "runtime deps select a transport explicitly" do
47+
assert {:ok, deps} =
48+
Transport.RuntimeDeps.new(
49+
transport: Transport.Fixture,
50+
transport_opts: [submit_work: {:ok, %{"accepted_ref" => "fixture://work"}}]
51+
)
52+
53+
assert {:ok, %{"accepted_ref" => "fixture://work"}} =
54+
Transport.RuntimeDeps.submit_work(deps, %{})
55+
end
56+
end

0 commit comments

Comments
 (0)