From 995f3cc61a22420065d0034fb5b837b3b6a24440 Mon Sep 17 00:00:00 2001 From: fa-sharp Date: Tue, 26 May 2026 02:14:41 -0400 Subject: [PATCH] add docs --- README.md | 130 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 70 ++++++++++++++++++++++------- 2 files changed, 184 insertions(+), 16 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..b94c46a --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# axum-app-wrapper + +A small plugin layer for `axum` applications, inspired by [fastify](https://fastify.dev/) from the Node/JS ecossytem. + +Plugins can: + +- add startup state before the final axum state type exists +- add routes, services, and middleware after state is initialized +- run graceful shutdown work in reverse registration order + +This crate is intentionally thin. It does not replace axum's router, extractors, or middleware. Rather, it organizes your server setup and teardown into plugins. + +## Lifecycle + +`App::init()` runs plugins in this order: + +1. `on_init` in registration order, passing a `TypeMap` to build state. +2. `S::try_from(TypeMap)` to build the final typed app state. +3. `on_setup` in registration order, passing `Router` and `&S`. +4. `on_shutdown` consecutively in reverse registration order + +## Example + +```rust +use std::{net::SocketAddr, sync::Arc}; + +use axum::{ + extract::State, + routing::get, +}; +use axum_app_wrapper::{AdHocPlugin, App, AppState}; + +#[derive(Clone, AppState)] +struct AppState { + config: Arc, +} + +#[derive(Clone)] +struct Config { + service_name: String, +} + +async fn health(State(state): State) -> String { + format!("{}:ok", state.config.service_name) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let config = Arc::new(Config { + service_name: "api".to_owned(), + }); + + let app = App::::new().register( + AdHocPlugin::::new() + .on_init(async move |mut state| { + state.insert(config); + Ok(state) + }) + .on_setup(|router, _state| Ok(router.route("/health", get(health)))) + .on_shutdown(|state| { + let service_name = state.config.service_name.clone(); + async move { + tracing::info!(%service_name, "shutting down"); + } + }), + ); + + let (router, state, on_shutdown) = app.init().await?; + let router = router.with_state(state); + + let addr: SocketAddr = "127.0.0.1:3000".parse()?; + let listener = tokio::net::TcpListener::bind(addr).await?; + + axum::serve(listener, router) + .with_graceful_shutdown(async { + tokio::signal::ctrl_c().await.expect("failed to listen for ctrl-c"); + on_shutdown.await; + }) + .await?; + + Ok(()) +} +``` + +For `on_setup` closures that access typed state, construct the plugin as +`AdHocPlugin::::new()`. That gives Rust enough context to infer the `state` parameter: + +```rust +let plugin = AdHocPlugin::::new() + .on_setup(|router, state| { + let config = Arc::clone(&state.config); + Ok(router.layer(axum::Extension(config))) + }); +``` + +## Reusable Plugins + +Implement `AppPlugin` directly when setup should be reusable across apps: + +```rust +use axum::Router; +use axum_app_wrapper::{AppPlugin, TypeMap}; +use futures::{future::BoxFuture, FutureExt}; + +struct ConfigPlugin { + config: Config, +} + +impl AppPlugin for ConfigPlugin { + fn on_init(&mut self, mut state: TypeMap) -> BoxFuture<'static, anyhow::Result> { + let config = self.config.clone(); + async move { + state.insert(config); + Ok(state) + } + .boxed() + } + + fn on_setup( + &mut self, + router: Router, + _state: &AppState, + ) -> anyhow::Result> { + Ok(router.route("/health", axum::routing::get(health))) + } +} +``` + +Shutdown hooks run like stack unwinding: the last registered plugin shuts down first, and each hook +finishes before the next one starts. diff --git a/src/lib.rs b/src/lib.rs index 5341f4e..87abfc5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,31 @@ #![allow(clippy::type_complexity)] +//! A small plugin layer for [`axum`](https://docs.rs/axum/latest/axum/) applications. +//! +//! `axum-app-wrapper` lets plugins contribute startup state, router setup, and shutdown work +//! around an `axum::Router`. +//! +//! Lifecycle: +//! +//! 1. [`AppPlugin::on_init`] runs in registration order and builds a [`TypeMap`]. +//! 2. The `TypeMap` is converted into the app state `S` with `TryFrom`. +//! 3. [`AppPlugin::on_setup`] runs in registration order and receives `Router` plus `&S`. +//! 4. The returned shutdown future runs [`AppPlugin::on_shutdown`] consecutively in reverse +//! registration order. +//! +//! For one-off plugins, use [`AdHocPlugin`]. If the setup closure needs access to typed state, +//! spell the state type at construction time so Rust can infer the closure parameters: +//! +//! ```ignore +//! let plugin = AdHocPlugin::::new() +//! .on_init(async |mut state| { +//! state.insert(config); +//! Ok(state) +//! }) +//! .on_setup(|router, state| { +//! let config = state.config.clone(); +//! Ok(router.layer(axum::Extension(config))) +//! }); +//! ``` use std::{fmt::Display, sync::Arc}; @@ -22,7 +49,7 @@ pub fn extract_type_field(map: &mut TypeMap) -> anyhow // Plugin system -/// A lightweight wrapper around axum that enables building plugins around the router +/// An axum app builder with plugin-managed state, router setup, and shutdown hooks. pub struct App> { base_router: Router, plugins: Vec>>, @@ -33,7 +60,7 @@ impl App where S: TryFrom + Clone + Send + Sync + 'static, { - /// Create a new server with the given routes. + /// Create an empty app. pub fn new() -> Self { Self { base_router: Router::new(), @@ -42,8 +69,10 @@ where } } - /// Register a plugin. This can be considered analogous to axum/tower's `layer` function - /// and follows the same ordering (each plugin "wraps" the previous one). + /// Register a plugin. + /// + /// `on_init` and `on_setup` run in registration order. Shutdown runs in reverse registration + /// order, awaiting each hook before starting the next. pub fn register(mut self, plugin: impl AppPlugin + 'static) -> Self { self.plugins.push(Box::new(plugin)); self @@ -65,8 +94,9 @@ where // self // } - /// Build and initialize the server. Returns the base router, finalized state, and a future to run - /// on graceful shutdown. + /// Build the router, finalized state, and graceful shutdown future. + /// + /// The returned shutdown future must be awaited by the caller when the server is shutting down. pub async fn init(mut self) -> anyhow::Result<(Router, S, impl Future + Send)> where S::Error: Display, @@ -99,27 +129,35 @@ where } } -/// Trait for a plugin that can be attached to the server +/// A plugin that can participate in app initialization, router setup, and shutdown. #[allow(unused_variables, reason = "trait functions with default no-op")] pub trait AppPlugin> { - /// Init function that will run on server startup. Can add and manipulate state. + /// Run during startup before typed state exists. + /// + /// Use this hook to insert or transform values in the shared [`TypeMap`]. fn on_init(&mut self, app_state: TypeMap) -> BoxFuture<'static, anyhow::Result> { async { Ok(app_state) }.boxed() } - /// Setup function that will run _after_ state is initialized (e.g. routes, middleware, - /// and services should be added here) + /// Run after typed state has been created. + /// + /// Use this hook to add routes, services, middleware, or router state. fn on_setup(&mut self, router: Router, state: &S) -> anyhow::Result> { Ok(router) } - /// Teardown function that will run on server shutdown + /// Return shutdown work for this plugin. + /// + /// Shutdown hooks are awaited consecutively in reverse registration order. fn on_shutdown(&mut self, state: &S) -> Option> { None } } -/// Utility to build a plugin on the fly +/// A closure-based plugin for application-local setup. +/// +/// `on_init` and `on_setup` accept capturing closures. If `on_setup` uses typed state, prefer +/// `AdHocPlugin::::new()` so the closure parameter type can be inferred. pub struct AdHocPlugin> { on_init: Option BoxFuture<'static, anyhow::Result> + Send>>, on_setup: Option, &S) -> anyhow::Result> + Send>>, @@ -127,6 +165,7 @@ pub struct AdHocPlugin> { } impl AdHocPlugin { + /// Create an empty ad-hoc plugin. pub fn new() -> Self { Self { on_init: None, @@ -135,7 +174,7 @@ impl AdHocPlugin { } } - /// Init function that will run on server startup. Can add and manipulate state. + /// Set startup state initialization for this plugin. pub fn on_init(mut self, on_init: F) -> Self where F: FnOnce(TypeMap) -> T + Send + 'static, @@ -145,8 +184,7 @@ impl AdHocPlugin { self } - /// Setup function that will run _after_ state is initialized. (e.g. routes, middleware, - /// and services should be added here) + /// Set router setup for this plugin. pub fn on_setup(mut self, on_setup: F) -> Self where F: FnOnce(Router, &S) -> anyhow::Result> + Send + 'static, @@ -155,7 +193,7 @@ impl AdHocPlugin { self } - /// Teardown function that will run on server shutdown + /// Set shutdown work for this plugin. pub fn on_shutdown(mut self, on_shutdown: fn(state: &S) -> T) -> Self where T: Future + Send + 'static,