feat: Official plug API
This commit is contained in:
19
README.md
19
README.md
@@ -137,6 +137,8 @@ The intended in-process surface is `Parrhesia.API.*`, especially:
|
|||||||
- `Parrhesia.API.Admin` for management operations
|
- `Parrhesia.API.Admin` for management operations
|
||||||
- `Parrhesia.API.Identity`, `Parrhesia.API.ACL`, and `Parrhesia.API.Sync` for relay identity, protected sync ACLs, and outbound relay sync
|
- `Parrhesia.API.Identity`, `Parrhesia.API.ACL`, and `Parrhesia.API.Sync` for relay identity, protected sync ACLs, and outbound relay sync
|
||||||
|
|
||||||
|
For host-managed HTTP/WebSocket ingress mounting, use `Parrhesia.Plug`.
|
||||||
|
|
||||||
Start with:
|
Start with:
|
||||||
|
|
||||||
- [`docs/LOCAL_API.md`](./docs/LOCAL_API.md) for the embedding model and a minimal host setup
|
- [`docs/LOCAL_API.md`](./docs/LOCAL_API.md) for the embedding model and a minimal host setup
|
||||||
@@ -144,17 +146,30 @@ Start with:
|
|||||||
|
|
||||||
Important caveats for host applications:
|
Important caveats for host applications:
|
||||||
|
|
||||||
- Parrhesia is still alpha; expect some public API and config churn.
|
- Parrhesia is pre-beta; expect some public API and config churn while we prepare for beta.
|
||||||
- Parrhesia currently assumes a single runtime per BEAM node and uses globally registered process names.
|
- Parrhesia currently assumes a single runtime per BEAM node and uses globally registered process names.
|
||||||
- The defaults in this repo's `config/*.exs` are not imported automatically when Parrhesia is used as a dependency. A host app must set `config :parrhesia, ...` explicitly.
|
- The defaults in this repo's `config/*.exs` are not imported automatically when Parrhesia is used as a dependency. A host app must set `config :parrhesia, ...` explicitly.
|
||||||
- The host app is responsible for migrating Parrhesia's schema, for example with `Parrhesia.Release.migrate()` or `mix ecto.migrate -r Parrhesia.Repo`.
|
- The host app is responsible for migrating Parrhesia's schema, for example with `Parrhesia.Release.migrate()` or `mix ecto.migrate -r Parrhesia.Repo`.
|
||||||
|
|
||||||
If you only want the in-process API and not the HTTP/WebSocket edge, configure:
|
### Official embedding boundary
|
||||||
|
|
||||||
|
For embedded use, the stable boundaries are:
|
||||||
|
|
||||||
|
- `Parrhesia.API.*` for in-process publish/query/admin/sync operations
|
||||||
|
- `Parrhesia.Plug` for host-managed HTTP/WebSocket ingress mounting
|
||||||
|
|
||||||
|
If your host app owns the public HTTPS endpoint, keep this as the baseline runtime config:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
config :parrhesia, :listeners, %{}
|
config :parrhesia, :listeners, %{}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `listeners: %{}` disables Parrhesia-managed HTTP/WebSocket ingress (`/relay`, `/management`, `/metrics`, etc.).
|
||||||
|
- Mount `Parrhesia.Plug` in your host endpoint/router when you still want Parrhesia ingress under the host's single HTTPS surface.
|
||||||
|
- `Parrhesia.Web.*` modules remain internal runtime wiring. Use `Parrhesia.Plug` as the documented mount API.
|
||||||
|
|
||||||
The config reference below still applies when embedded. That is the primary place to document basic setup and runtime configuration changes.
|
The config reference below still applies when embedded. That is the primary place to document basic setup and runtime configuration changes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Parrhesia can run as a normal standalone relay application, but it also exposes a stable
|
Parrhesia can run as a normal standalone relay application, but it also exposes a stable
|
||||||
in-process API for Elixir callers that want to embed the relay inside a larger OTP system.
|
in-process API for Elixir callers that want to embed the relay inside a larger OTP system.
|
||||||
|
|
||||||
This document describes that embedding surface. The runtime is still alpha, so treat the API
|
This document describes that embedding surface. The runtime is pre-beta, so treat the API
|
||||||
as usable but not yet frozen.
|
as usable but not yet frozen.
|
||||||
|
|
||||||
## What embedding means today
|
## What embedding means today
|
||||||
@@ -14,6 +14,7 @@ Embedding currently means:
|
|||||||
- the host app provides `config :parrhesia, ...` explicitly
|
- the host app provides `config :parrhesia, ...` explicitly
|
||||||
- the host app migrates the Parrhesia database schema
|
- the host app migrates the Parrhesia database schema
|
||||||
- callers interact with the relay through `Parrhesia.API.*`
|
- callers interact with the relay through `Parrhesia.API.*`
|
||||||
|
- host-managed HTTP/WebSocket ingress is mounted through `Parrhesia.Plug`
|
||||||
|
|
||||||
Current operational assumptions:
|
Current operational assumptions:
|
||||||
|
|
||||||
@@ -63,9 +64,14 @@ config :parrhesia, ecto_repos: [Parrhesia.Repo]
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Set `listeners: %{}` if you only want the in-process API and no HTTP/WebSocket ingress.
|
- `listeners: %{}` is the official embedding pattern when your host app owns the HTTPS edge.
|
||||||
- If you do want ingress, copy the listener shape from the config reference in
|
- `listeners: %{}` disables Parrhesia-managed ingress (`/relay`, `/management`, `/metrics`, etc.).
|
||||||
[README.md](../README.md).
|
- Mount `Parrhesia.Plug` from the host app when you still want Parrhesia ingress behind that same
|
||||||
|
HTTPS edge.
|
||||||
|
- `Parrhesia.Web.*` modules are internal runtime wiring. Treat `Parrhesia.Plug` as the stable
|
||||||
|
mount API.
|
||||||
|
- If you prefer Parrhesia-managed ingress instead, copy the listener shape from the config
|
||||||
|
reference in [README.md](../README.md).
|
||||||
- Production runtime overrides still use the `PARRHESIA_*` environment variables described in
|
- Production runtime overrides still use the `PARRHESIA_*` environment variables described in
|
||||||
[README.md](../README.md).
|
[README.md](../README.md).
|
||||||
|
|
||||||
@@ -77,6 +83,27 @@ Parrhesia.Release.migrate()
|
|||||||
|
|
||||||
In development, `mix ecto.migrate -r Parrhesia.Repo` works too.
|
In development, `mix ecto.migrate -r Parrhesia.Repo` works too.
|
||||||
|
|
||||||
|
## Mounting `Parrhesia.Plug` from a host app
|
||||||
|
|
||||||
|
When `listeners: %{}` is set, you can still expose Parrhesia ingress by mounting `Parrhesia.Plug`
|
||||||
|
in your host endpoint/router and passing an explicit listener config:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
forward "/nostr", Parrhesia.Plug,
|
||||||
|
listener: %{
|
||||||
|
id: :public,
|
||||||
|
transport: %{scheme: :https, tls: %{mode: :proxy_terminated}},
|
||||||
|
proxy: %{trusted_cidrs: ["10.0.0.0/8"], honor_x_forwarded_for: true},
|
||||||
|
features: %{
|
||||||
|
nostr: %{enabled: true},
|
||||||
|
admin: %{enabled: true},
|
||||||
|
metrics: %{enabled: true, access: %{private_networks_only: true}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the same listener schema documented in [README.md](../README.md).
|
||||||
|
|
||||||
## Starting the runtime
|
## Starting the runtime
|
||||||
|
|
||||||
In the common case, letting OTP start the `:parrhesia` application is enough.
|
In the common case, letting OTP start the `:parrhesia` application is enough.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ defmodule Parrhesia do
|
|||||||
Parrhesia is a Nostr relay runtime that can run standalone or as an embedded OTP service.
|
Parrhesia is a Nostr relay runtime that can run standalone or as an embedded OTP service.
|
||||||
|
|
||||||
For embedded use, the main developer-facing surface is `Parrhesia.API.*`.
|
For embedded use, the main developer-facing surface is `Parrhesia.API.*`.
|
||||||
|
For host-managed HTTP/WebSocket ingress mounting, use `Parrhesia.Plug`.
|
||||||
Start with:
|
Start with:
|
||||||
|
|
||||||
- `Parrhesia.API.Events`
|
- `Parrhesia.API.Events`
|
||||||
@@ -11,6 +12,7 @@ defmodule Parrhesia do
|
|||||||
- `Parrhesia.API.Identity`
|
- `Parrhesia.API.Identity`
|
||||||
- `Parrhesia.API.ACL`
|
- `Parrhesia.API.ACL`
|
||||||
- `Parrhesia.API.Sync`
|
- `Parrhesia.API.Sync`
|
||||||
|
- `Parrhesia.Plug`
|
||||||
|
|
||||||
The host application is responsible for:
|
The host application is responsible for:
|
||||||
|
|
||||||
|
|||||||
113
lib/parrhesia/plug.ex
Normal file
113
lib/parrhesia/plug.ex
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
defmodule Parrhesia.Plug do
|
||||||
|
@moduledoc """
|
||||||
|
Official Plug interface for mounting Parrhesia HTTP/WebSocket ingress in a host app.
|
||||||
|
|
||||||
|
This plug serves the same route surface as the built-in listener endpoint:
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `GET /ready`
|
||||||
|
- `GET /relay` (NIP-11 + websocket transport)
|
||||||
|
- `POST /management`
|
||||||
|
- `GET /metrics`
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
* `:listener` - listener configuration used to authorize and serve requests.
|
||||||
|
Supported values:
|
||||||
|
* an atom listener id from `config :parrhesia, :listeners` (for example `:public`)
|
||||||
|
* a listener config map/keyword list (same schema as `:listeners` entries)
|
||||||
|
|
||||||
|
When a host app owns the HTTPS edge, a common pattern is:
|
||||||
|
|
||||||
|
config :parrhesia, :listeners, %{}
|
||||||
|
|
||||||
|
and mount `Parrhesia.Plug` with an explicit `:listener` map.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@behaviour Plug
|
||||||
|
|
||||||
|
alias Parrhesia.Web.Listener
|
||||||
|
alias Parrhesia.Web.Router
|
||||||
|
|
||||||
|
@type listener_option :: atom() | map() | keyword()
|
||||||
|
@type option :: {:listener, listener_option()}
|
||||||
|
|
||||||
|
@spec init([option()]) :: keyword()
|
||||||
|
@impl Plug
|
||||||
|
def init(opts) do
|
||||||
|
opts = Keyword.validate!(opts, listener: :public)
|
||||||
|
listener = opts |> Keyword.fetch!(:listener) |> resolve_listener!()
|
||||||
|
[listener: listener]
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec call(Plug.Conn.t(), keyword()) :: Plug.Conn.t()
|
||||||
|
@impl Plug
|
||||||
|
def call(conn, opts) do
|
||||||
|
conn
|
||||||
|
|> Listener.put_conn(opts)
|
||||||
|
|> Router.call([])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_listener!(listener_id) when is_atom(listener_id) do
|
||||||
|
listeners = Application.get_env(:parrhesia, :listeners, %{})
|
||||||
|
|
||||||
|
case lookup_listener_by_id(listeners, listener_id) do
|
||||||
|
nil ->
|
||||||
|
raise ArgumentError,
|
||||||
|
"listener #{inspect(listener_id)} not found in config :parrhesia, :listeners; " <>
|
||||||
|
"configure it there or pass :listener as a map"
|
||||||
|
|
||||||
|
listener ->
|
||||||
|
Listener.from_opts(listener: listener)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_listener!(listener) when is_map(listener) do
|
||||||
|
Listener.from_opts(listener: listener)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_listener!(listener) when is_list(listener) do
|
||||||
|
if Keyword.keyword?(listener) do
|
||||||
|
Listener.from_opts(listener: Map.new(listener))
|
||||||
|
else
|
||||||
|
raise ArgumentError,
|
||||||
|
":listener keyword list must be a valid keyword configuration"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_listener!(other) do
|
||||||
|
raise ArgumentError,
|
||||||
|
":listener must be an atom id, map, or keyword list, got: #{inspect(other)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp lookup_listener_by_id(listeners, listener_id) when is_map(listeners) do
|
||||||
|
case Map.fetch(listeners, listener_id) do
|
||||||
|
{:ok, listener} when is_map(listener) ->
|
||||||
|
Map.put_new(listener, :id, listener_id)
|
||||||
|
|
||||||
|
{:ok, listener} when is_list(listener) ->
|
||||||
|
listener |> Map.new() |> Map.put_new(:id, listener_id)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp lookup_listener_by_id(listeners, listener_id) when is_list(listeners) do
|
||||||
|
case Enum.find(listeners, fn
|
||||||
|
{id, _listener} -> id == listener_id
|
||||||
|
_other -> false
|
||||||
|
end) do
|
||||||
|
{^listener_id, listener} when is_map(listener) ->
|
||||||
|
Map.put_new(listener, :id, listener_id)
|
||||||
|
|
||||||
|
{^listener_id, listener} when is_list(listener) ->
|
||||||
|
listener |> Map.new() |> Map.put_new(:id, listener_id)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp lookup_listener_by_id(_listeners, _listener_id), do: nil
|
||||||
|
end
|
||||||
@@ -177,7 +177,7 @@ defmodule Parrhesia.Web.Listener do
|
|||||||
ip: listener.bind.ip,
|
ip: listener.bind.ip,
|
||||||
port: listener.bind.port,
|
port: listener.bind.port,
|
||||||
scheme: scheme,
|
scheme: scheme,
|
||||||
plug: {Parrhesia.Web.ListenerPlug, listener: listener}
|
plug: {Parrhesia.Plug, listener: listener}
|
||||||
] ++
|
] ++
|
||||||
TLS.bandit_options(listener.transport.tls) ++
|
TLS.bandit_options(listener.transport.tls) ++
|
||||||
[thousand_island_options: thousand_island_options] ++
|
[thousand_island_options: thousand_island_options] ++
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
defmodule Parrhesia.Web.ListenerPlug do
|
|
||||||
@moduledoc false
|
|
||||||
|
|
||||||
alias Parrhesia.Web.Listener
|
|
||||||
alias Parrhesia.Web.Router
|
|
||||||
|
|
||||||
def init(opts), do: opts
|
|
||||||
|
|
||||||
def call(conn, opts) do
|
|
||||||
conn
|
|
||||||
|> Listener.put_conn(opts)
|
|
||||||
|> Router.call([])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
1
mix.exs
1
mix.exs
@@ -109,6 +109,7 @@ defmodule Parrhesia.MixProject do
|
|||||||
],
|
],
|
||||||
Runtime: [
|
Runtime: [
|
||||||
Parrhesia,
|
Parrhesia,
|
||||||
|
Parrhesia.Plug,
|
||||||
Parrhesia.Release,
|
Parrhesia.Release,
|
||||||
Parrhesia.Runtime
|
Parrhesia.Runtime
|
||||||
]
|
]
|
||||||
|
|||||||
43
test/parrhesia/plug_test.exs
Normal file
43
test/parrhesia/plug_test.exs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
defmodule Parrhesia.PlugTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
import Plug.Test
|
||||||
|
|
||||||
|
alias Parrhesia.Plug
|
||||||
|
|
||||||
|
test "init resolves configured listener id" do
|
||||||
|
opts = Plug.init(listener: :public)
|
||||||
|
|
||||||
|
assert is_list(opts)
|
||||||
|
assert Keyword.fetch!(opts, :listener).id == :public
|
||||||
|
end
|
||||||
|
|
||||||
|
test "init accepts inline listener map" do
|
||||||
|
opts =
|
||||||
|
Plug.init(
|
||||||
|
listener: %{
|
||||||
|
id: :host_mount,
|
||||||
|
features: %{nostr: %{enabled: true}}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert Keyword.fetch!(opts, :listener).id == :host_mount
|
||||||
|
end
|
||||||
|
|
||||||
|
test "init raises for unknown listener id" do
|
||||||
|
assert_raise ArgumentError, ~r/listener :does_not_exist not found/, fn ->
|
||||||
|
Plug.init(listener: :does_not_exist)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "call serves health route" do
|
||||||
|
opts = Plug.init(listener: :public)
|
||||||
|
|
||||||
|
response =
|
||||||
|
conn(:get, "/health")
|
||||||
|
|> Plug.call(opts)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert response.resp_body == "ok"
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user