rusty-gasket 0.1.1

A plugin-based Rust framework for backend HTTP services
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
//! Plugin system for Rusty Gasket.
//!
//! Plugins are the primary extension mechanism. Each plugin implements
//! the [`Plugin`] trait and participates in the application lifecycle:
//! `init → configure → prepare → ready → shutdown`. Plugins also
//! contribute middleware layers and routes via [`TaggedLayer`] and
//! [`TaggedRoute`].

mod engine;
mod ordering;

use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;

use axum::Router;

use crate::BoxError;
use crate::BoxFuture;
use crate::config::AppConfig;
use crate::pipeline::MiddlewareSlot;

pub use engine::{DEFAULT_REQUEST_BODY_LIMIT, GasketApp, GasketAppBuilder};
pub use ordering::{PluginOrdering, topological_sort};

/// The result type returned by named actions.
pub type ActionResult = Result<Box<dyn std::any::Any + Send>, BoxError>;

/// A type-erased async closure registered as a named action during init.
pub type BoxAction = Arc<dyn Fn(ActionArgs) -> BoxFuture<'static, ActionResult> + Send + Sync>;

/// Arguments passed to a [`BoxAction`] invocation.
pub type ActionArgs = Vec<Box<dyn std::any::Any + Send>>;

/// A type-erased router transformation for the middleware pipeline.
///
/// Plugins wrap their middleware (e.g., `from_fn_with_state`) in a closure
/// that applies it to a `Router`. This avoids Tower service type mismatches
/// between `BoxService` and axum's internal `Route` type.
pub type BoxRouterLayer = Box<dyn FnOnce(Router) -> Router + Send>;

/// A named router transformation used by the middleware pipeline.
///
/// This is the readable wrapper around the framework's boxed router closure.
/// Most plugin code creates one through [`TaggedLayer::new`] rather than
/// constructing this type directly.
pub struct RouterTransform {
    /// The one-shot closure that applies an axum/Tower middleware layer.
    ///
    /// It is boxed because each middleware closure has a unique compiler
    /// generated type, but the pipeline needs to store many of them together.
    layer: BoxRouterLayer,
}

impl RouterTransform {
    /// Create a router transform from a closure.
    #[must_use]
    pub fn new(layer: impl FnOnce(Router) -> Router + Send + 'static) -> Self {
        Self {
            layer: Box::new(layer),
        }
    }

    /// Apply the transform to a router and return the transformed router.
    pub fn apply(self, router: Router) -> Router {
        (self.layer)(router)
    }
}

impl std::fmt::Debug for RouterTransform {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("RouterTransform").finish_non_exhaustive()
    }
}

/// Controls which middleware stacks apply to a set of routes.
///
/// - `Bare` — no middleware at all (liveness probes). No logging, no
///   request body limit, no auth. Use only for handlers that read no
///   request body and intentionally bypass observability.
/// - `Public` — logging + request body size limit
///   ([`DEFAULT_REQUEST_BODY_LIMIT`]). Suitable for health checks,
///   docs, and Swagger UI.
/// - `Protected` — full middleware stack: logging, request body limit,
///   plus the per-plugin layers (auth, rate limiting, transactions, ...).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RouteGroup {
    Bare,
    Public,
    Protected,
}

/// A middleware layer tagged with the pipeline slot it belongs to.
/// The server assembles layers in slot order regardless of plugin registration order.
#[non_exhaustive]
pub struct TaggedLayer {
    /// Where this middleware belongs in the protected request pipeline.
    pub slot: MiddlewareSlot,
    /// The router transformation to apply at that pipeline slot.
    pub layer: RouterTransform,
}

impl TaggedLayer {
    /// Create a tagged layer from a middleware closure.
    ///
    /// Avoids manual `Box::new(...)` wrapping — just pass a closure:
    /// ```ignore
    /// TaggedLayer::new(MiddlewareSlot::Authentication, |router| {
    ///     router.layer(my_middleware)
    /// })
    /// ```
    pub fn new(
        slot: MiddlewareSlot,
        layer: impl FnOnce(Router) -> Router + Send + 'static,
    ) -> Self {
        Self {
            slot,
            layer: RouterTransform::new(layer),
        }
    }
}

impl std::fmt::Debug for TaggedLayer {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("TaggedLayer")
            .field("slot", &self.slot)
            .finish_non_exhaustive()
    }
}

/// A router tagged with the route group it belongs to.
/// The server merges routes into separate groups with different middleware stacks.
#[non_exhaustive]
pub struct TaggedRoute {
    /// The middleware group this router should be merged into.
    pub group: RouteGroup,
    /// The axum router contributed by a plugin.
    pub router: Router,
}

impl TaggedRoute {
    /// Create a tagged route from a group and router.
    #[must_use]
    pub const fn new(group: RouteGroup, router: Router) -> Self {
        Self { group, router }
    }
}

impl std::fmt::Debug for TaggedRoute {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("TaggedRoute")
            .field("group", &self.group)
            .finish_non_exhaustive()
    }
}

/// Context available during the `init` lifecycle phase.
///
/// Plugins use this to register named actions — async closures that can
/// be invoked by name at runtime via [`GasketApp::invoke_action`].
/// Duplicate action names are a hard error (prevents silent collisions).
pub struct InitContext {
    actions: HashMap<String, BoxAction>,
}

impl InitContext {
    #[must_use]
    pub fn new() -> Self {
        Self {
            actions: HashMap::new(),
        }
    }

    /// Register a named action. Returns an error if the name is already taken.
    ///
    /// # Errors
    /// Returns an error if another plugin has already registered an action
    /// with the same `name`.
    pub fn register_action(&mut self, name: &str, action: BoxAction) -> Result<(), BoxError> {
        if self.actions.contains_key(name) {
            return Err(format!("Action '{name}' already registered by another plugin").into());
        }
        self.actions.insert(name.to_string(), action);
        Ok(())
    }

    /// Register a named async action without writing boxed-future boilerplate.
    ///
    /// The action receives type-erased arguments and returns a concrete value.
    /// Rusty Gasket boxes the returned value internally so callers can retrieve
    /// it with [`GasketApp::invoke`].
    ///
    /// # Errors
    /// Returns an error if another plugin has already registered an action
    /// with the same `name`.
    pub fn register_action_fn<Function, FutureOutput, Output>(
        &mut self,
        name: &str,
        action: Function,
    ) -> Result<(), BoxError>
    where
        Function: Fn(ActionArgs) -> FutureOutput + Send + Sync + 'static,
        FutureOutput: Future<Output = Result<Output, BoxError>> + Send + 'static,
        Output: std::any::Any + Send + 'static,
    {
        let action = Arc::new(action);
        self.register_action(
            name,
            Arc::new(move |args| {
                let action = Arc::clone(&action);
                Box::pin(async move {
                    let result = action(args).await?;
                    let result: Box<dyn std::any::Any + Send> = Box::new(result);
                    Ok(result)
                })
            }),
        )
    }

    pub(crate) fn into_actions(self) -> HashMap<String, BoxAction> {
        self.actions
    }
}

impl Default for InitContext {
    fn default() -> Self {
        Self::new()
    }
}

impl std::fmt::Debug for InitContext {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("InitContext")
            .field("actions", &self.actions.keys().collect::<Vec<_>>())
            .finish()
    }
}

/// Context available during the `prepare` lifecycle phase.
/// Plugins do async setup here (connect to databases, warm caches).
/// The `extensions` map is shared across all plugins for passing state.
#[derive(Debug)]
#[non_exhaustive]
pub struct PrepareContext {
    pub config: AppConfig,
    pub extensions: http::Extensions,
}

impl PrepareContext {
    /// Create a `PrepareContext`. Used by the framework engine and tests.
    #[must_use]
    pub const fn new(config: AppConfig, extensions: http::Extensions) -> Self {
        Self { config, extensions }
    }
}

/// Context available when plugins contribute middleware layers.
#[derive(Debug)]
#[non_exhaustive]
pub struct LayerContext {
    pub config: AppConfig,
    pub extensions: http::Extensions,
}

impl LayerContext {
    /// Create a `LayerContext`. Used by the framework engine and tests.
    #[must_use]
    pub const fn new(config: AppConfig, extensions: http::Extensions) -> Self {
        Self { config, extensions }
    }
}

/// Context available when plugins contribute routes.
#[derive(Debug)]
#[non_exhaustive]
pub struct RouteContext {
    pub config: AppConfig,
    pub extensions: http::Extensions,
}

impl RouteContext {
    /// Create a `RouteContext`. Used by the framework engine and tests.
    #[must_use]
    pub const fn new(config: AppConfig, extensions: http::Extensions) -> Self {
        Self { config, extensions }
    }
}

/// Context available during the `ready` lifecycle phase.
/// At this point the server is bound and about to accept traffic.
#[non_exhaustive]
pub struct ReadyContext {
    pub config: AppConfig,
    pub extensions: http::Extensions,
    pub local_addr: std::net::SocketAddr,
}

impl ReadyContext {
    /// Create a `ReadyContext`. Used by the framework engine and tests.
    #[must_use]
    pub const fn new(
        config: AppConfig,
        extensions: http::Extensions,
        local_addr: std::net::SocketAddr,
    ) -> Self {
        Self {
            config,
            extensions,
            local_addr,
        }
    }
}

impl std::fmt::Debug for ReadyContext {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ReadyContext")
            .field("config", &self.config)
            .field("local_addr", &self.local_addr)
            .finish_non_exhaustive()
    }
}

/// Context available during the `shutdown` lifecycle phase.
/// Plugins run in reverse topological order during shutdown.
#[non_exhaustive]
pub struct ShutdownContext {
    pub extensions: http::Extensions,
}

impl ShutdownContext {
    /// Create a `ShutdownContext`. Used by the framework engine and tests.
    #[must_use]
    pub const fn new(extensions: http::Extensions) -> Self {
        Self { extensions }
    }
}

impl std::fmt::Debug for ShutdownContext {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ShutdownContext").finish_non_exhaustive()
    }
}

/// The core extension trait for Rusty Gasket.
///
/// Plugins participate in a lifecycle that runs during application startup
/// and shutdown. Each method has a default no-op implementation so plugins
/// only need to override the phases they care about.
///
/// # Lifecycle order
///
/// 1. `init` — synchronous, infallible, register named actions
/// 2. `configure` — synchronous, infallible waterfall, transform config
/// 3. `prepare` — async, fallible, connect to external resources
/// 4. `layers` + `routes` — synchronous, infallible accessors invoked when
///    the router is assembled (between `prepare` and `ready`)
/// 5. `ready` — async, fallible, server is bound and accepting traffic
/// 6. `shutdown` — async, fallible best-effort cleanup; runs in reverse
///    topological order; errors are logged but do not abort the
///    sequence (the framework calls every plugin's `shutdown` even if
///    one fails)
///
/// If `prepare` fails for any plugin, already-prepared plugins receive
/// `shutdown` in reverse order before the error propagates. `ready` and
/// `shutdown` errors are logged but do not abort the sequence.
///
/// # Plugin naming convention
///
/// Built-in framework plugins use the `gasket:*` namespace (for
/// example `gasket:health`, `gasket:server`). When listing
/// [`Self::dependencies`], use the exact `name()` strings returned by
/// the plugins you depend on; the framework matches by literal string
/// and a typo produces a `"requires missing dependency"` build error.
///
/// # Threading
///
/// All lifecycle methods run on the same async runtime as the rest of
/// the application. The synchronous methods (`init`, `configure`,
/// `layers`, `routes`) must not block — perform any I/O in
/// `prepare`/`ready`/`shutdown` instead.
pub trait Plugin: Send + Sync + 'static {
    /// Human-readable name for diagnostics and logging. Used as the
    /// match key for `dependencies()` so the literal string matters;
    /// built-ins use the `gasket:*` namespace.
    ///
    /// Returns `&'static str` because plugin names are compile-time
    /// constants in every known implementation; if a dynamic name is
    /// ever needed, leak via `Box::leak`.
    fn name(&self) -> &'static str;

    /// Ordering constraints relative to other plugins.
    /// The framework topologically sorts plugins based on these constraints.
    fn ordering(&self) -> PluginOrdering {
        PluginOrdering::default()
    }

    /// Hard dependencies on other plugins. Build fails if any are
    /// missing. Identify dependencies by the exact `name()` they
    /// return (e.g. `"gasket:health"`).
    fn dependencies(&self) -> Vec<&str> {
        Vec::new()
    }

    /// Synchronous, infallible init phase. Register named actions via
    /// `ctx.register_action()`. Validation that can fail belongs in
    /// `prepare` so the error can propagate.
    fn init(&self, _ctx: &mut InitContext) {}

    /// Config waterfall. Each plugin can transform the resolved config.
    fn configure(&self, config: AppConfig) -> AppConfig {
        config
    }

    /// Async prepare phase. Connect to databases, warm caches, etc.
    fn prepare<'ctx>(
        &'ctx self,
        _ctx: &'ctx mut PrepareContext,
    ) -> impl Future<Output = Result<(), BoxError>> + Send + 'ctx {
        async { Ok(()) }
    }

    /// Called when the server is fully ready and accepting traffic.
    fn ready<'ctx>(
        &'ctx self,
        _ctx: &'ctx ReadyContext,
    ) -> impl Future<Output = Result<(), BoxError>> + Send + 'ctx {
        async { Ok(()) }
    }

    /// Called during graceful shutdown (reverse plugin order).
    fn shutdown<'ctx>(
        &'ctx self,
        _ctx: &'ctx ShutdownContext,
    ) -> impl Future<Output = Result<(), BoxError>> + Send + 'ctx {
        async { Ok(()) }
    }

    /// Return middleware layers tagged with pipeline slots.
    fn layers(&self, _ctx: &LayerContext) -> Vec<TaggedLayer> {
        Vec::new()
    }

    /// Return routes tagged with route groups.
    fn routes(&self, _ctx: &RouteContext) -> Vec<TaggedRoute> {
        Vec::new()
    }
}

/// Dyn-compatible version of [`Plugin`] used only by the framework runtime.
///
/// Public plugin implementations use the readable [`Plugin`] trait with
/// plain `async fn` hooks. The runtime still needs one list containing many
/// different plugin types, so this private trait performs the required type
/// erasure in one named place.
trait ErasedPlugin: Send + Sync + 'static {
    /// Return the public plugin name.
    fn name(&self) -> &'static str;

    /// Return ordering constraints used by the dependency sorter.
    fn ordering(&self) -> PluginOrdering;

    /// Return hard plugin dependencies by name.
    fn dependencies(&self) -> Vec<&str>;

    /// Forward the synchronous init hook.
    fn init(&self, ctx: &mut InitContext);

    /// Forward the config transformation hook.
    fn configure(&self, config: AppConfig) -> AppConfig;

    /// Forward the async prepare hook as a boxed future.
    fn prepare<'ctx>(
        &'ctx self,
        ctx: &'ctx mut PrepareContext,
    ) -> BoxFuture<'ctx, Result<(), BoxError>>;

    /// Forward the async ready hook as a boxed future.
    fn ready<'ctx>(&'ctx self, ctx: &'ctx ReadyContext) -> BoxFuture<'ctx, Result<(), BoxError>>;

    /// Forward the async shutdown hook as a boxed future.
    fn shutdown<'ctx>(
        &'ctx self,
        ctx: &'ctx ShutdownContext,
    ) -> BoxFuture<'ctx, Result<(), BoxError>>;

    /// Forward middleware contributions.
    fn layers(&self, ctx: &LayerContext) -> Vec<TaggedLayer>;

    /// Forward route contributions.
    fn routes(&self, ctx: &RouteContext) -> Vec<TaggedRoute>;
}

impl<T> ErasedPlugin for T
where
    T: Plugin,
{
    fn name(&self) -> &'static str {
        Plugin::name(self)
    }

    fn ordering(&self) -> PluginOrdering {
        Plugin::ordering(self)
    }

    fn dependencies(&self) -> Vec<&str> {
        Plugin::dependencies(self)
    }

    fn init(&self, ctx: &mut InitContext) {
        Plugin::init(self, ctx);
    }

    fn configure(&self, config: AppConfig) -> AppConfig {
        Plugin::configure(self, config)
    }

    fn prepare<'ctx>(
        &'ctx self,
        ctx: &'ctx mut PrepareContext,
    ) -> BoxFuture<'ctx, Result<(), BoxError>> {
        // The public trait returns an anonymous future. Boxing happens here so
        // callers and plugin authors never have to name that future type.
        Box::pin(Plugin::prepare(self, ctx))
    }

    fn ready<'ctx>(&'ctx self, ctx: &'ctx ReadyContext) -> BoxFuture<'ctx, Result<(), BoxError>> {
        // Keep async lifecycle storage dynamic without requiring async-trait
        // or boxed-future syntax in plugin implementations.
        Box::pin(Plugin::ready(self, ctx))
    }

    fn shutdown<'ctx>(
        &'ctx self,
        ctx: &'ctx ShutdownContext,
    ) -> BoxFuture<'ctx, Result<(), BoxError>> {
        // Shutdown uses the same erased future shape so rollback and normal
        // graceful shutdown can share one plugin list.
        Box::pin(Plugin::shutdown(self, ctx))
    }

    fn layers(&self, ctx: &LayerContext) -> Vec<TaggedLayer> {
        Plugin::layers(self, ctx)
    }

    fn routes(&self, ctx: &RouteContext) -> Vec<TaggedRoute> {
        Plugin::routes(self, ctx)
    }
}

/// A plugin handle ready for dynamic registration or presets.
///
/// Most applications can pass plugin values directly to
/// [`GasketAppBuilder::plugin`]. Use `PluginHandle` when building a plugin
/// list dynamically, such as framework presets.
pub struct PluginHandle {
    /// The dyn-compatible plugin object used by the runtime lifecycle engine.
    inner: Box<dyn ErasedPlugin>,
}

impl PluginHandle {
    /// Store a plugin behind a readable framework handle.
    pub fn new(plugin: impl Plugin) -> Self {
        Self {
            inner: Box::new(plugin),
        }
    }

    /// Human-readable plugin name.
    #[must_use]
    pub fn name(&self) -> &'static str {
        self.inner.name()
    }

    /// Plugin ordering constraints consumed by the sorter.
    pub(crate) fn ordering(&self) -> PluginOrdering {
        self.inner.ordering()
    }

    /// Hard plugin dependencies consumed by the dependency validator.
    pub(crate) fn dependencies(&self) -> Vec<&str> {
        self.inner.dependencies()
    }

    /// Run the plugin's init hook.
    pub(crate) fn init(&self, ctx: &mut InitContext) {
        self.inner.init(ctx);
    }

    /// Run the plugin's config transformation hook.
    pub(crate) fn configure(&self, config: AppConfig) -> AppConfig {
        self.inner.configure(config)
    }

    /// Run the plugin's async prepare hook.
    pub(crate) fn prepare<'ctx>(
        &'ctx self,
        ctx: &'ctx mut PrepareContext,
    ) -> BoxFuture<'ctx, Result<(), BoxError>> {
        self.inner.prepare(ctx)
    }

    /// Run the plugin's async ready hook.
    pub(crate) fn ready<'ctx>(
        &'ctx self,
        ctx: &'ctx ReadyContext,
    ) -> BoxFuture<'ctx, Result<(), BoxError>> {
        self.inner.ready(ctx)
    }

    /// Run the plugin's async shutdown hook.
    pub(crate) fn shutdown<'ctx>(
        &'ctx self,
        ctx: &'ctx ShutdownContext,
    ) -> BoxFuture<'ctx, Result<(), BoxError>> {
        self.inner.shutdown(ctx)
    }

    /// Collect middleware contributed by this plugin.
    pub(crate) fn layers(&self, ctx: &LayerContext) -> Vec<TaggedLayer> {
        self.inner.layers(ctx)
    }

    /// Collect routes contributed by this plugin.
    pub(crate) fn routes(&self, ctx: &RouteContext) -> Vec<TaggedRoute> {
        self.inner.routes(ctx)
    }
}

impl std::fmt::Debug for PluginHandle {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_tuple("PluginHandle").field(&self.name()).finish()
    }
}

/// Backward-compatible name for dynamic plugin storage.
///
/// Prefer [`PluginHandle`] in new framework and application code.
pub type BoxPlugin = PluginHandle;