//! 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. [`InitializedApp::shutdown`] 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))) //! }); //! ``` extern crate self as axum_app_wrapper; use std::{borrow::Cow, fmt::Display, sync::Arc}; use anyhow::Context; use axum::{Router, routing::MethodRouter}; use futures::{FutureExt, future::BoxFuture}; pub use axum_app_wrapper_macros::AppState; pub use type_map::concurrent::TypeMap; pub use anyhow; /// Error type used by this crate. pub type Error = anyhow::Error; /// Result type used by this crate. pub type Result = std::result::Result; /// Startup initialization future returned by [`AppPlugin::on_init`]. pub type InitFuture = BoxFuture<'static, Result>; /// Shutdown future returned by [`AppPlugin::on_shutdown`]. pub type ShutdownFuture = BoxFuture<'static, Result<()>>; /// Default app state that keeps the startup [`TypeMap`] available behind an [`Arc`]. #[derive(Clone)] pub struct TypeMapState(Arc); impl TypeMapState { /// Borrow the underlying [`TypeMap`]. pub fn type_map(&self) -> &TypeMap { &self.0 } } impl From for TypeMapState { fn from(map: TypeMap) -> Self { Self(Arc::new(map)) } } /// Extracts a value of type `T` from a [`TypeMap`], returning an Anyhow error if the type is not present. /// /// This is used internally by the [`AppState`] macro but is also available for manual /// [`TryFrom`] implementations. pub fn extract_type_field(map: &mut TypeMap) -> Result { map.remove::() .ok_or_else(|| ::anyhow::anyhow!("Missing type in TypeMap: {}", std::any::type_name::())) } // Plugin system /// An axum app builder with plugin-managed state, router setup, and shutdown hooks. pub struct App { base_router: Router, plugins: Vec>>, state: TypeMap, } impl App where S: TryFrom + Clone + Send + Sync + 'static, { /// Create an empty app. pub fn new() -> Self { Self { base_router: Router::new(), state: TypeMap::new(), plugins: Vec::new(), } } /// Add a route to the base router before plugins run setup. pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self { self.base_router = self.base_router.route(path, method_router); self } /// Mount routes at the given path before any setup hooks run. Prefer mounting most /// routes within plugins. pub fn mount(mut self, path: &str, router: Router) -> Self { self.base_router = match path { "" | "/" => self.base_router.merge(router), _ => self.base_router.nest(path, router), }; self } /// Store a value in router state. pub fn store(mut self, state: T) -> Self { self.state.insert(state); self } /// 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 } /// Build the router, finalized state, and graceful shutdown handle. pub async fn init(mut self) -> Result> where S::Error: Display, { let mut state = self.state; for plugin in self.plugins.iter_mut() { state = plugin .on_init(state) .await .with_context(|| format!("Error in on_init hook of plugin '{}'", plugin.name()))?; } let state = S::try_from(state).map_err(|err| ::anyhow::anyhow!("Error creating state: {err}"))?; let mut router = self.base_router; for plugin in self.plugins.iter_mut() { router = plugin .on_setup(router, &state) .with_context(|| format!("Error in on_setup hook of plugin '{}'", plugin.name()))?; } let shutdown_fns: Vec<_> = self .plugins .into_iter() .rev() .filter_map(|mut p| Some((p.name(), p.on_shutdown(&state)?))) .collect(); let on_shutdown = async move { for (plugin_name, shutdown_fn) in shutdown_fns { shutdown_fn.await.with_context(|| { format!("Error in on_shutdown hook of plugin '{plugin_name}'") })?; } Ok(()) } .boxed(); Ok(InitializedApp { router, state, on_shutdown, }) } } impl Default for App where S: TryFrom + Clone + Send + Sync + 'static, { fn default() -> Self { Self::new() } } /// A fully initialized axum app. pub struct InitializedApp { router: Router, state: S, on_shutdown: ShutdownFuture, } impl InitializedApp where S: Clone + Send + Sync + 'static, { /// Build a ready-to-serve router by attaching the finalized state. pub fn router(&self) -> Router<()> { self.router.clone().with_state(self.state.clone()) } /// Borrow the finalized app state. pub fn state(&self) -> &S { &self.state } /// Consume the initialized app and return its raw parts. pub fn into_parts(self) -> (Router, S, ShutdownFuture) { (self.router, self.state, self.on_shutdown) } /// Run graceful shutdown work for all plugins. pub async fn shutdown(self) -> Result<()> { self.on_shutdown.await } } /// 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 { /// Plugin name fn name(&self) -> Cow<'static, str> { Cow::Borrowed(std::any::type_name::()) } /// 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) -> InitFuture { async { Ok(app_state) }.boxed() } /// 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) -> Result> { Ok(router) } /// Return shutdown work for this plugin. /// /// Shutdown hooks are awaited consecutively in reverse registration order. fn on_shutdown(&mut self, state: &S) -> Option { None } } /// 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 { name: Cow<'static, str>, on_init: Option, on_setup: Option>, on_shutdown: Option>, } type InitFn = Box InitFuture + Send>; type SetupFn = Box, &S) -> Result> + Send>; type ShutdownFn = Box ShutdownFuture + Send>; impl AdHocPlugin { /// Create an ad-hoc plugin. Prefer `named()` to help with debugging. pub fn new() -> Self { Self { name: Cow::Borrowed("adhoc"), on_init: None, on_setup: None, on_shutdown: None, } } /// Create a named ad-hoc plugin. pub fn named(name: impl Into>) -> Self { Self { name: name.into(), on_init: None, on_setup: None, on_shutdown: None, } } /// Set startup state initialization for this plugin. pub fn on_init(mut self, on_init: F) -> Self where F: FnOnce(TypeMap) -> T + Send + 'static, T: Future> + Send + 'static, { self.on_init = Some(Box::new(move |s| Box::pin(on_init(s)))); self } /// Set router setup for this plugin. pub fn on_setup(mut self, on_setup: F) -> Self where F: FnOnce(Router, &S) -> Result> + Send + 'static, { self.on_setup = Some(Box::new(on_setup)); self } /// Set shutdown work for this plugin. pub fn on_shutdown(mut self, on_shutdown: F) -> Self where F: FnOnce(&S) -> T + Send + 'static, T: Future> + Send + 'static, { self.on_shutdown = Some(Box::new(move |s| Box::pin(on_shutdown(s)))); self } } impl Default for AdHocPlugin { fn default() -> Self { Self::new() } } impl AppPlugin for AdHocPlugin { fn name(&self) -> Cow<'static, str> { self.name.clone() } fn on_init(&mut self, app_state: TypeMap) -> InitFuture { match self.on_init.take() { Some(init_fn) => async move { init_fn(app_state).await }.boxed(), None => async { Ok(app_state) }.boxed(), } } fn on_setup(&mut self, router: Router, state: &S) -> Result> { match self.on_setup.take() { Some(setup_fn) => setup_fn(router, state), None => Ok(router), } } fn on_shutdown(&mut self, state: &S) -> Option { match self.on_shutdown.take() { Some(shutdown_fn) => Some(shutdown_fn(state)), None => None, } } } #[cfg(test)] mod tests { use super::*; use std::{ sync::{ Mutex, atomic::{AtomicUsize, Ordering}, }, task::Poll, }; #[derive(Clone, self::AppState)] struct TestState { value: Arc, } #[test] fn adhoc_plugin_with_basic_state() { let init_value = Arc::new(String::from("ready")); let setup_value = Arc::clone(&init_value); let plugin = AdHocPlugin::::new() .on_init(async |mut state| { state.insert(init_value); Ok(state) }) .on_setup(move |router, state| { assert_eq!(state.value.as_str(), setup_value.as_str()); Ok(router) }); let app = App::::new().register(plugin); let app = futures::executor::block_on(app.init()).expect("app should initialize"); assert_eq!(app.state().value.as_str(), "ready"); } #[test] fn store_and_router_handle_expose_initialized_state() { let app = App::::new().store(Arc::new(String::from("stored"))); let app = futures::executor::block_on(app.init()).expect("app should initialize"); let _router = app.router(); assert_eq!(app.state().value.as_str(), "stored"); } #[test] fn default_state_keeps_type_map_available() { let app: App = App::new().store(Arc::new(String::from("typemap"))); let app = futures::executor::block_on(app.init()).expect("app should initialize"); let value = app .state() .type_map() .get::>() .expect("stored value should remain in typemap"); assert_eq!(value.as_str(), "typemap"); } #[derive(Clone, AppState)] struct GenericState { value: Arc, } #[test] fn app_state_derive_supports_generics() { let app = App::>::new().store(Arc::new(String::from("generic"))); let app = futures::executor::block_on(app.init()).expect("app should initialize"); assert_eq!(app.state().value.as_str(), "generic"); } #[test] fn adhoc_shutdown_accepts_capturing_fallible_closure() { let events = Arc::new(Mutex::new(Vec::new())); let shutdown_events = Arc::clone(&events); let app = App::::new() .store(Arc::new(String::from("ready"))) .register(AdHocPlugin::::new().on_shutdown(move |state| { let events = Arc::clone(&shutdown_events); let value = Arc::clone(&state.value); async move { events .lock() .expect("events lock poisoned") .push(value.to_string()); Ok(()) } })); let app = futures::executor::block_on(app.init()).expect("app should initialize"); futures::executor::block_on(app.shutdown()).expect("shutdown should succeed"); assert_eq!( *events.lock().expect("events lock poisoned"), [String::from("ready")] ); } struct ShutdownOrderPlugin { name: &'static str, events: Arc>>, active_shutdowns: Arc, } impl AppPlugin for ShutdownOrderPlugin { fn on_shutdown(&mut self, _state: &TestState) -> Option { let name = self.name; let events = Arc::clone(&self.events); let active_shutdowns = Arc::clone(&self.active_shutdowns); let mut yielded = false; Some(Box::pin(futures::future::poll_fn(move |cx| { if !yielded { yielded = true; let previously_active = active_shutdowns.fetch_add(1, Ordering::SeqCst); assert_eq!(previously_active, 0, "shutdown hooks ran concurrently"); events .lock() .expect("events lock poisoned") .push(format!("{name}:start")); cx.waker().wake_by_ref(); return Poll::Pending; } events .lock() .expect("events lock poisoned") .push(format!("{name}:finish")); active_shutdowns.fetch_sub(1, Ordering::SeqCst); Poll::Ready(Ok(())) }))) } } #[test] fn shutdown_hooks_order() { let events = Arc::new(Mutex::new(Vec::new())); let active_shutdowns = Arc::new(AtomicUsize::new(0)); let app = App::::new() .register(AdHocPlugin::::new().on_init(async |mut state| { state.insert(Arc::new(String::from("ready"))); Ok(state) })) .register(ShutdownOrderPlugin { name: "first", events: Arc::clone(&events), active_shutdowns: Arc::clone(&active_shutdowns), }) .register(ShutdownOrderPlugin { name: "second", events: Arc::clone(&events), active_shutdowns, }); let app = futures::executor::block_on(app.init()).expect("app should initialize"); futures::executor::block_on(app.shutdown()).expect("shutdown should succeed"); assert_eq!( *events.lock().expect("events lock poisoned"), [ "second:start", "second:finish", "first:start", "first:finish" ] ); } }