Skip to main content

arcly_http_core/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    /// **Drain started.** Fired as soon as a shutdown signal is received,
101    /// *concurrently* with the HTTP in-flight drain (it does not delay the
102    /// listener closing). Stop accepting new work here — pause message-queue
103    /// consumers, schedulers, pollers — so plugin workloads quiesce while
104    /// HTTP connections drain. Cleanup itself belongs in `on_shutdown`.
105    fn on_draining<'a>(
106        &'a self,
107        container: std::sync::Arc<FrozenDiContainer>,
108    ) -> BoxFuture<'a, Result<(), PluginError>> {
109        let _ = container;
110        Box::pin(async { Ok(()) })
111    }
112
113    /// **Post-bind.** Listener is live and accepting. Background tasks spawn
114    /// here. `container` resolves any provider — including ones the plugin
115    /// itself registered in `on_init`.
116    fn on_start<'a>(
117        &'a self,
118        container: std::sync::Arc<FrozenDiContainer>,
119    ) -> BoxFuture<'a, Result<(), PluginError>> {
120        let _ = container;
121        Box::pin(async { Ok(()) })
122    }
123
124    /// **Graceful shutdown.** Invoked **after** the HTTP server has stopped
125    /// accepting new connections and all in-flight requests have drained.
126    /// Wrapped by `App` in a per-plugin timeout (default 5s, configurable
127    /// via `LaunchConfig::drain_budget`) so a hung plugin can never wedge
128    /// the process or starve other plugins' shutdown.
129    ///
130    /// **Do not block.** The timeout can only cancel the future at an
131    /// `.await` point — a synchronous spin or blocking syscall here pins a
132    /// runtime worker thread until it returns. Use `spawn_blocking` for
133    /// unavoidable blocking cleanup.
134    fn on_shutdown<'a>(
135        &'a self,
136        container: std::sync::Arc<FrozenDiContainer>,
137    ) -> BoxFuture<'a, Result<(), PluginError>> {
138        let _ = container;
139        Box::pin(async { Ok(()) })
140    }
141}
142
143// ─── Handler factory + context ─────────────────────────────────────────
144
145/// A typed handler usable from plugin-registered routes. Takes a
146/// `RequestContext`, returns a `Response`.
147pub type PluginHandler =
148    std::sync::Arc<dyn Fn(RequestContext) -> BoxFuture<'static, Response> + Send + Sync>;
149
150#[doc(hidden)]
151pub struct PluginRoute {
152    pub method: HttpMethod,
153    pub path: &'static str,
154    pub handler: PluginHandler,
155    /// Name of the plugin that registered this route. Filled in by the
156    /// launch path after each `on_init` returns; used in collision errors.
157    pub plugin: &'static str,
158}
159
160/// Mutable handle plugins receive during `on_init`.
161///
162/// `provide<T>` queues a singleton for the DI container, applied just before
163/// the container freezes. Once frozen the container is `&'static` and
164/// reads are lock-free for the lifetime of the process.
165pub(crate) type OpenApiMutator = Box<dyn FnOnce(&mut serde_json::Value) + Send + Sync>;
166pub(crate) type PendingProvider =
167    Box<dyn FnOnce(&mut crate::core::engine::DiContainerBuilder) + Send>;
168
169pub struct ArclyPluginContext {
170    #[doc(hidden)]
171    pub extra_routes: Vec<PluginRoute>,
172    #[doc(hidden)]
173    pub openapi_mutators: Vec<OpenApiMutator>,
174    #[doc(hidden)]
175    pub global_interceptors: Vec<&'static dyn crate::web::interceptors::Interceptor>,
176    #[doc(hidden)]
177    pub boundary_filters: Vec<&'static dyn crate::web::boundary::BoundaryFilter>,
178    #[doc(hidden)]
179    pub pending_providers: Vec<PendingProvider>,
180    /// Name of the plugin whose `on_init` is currently running. Set by the
181    /// launch loop so errors raised through the context name the right plugin.
182    #[doc(hidden)]
183    pub current_plugin: &'static str,
184}
185
186impl ArclyPluginContext {
187    #[doc(hidden)]
188    pub fn new() -> Self {
189        Self {
190            extra_routes: Vec::new(),
191            openapi_mutators: Vec::new(),
192            global_interceptors: Vec::new(),
193            boundary_filters: Vec::new(),
194            pending_providers: Vec::new(),
195            current_plugin: "ArclyPluginContext",
196        }
197    }
198
199    /// Inject a singleton of type `T` into the DI container. Resolves later
200    /// via `Inject<T>` in any controller / service / interceptor.
201    ///
202    /// Order: every plugin's `on_init` runs first, then all `provide<T>`
203    /// closures are applied in declaration order, *then* the container
204    /// freezes. So one plugin can read another's provision in `on_start`
205    /// but not in `on_init`.
206    ///
207    /// If `T` already has an `#[Injectable]` provider, launch fails loudly —
208    /// use [`override_provider`](Self::override_provider) to replace it
209    /// intentionally.
210    pub fn provide<T: Send + Sync + 'static>(&mut self, value: T) {
211        self.pending_providers.push(Box::new(move |b| {
212            b.register(value);
213        }));
214    }
215
216    /// Replace a core `#[Injectable]` provider with this instance. The
217    /// descriptor is discarded; every `Inject<T>` in the app resolves to the
218    /// plugin's value. Happens before the container freezes, so the swap is
219    /// race-free and the hot path stays lock-free.
220    pub fn override_provider<T: Send + Sync + 'static>(&mut self, value: T) {
221        self.pending_providers.push(Box::new(move |b| {
222            b.register_override(value);
223        }));
224    }
225
226    /// Attach a boundary filter that runs on **every** request *before the
227    /// body is read* — the cheap early-reject point for signature checks,
228    /// IP allowlists, and rate limits. Returning `Break(resp)` short-circuits
229    /// without paying for body buffering or context assembly.
230    /// Ergonomic twin of [`Self::register_global_interceptor`]: takes ownership
231    /// and leaks internally (interceptors are process-lifetime objects) —
232    /// no `Box::leak` ceremony in plugin code.
233    pub fn add_interceptor(&mut self, ic: impl crate::web::interceptors::Interceptor) {
234        self.global_interceptors.push(Box::leak(Box::new(ic)));
235    }
236
237    /// Ergonomic twin of [`Self::register_boundary_filter`]: takes ownership and
238    /// leaks internally.
239    pub fn add_boundary_filter(&mut self, f: impl crate::web::boundary::BoundaryFilter) {
240        self.boundary_filters.push(Box::leak(Box::new(f)));
241    }
242
243    pub fn register_boundary_filter(
244        &mut self,
245        f: &'static dyn crate::web::boundary::BoundaryFilter,
246    ) {
247        self.boundary_filters.push(f);
248    }
249
250    /// Register a route. The handler receives a fully-built `RequestContext`
251    /// — including DI access via `ctx.inject::<T>()` — and returns a
252    /// `Response`. The path is mounted verbatim under the application root.
253    ///
254    /// The path may be built at runtime (e.g. a config-driven prefix); it is
255    /// leaked to `&'static str`, which is fine because routes live for the
256    /// whole process anyway.
257    pub fn add_route<F, Fut>(&mut self, method: HttpMethod, path: impl Into<String>, handler: F)
258    where
259        F: Fn(RequestContext) -> Fut + Send + Sync + 'static,
260        Fut: std::future::Future<Output = Response> + Send + 'static,
261    {
262        let arc: PluginHandler = std::sync::Arc::new(move |ctx| Box::pin(handler(ctx)));
263        self.extra_routes.push(PluginRoute {
264            method,
265            path: Box::leak(path.into().into_boxed_str()),
266            handler: arc,
267            plugin: self.current_plugin,
268        });
269    }
270
271    /// Shortcut: register a GET.
272    pub fn add_get<F, Fut>(&mut self, path: impl Into<String>, handler: F)
273    where
274        F: Fn(RequestContext) -> Fut + Send + Sync + 'static,
275        Fut: std::future::Future<Output = Response> + Send + 'static,
276    {
277        self.add_route(HttpMethod::GET, path, handler);
278    }
279
280    /// Mutate the assembled OpenAPI document at launch time.
281    pub fn modify_openapi<F>(&mut self, f: F)
282    where
283        F: FnOnce(&mut serde_json::Value) + Send + Sync + 'static,
284    {
285        self.openapi_mutators.push(Box::new(f));
286    }
287
288    /// Read a required environment variable.
289    ///
290    /// Returns `Err(PluginError)` with stage `Init` if the variable is absent
291    /// or contains non-UTF-8 bytes, so callers can propagate it cleanly with `?`.
292    pub fn require_env(&self, key: &str) -> Result<String, PluginError> {
293        std::env::var(key).map_err(|_| {
294            PluginError::new(
295                self.current_plugin,
296                PluginStage::Init,
297                format!("required env var `{key}` is missing or not valid UTF-8"),
298            )
299        })
300    }
301
302    /// Read an environment variable with a fallback default.
303    pub fn env_or(&self, key: &str, default: impl Into<String>) -> String {
304        std::env::var(key).unwrap_or_else(|_| default.into())
305    }
306
307    /// Attach an interceptor that fires on **every** mounted route — macro
308    /// routes and plugin-registered routes alike. Global interceptors compose
309    /// as the outermost layers, in registration order (first registered =
310    /// outermost), around any per-route `#[UseInterceptors]` chain.
311    pub fn register_global_interceptor(
312        &mut self,
313        ic: &'static dyn crate::web::interceptors::Interceptor,
314    ) {
315        self.global_interceptors.push(ic);
316    }
317}
318
319impl Default for ArclyPluginContext {
320    fn default() -> Self {
321        Self::new()
322    }
323}
324
325// Route mounting moved to `crate::web::plugin_routes` so this module stays
326// HTTP-agnostic. Re-exported here to keep the existing import path working.
327pub use crate::web::plugin_routes::build_plugin_route;