arcly_http/core/plugins.rs
1//! Third-party plugin ecosystem.
2//!
3//! A plugin owns a corner of the framework's behaviour without forking the
4//! core crate: register global routes, mutate the OpenAPI spec, attach
5//! global interceptors, spawn background tasks under graceful shutdown.
6//!
7//! ## Object safety
8//!
9//! `ArclyPlugin` uses the *erased-type* pattern: each lifecycle hook returns
10//! a `BoxFuture` instead of `async fn`, so the trait stays object-safe and
11//! we can hold `Vec<Box<dyn ArclyPlugin>>`.
12//!
13//! ## Opaque context
14//!
15//! `ArclyPluginContext` exposes a small, framework-typed API for the things
16//! plugins legitimately need to do. Axum / Tower types are private; the
17//! public methods all speak in arcly types (`RequestContext`, `Response`).
18//!
19//! ## Deferred construction
20//!
21//! Plugins register *handler factories* rather than routes directly. At
22//! launch time, once the frozen DI container exists, `App::launch` invokes
23//! each factory with the real `&'static FrozenDiContainer` and mounts the
24//! resulting axum route. This sidesteps any unsoundness from materialising
25//! the container before it's built.
26
27use std::fmt;
28
29use futures::future::BoxFuture;
30
31use crate::http::Response;
32
33use crate::core::engine::{FrozenDiContainer, HttpMethod};
34use crate::web::context::RequestContext;
35
36// ─── Errors ─────────────────────────────────────────────────────────────
37
38#[derive(Debug)]
39pub struct PluginError {
40 pub plugin: &'static str,
41 pub stage: PluginStage,
42 pub source: Box<dyn std::error::Error + Send + Sync>,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum PluginStage {
47 Init,
48 Start,
49 Shutdown,
50}
51
52impl PluginError {
53 pub fn new<E: Into<Box<dyn std::error::Error + Send + Sync>>>(
54 plugin: &'static str,
55 stage: PluginStage,
56 source: E,
57 ) -> Self {
58 Self {
59 plugin,
60 stage,
61 source: source.into(),
62 }
63 }
64}
65
66impl fmt::Display for PluginError {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 write!(
69 f,
70 "plugin `{}` failed at {:?}: {}",
71 self.plugin, self.stage, self.source
72 )
73 }
74}
75impl std::error::Error for PluginError {}
76
77// ─── The trait ──────────────────────────────────────────────────────────
78
79/// Plugin lifecycle.
80///
81/// All three hooks receive the framework's frozen DI container as a
82/// `&'static` ref (after init completes), so background tasks and shutdown
83/// drain routines can resolve injected services with zero locks. The init
84/// hook receives the *mutable* `ArclyPluginContext`, through which the
85/// plugin can `provide<T>` new singletons into the container before it
86/// freezes.
87pub trait ArclyPlugin: Send + Sync + 'static {
88 fn name(&self) -> &'static str;
89
90 /// **Pre-bind.** Register providers, routes, interceptors, openapi mutators.
91 /// The container is built AFTER every plugin's `on_init` returns.
92 fn on_init<'a>(
93 &'a mut self,
94 ctx: &'a mut ArclyPluginContext,
95 ) -> BoxFuture<'a, Result<(), PluginError>> {
96 let _ = ctx;
97 Box::pin(async { Ok(()) })
98 }
99
100 /// **Post-bind.** Listener is live and accepting. Background tasks spawn
101 /// here. `container` resolves any provider — including ones the plugin
102 /// itself registered in `on_init`.
103 fn on_start<'a>(
104 &'a self,
105 container: &'static FrozenDiContainer,
106 ) -> BoxFuture<'a, Result<(), PluginError>> {
107 let _ = container;
108 Box::pin(async { Ok(()) })
109 }
110
111 /// **Graceful shutdown.** Invoked **after** the HTTP server has stopped
112 /// accepting new connections and all in-flight requests have drained.
113 /// Wrapped by `App` in a configurable timeout (default 5s) so a hung
114 /// plugin can never wedge the process.
115 fn on_shutdown<'a>(
116 &'a self,
117 container: &'static FrozenDiContainer,
118 ) -> BoxFuture<'a, Result<(), PluginError>> {
119 let _ = container;
120 Box::pin(async { Ok(()) })
121 }
122}
123
124// ─── Handler factory + context ─────────────────────────────────────────
125
126/// A typed handler usable from plugin-registered routes. Takes a
127/// `RequestContext`, returns a `Response`.
128pub type PluginHandler =
129 std::sync::Arc<dyn Fn(RequestContext) -> BoxFuture<'static, Response> + Send + Sync>;
130
131#[doc(hidden)]
132pub struct PluginRoute {
133 pub method: HttpMethod,
134 pub path: &'static str,
135 pub handler: PluginHandler,
136}
137
138/// Mutable handle plugins receive during `on_init`.
139///
140/// `provide<T>` queues a singleton for the DI container, applied just before
141/// the container freezes. Once frozen the container is `&'static` and
142/// reads are lock-free for the lifetime of the process.
143pub(crate) type OpenApiMutator = Box<dyn FnOnce(&mut serde_json::Value) + Send + Sync>;
144pub(crate) type PendingProvider =
145 Box<dyn FnOnce(&mut crate::core::engine::DiContainerBuilder) + Send>;
146
147pub struct ArclyPluginContext {
148 pub(crate) extra_routes: Vec<PluginRoute>,
149 pub(crate) openapi_mutators: Vec<OpenApiMutator>,
150 pub(crate) global_interceptors: Vec<&'static dyn crate::web::interceptors::Interceptor>,
151 pub(crate) pending_providers: Vec<PendingProvider>,
152}
153
154impl ArclyPluginContext {
155 #[doc(hidden)]
156 pub fn new() -> Self {
157 Self {
158 extra_routes: Vec::new(),
159 openapi_mutators: Vec::new(),
160 global_interceptors: Vec::new(),
161 pending_providers: Vec::new(),
162 }
163 }
164
165 /// Inject a singleton of type `T` into the DI container. Resolves later
166 /// via `Inject<T>` in any controller / service / interceptor.
167 ///
168 /// Order: every plugin's `on_init` runs first, then all `provide<T>`
169 /// closures are applied in declaration order, *then* the container
170 /// freezes. So one plugin can read another's provision in `on_start`
171 /// but not in `on_init`.
172 pub fn provide<T: Send + Sync + 'static>(&mut self, value: T) {
173 self.pending_providers.push(Box::new(move |b| {
174 b.register(value);
175 }));
176 }
177
178 /// Register a route. The handler receives a fully-built `RequestContext`
179 /// — including DI access via `ctx.inject::<T>()` — and returns a
180 /// `Response`. The path is mounted verbatim under the application root.
181 pub fn add_route<F, Fut>(&mut self, method: HttpMethod, path: &'static str, handler: F)
182 where
183 F: Fn(RequestContext) -> Fut + Send + Sync + 'static,
184 Fut: std::future::Future<Output = Response> + Send + 'static,
185 {
186 let arc: PluginHandler = std::sync::Arc::new(move |ctx| Box::pin(handler(ctx)));
187 self.extra_routes.push(PluginRoute {
188 method,
189 path,
190 handler: arc,
191 });
192 }
193
194 /// Shortcut: register a GET.
195 pub fn add_get<F, Fut>(&mut self, path: &'static str, handler: F)
196 where
197 F: Fn(RequestContext) -> Fut + Send + Sync + 'static,
198 Fut: std::future::Future<Output = Response> + Send + 'static,
199 {
200 self.add_route(HttpMethod::GET, path, handler);
201 }
202
203 /// Mutate the assembled OpenAPI document at launch time.
204 pub fn modify_openapi<F>(&mut self, f: F)
205 where
206 F: FnOnce(&mut serde_json::Value) + Send + Sync + 'static,
207 {
208 self.openapi_mutators.push(Box::new(f));
209 }
210
211 /// Read a required environment variable.
212 ///
213 /// Returns `Err(PluginError)` with stage `Init` if the variable is absent
214 /// or contains non-UTF-8 bytes, so callers can propagate it cleanly with `?`.
215 pub fn require_env(&self, key: &'static str) -> Result<String, PluginError> {
216 std::env::var(key).map_err(|_| {
217 PluginError::new(
218 "ArclyPluginContext",
219 PluginStage::Init,
220 format!("required env var `{key}` is missing or not valid UTF-8"),
221 )
222 })
223 }
224
225 /// Read an environment variable with a fallback default.
226 pub fn env_or(&self, key: &str, default: impl Into<String>) -> String {
227 std::env::var(key).unwrap_or_else(|_| default.into())
228 }
229
230 /// Attach an interceptor that fires on **every** route registered via
231 /// the macros. Plugin routes opt in separately if they want it.
232 pub fn register_global_interceptor(
233 &mut self,
234 ic: &'static dyn crate::web::interceptors::Interceptor,
235 ) {
236 self.global_interceptors.push(ic);
237 }
238}
239
240impl Default for ArclyPluginContext {
241 fn default() -> Self {
242 Self::new()
243 }
244}
245
246// Route mounting moved to `crate::web::plugin_routes` so this module stays
247// HTTP-agnostic. Re-exported here to keep the existing import path working.
248pub use crate::web::plugin_routes::build_plugin_route;