From 8342a9ef27746fd27cb52b92df0a947e91dbe097 Mon Sep 17 00:00:00 2001
From: Spencer Ferris <3319370+spencewenski@users.noreply.github.com>
Date: Thu, 29 Aug 2024 15:42:34 -0700
Subject: [PATCH] feat: Add lifecycle handlers (#360)
Add `AppLifecycleHandler` trait with methods that are called at various
stages of an app's lifecycle. The initial set of methods are the
following:
- `before_service_cli`
- `before_health_checks`
- `before_services`
- `on_shutdown`
Closes https://github.com/roadster-rs/roadster/issues/350
---
src/app/mod.rs | 61 +++++++++-
src/config/app_config.rs | 5 +
src/config/default.toml | 3 +
src/config/health_check/mod.rs | 6 +
src/config/lifecycle/default.toml | 2 +
src/config/lifecycle/mod.rs | 115 ++++++++++++++++++
src/config/mod.rs | 1 +
...ster__config__app_config__tests__test.snap | 6 +
src/health_check/default.rs | 1 +
src/health_check/mod.rs | 16 +--
src/lib.rs | 1 +
src/lifecycle/db_migration.rs | 108 ++++++++++++++++
src/lifecycle/default.rs | 25 ++++
src/lifecycle/mod.rs | 85 +++++++++++++
src/lifecycle/registry.rs | 73 +++++++++++
src/service/http/initializer/default.rs | 1 +
src/service/http/middleware/default.rs | 1 +
src/service/runner.rs | 2 -
18 files changed, 496 insertions(+), 16 deletions(-)
create mode 100644 src/config/lifecycle/default.toml
create mode 100644 src/config/lifecycle/mod.rs
create mode 100644 src/lifecycle/db_migration.rs
create mode 100644 src/lifecycle/default.rs
create mode 100644 src/lifecycle/mod.rs
create mode 100644 src/lifecycle/registry.rs
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 63103b74..458ddf34 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -15,6 +15,7 @@ use crate::config::app_config::AppConfig;
use crate::config::environment::Environment;
use crate::error::RoadsterResult;
use crate::health_check::registry::HealthCheckRegistry;
+use crate::lifecycle::registry::LifecycleHandlerRegistry;
use crate::service::registry::ServiceRegistry;
use crate::tracing::init_tracing;
use async_trait::async_trait;
@@ -30,7 +31,7 @@ use sea_orm_migration::MigratorTrait;
use std::env;
use std::future;
use std::sync::Arc;
-use tracing::{instrument, warn};
+use tracing::{error, info, instrument, warn};
pub async fn run(app: A) -> RoadsterResult<()>
where
@@ -59,6 +60,7 @@ where
run_prepared_without_app_cli(prepare_from_cli_and_state(cli_and_state).await?).await
}
+#[non_exhaustive]
struct CliAndState
where
A: App + 'static,
@@ -132,6 +134,7 @@ where
pub app_cli: A::Cli,
pub state: S,
pub service_registry: ServiceRegistry,
+ pub lifecycle_handler_registry: LifecycleHandlerRegistry,
}
/// Prepare the app. Does everything to prepare the app short of starting the app. Specifically,
@@ -166,6 +169,10 @@ where
} = cli_and_state;
let context = AppContext::from_ref(&state);
+ let mut lifecycle_handler_registry = LifecycleHandlerRegistry::new(&state);
+ app.lifecycle_handlers(&mut lifecycle_handler_registry, &state)
+ .await?;
+
let mut health_check_registry = HealthCheckRegistry::new(&context);
app.health_checks(&mut health_check_registry, &state)
.await?;
@@ -182,6 +189,7 @@ where
app_cli,
state,
service_registry,
+ lifecycle_handler_registry,
})
}
@@ -226,8 +234,16 @@ where
return Ok(());
}
+ let lifecycle_handlers = prepared_app.lifecycle_handler_registry.handlers(state);
+
#[cfg(feature = "cli")]
{
+ info!("Running AppLifecycleHandler::before_service_cli");
+ for handler in lifecycle_handlers.iter() {
+ info!(name=%handler.name(), "Running AppLifecycleHandler::before_service_cli");
+ handler.before_service_cli(state).await?;
+ }
+
let PreparedApp {
roadster_cli,
app_cli,
@@ -240,16 +256,37 @@ where
}
}
- #[cfg(feature = "db-sql")]
- if context.config().database.auto_migrate {
- A::M::up(context.db(), None).await?;
+ info!("Running AppLifecycleHandler::before_health_checks");
+ for handler in lifecycle_handlers.iter() {
+ info!(name=%handler.name(), "Running AppLifecycleHandler::before_health_checks");
+ handler.before_health_checks(state).await?;
}
-
crate::service::runner::health_checks(&context).await?;
+ info!("Running AppLifecycleHandler::before_services");
+ for handler in lifecycle_handlers.iter() {
+ info!(name=%handler.name(), "Running AppLifecycleHandler::before_services");
+ handler.before_services(state).await?
+ }
crate::service::runner::before_run(service_registry, state).await?;
+ let result =
+ crate::service::runner::run(prepared_app.app, prepared_app.service_registry, state).await;
+ if let Err(err) = result {
+ error!("An error occurred in the app: {err}");
+ }
- crate::service::runner::run(prepared_app.app, prepared_app.service_registry, state).await?;
+ info!("Shutting down");
+
+ info!("Running AppLifecycleHandler::before_shutdown");
+ for handler in lifecycle_handlers.iter() {
+ info!(name=%handler.name(), "Running AppLifecycleHandler::before_shutdown");
+ let result = handler.on_shutdown(state).await;
+ if let Err(err) = result {
+ error!(name=%handler.name(), "An error occurred when running AppLifecycleHandler::before_shutdown: {err}");
+ }
+ }
+
+ info!("Shutdown complete");
Ok(())
}
@@ -288,6 +325,14 @@ where
/// See the following for more details regarding [FromRef]:
async fn provide_state(&self, context: AppContext) -> RoadsterResult;
+ async fn lifecycle_handlers(
+ &self,
+ _registry: &mut LifecycleHandlerRegistry,
+ _state: &S,
+ ) -> RoadsterResult<()> {
+ Ok(())
+ }
+
/// Provide the [crate::health_check::HealthCheck]s to use throughout the app.
async fn health_checks(
&self,
@@ -315,6 +360,10 @@ where
/// Override to provide custom graceful shutdown logic to clean up any resources created by
/// the app. Roadster will take care of cleaning up the resources it created.
+ ///
+ /// Alternatively, provide a [`crate::lifecycle::AppLifecycleHandler::on_shutdown`]
+ /// implementation and provide the handler to the [`LifecycleHandlerRegistry`] in
+ /// [`Self::lifecycle_handlers`].
#[instrument(skip_all)]
async fn graceful_shutdown(self: Arc, _state: &S) -> RoadsterResult<()> {
Ok(())
diff --git a/src/config/app_config.rs b/src/config/app_config.rs
index fb2dcf1e..11318041 100644
--- a/src/config/app_config.rs
+++ b/src/config/app_config.rs
@@ -3,6 +3,7 @@ use crate::config::auth::Auth;
use crate::config::database::Database;
use crate::config::environment::{Environment, ENVIRONMENT_ENV_VAR_NAME};
use crate::config::health_check::HealthCheck;
+use crate::config::lifecycle::LifecycleHandler;
use crate::config::service::Service;
use crate::config::tracing::Tracing;
use crate::error::RoadsterResult;
@@ -28,6 +29,8 @@ pub struct AppConfig {
#[validate(nested)]
pub app: App,
#[validate(nested)]
+ pub lifecycle_handler: LifecycleHandler,
+ #[validate(nested)]
pub health_check: HealthCheck,
#[validate(nested)]
pub service: Service,
@@ -172,6 +175,8 @@ impl AppConfig {
#[cfg(feature = "sidekiq")]
let config = config.add_source(crate::config::service::worker::sidekiq::default_config());
+ let config = config.add_source(crate::config::lifecycle::default_config());
+
let config = config.add_source(crate::config::health_check::default_config());
config
diff --git a/src/config/default.toml b/src/config/default.toml
index 0b87adde..620eb26c 100644
--- a/src/config/default.toml
+++ b/src/config/default.toml
@@ -1,6 +1,9 @@
[app]
shutdown-on-error = true
+[lifecycle-handler]
+default-enable = true
+
[service]
default-enable = true
diff --git a/src/config/health_check/mod.rs b/src/config/health_check/mod.rs
index 34520010..1b1a924e 100644
--- a/src/config/health_check/mod.rs
+++ b/src/config/health_check/mod.rs
@@ -19,11 +19,17 @@ pub fn default_config() -> config::File {
pub struct HealthCheck {
#[serde(default = "default_true")]
pub default_enable: bool,
+
pub max_duration: MaxDuration,
+
#[cfg(feature = "db-sql")]
+ #[validate(nested)]
pub database: HealthCheckConfig<()>,
+
#[cfg(feature = "sidekiq")]
+ #[validate(nested)]
pub sidekiq: HealthCheckConfig<()>,
+
/// Allows providing configs for custom health checks. Any configs that aren't pre-defined above
/// will be collected here.
///
diff --git a/src/config/lifecycle/default.toml b/src/config/lifecycle/default.toml
new file mode 100644
index 00000000..e3b239f9
--- /dev/null
+++ b/src/config/lifecycle/default.toml
@@ -0,0 +1,2 @@
+[lifecycle-handler.db-migration]
+priority = 0
diff --git a/src/config/lifecycle/mod.rs b/src/config/lifecycle/mod.rs
new file mode 100644
index 00000000..6b2a0512
--- /dev/null
+++ b/src/config/lifecycle/mod.rs
@@ -0,0 +1,115 @@
+use crate::app::context::AppContext;
+use crate::config::app_config::CustomConfig;
+use crate::util::serde::default_true;
+use config::{FileFormat, FileSourceString};
+use serde_derive::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+use validator::Validate;
+
+pub fn default_config() -> config::File {
+ config::File::from_str(include_str!("default.toml"), FileFormat::Toml)
+}
+
+#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+#[non_exhaustive]
+pub struct LifecycleHandler {
+ #[serde(default = "default_true")]
+ pub default_enable: bool,
+
+ #[cfg(feature = "db-sql")]
+ #[validate(nested)]
+ pub db_migration: LifecycleHandlerConfig<()>,
+
+ /// Allows providing configs for custom lifecycle handlers. Any configs that aren't pre-defined
+ /// above will be collected here.
+ ///
+ /// # Examples
+ ///
+ /// ```toml
+ /// [lifecycle-handler.foo]
+ /// enable = true
+ /// x = "y"
+ /// ```
+ ///
+ /// This will be parsed as:
+ /// ```raw
+ /// LifecycleHandler#custom: {
+ /// "foo": {
+ /// LifecycleHandlerConfig#common: {
+ /// enable: true,
+ /// priority: 10
+ /// },
+ /// LifecycleHandlerConfig#custom: {
+ /// "x": "y"
+ /// }
+ /// }
+ /// }
+ /// ```
+ #[serde(flatten)]
+ pub custom: BTreeMap>,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case", default)]
+#[non_exhaustive]
+pub struct CommonConfig {
+ // Optional so we can tell the difference between a consumer explicitly enabling/disabling
+ // the lifecycle handler, vs the lifecycle handler being enabled/disabled by default.
+ // If this is `None`, the value will match the value of `LifecycleHandler#default_enable`.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub enable: Option,
+ pub priority: i32,
+}
+
+impl CommonConfig {
+ pub fn enabled(&self, context: &AppContext) -> bool {
+ self.enable
+ .unwrap_or(context.config().lifecycle_handler.default_enable)
+ }
+}
+
+#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+#[non_exhaustive]
+pub struct LifecycleHandlerConfig {
+ #[serde(flatten, default)]
+ pub common: CommonConfig,
+ #[serde(flatten)]
+ pub custom: T,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::app_config::AppConfig;
+ use rstest::rstest;
+
+ #[rstest]
+ #[case(true, None, true)]
+ #[case(true, Some(true), true)]
+ #[case(true, Some(false), false)]
+ #[case(false, None, false)]
+ #[case(false, Some(true), true)]
+ #[case(false, Some(false), false)]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn common_config_enabled(
+ #[case] default_enable: bool,
+ #[case] enable: Option,
+ #[case] expected_enabled: bool,
+ ) {
+ // Arrange
+ let mut config = AppConfig::test(None).unwrap();
+ config.lifecycle_handler.default_enable = default_enable;
+
+ let context = AppContext::test(Some(config), None, None).unwrap();
+
+ let common_config = CommonConfig {
+ enable,
+ priority: 0,
+ };
+
+ // Act/Assert
+ assert_eq!(common_config.enabled(&context), expected_enabled);
+ }
+}
diff --git a/src/config/mod.rs b/src/config/mod.rs
index b5e8a71c..51fe9c57 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -4,5 +4,6 @@ pub mod auth;
pub mod database;
pub mod environment;
pub mod health_check;
+mod lifecycle;
pub mod service;
pub mod tracing;
diff --git a/src/config/snapshots/roadster__config__app_config__tests__test.snap b/src/config/snapshots/roadster__config__app_config__tests__test.snap
index 1b636dc9..d0f1c66c 100644
--- a/src/config/snapshots/roadster__config__app_config__tests__test.snap
+++ b/src/config/snapshots/roadster__config__app_config__tests__test.snap
@@ -8,6 +8,12 @@ environment = 'test'
name = 'Test'
shutdown-on-error = true
+[lifecycle-handler]
+default-enable = true
+
+[lifecycle-handler.db-migration]
+priority = 0
+
[health-check]
default-enable = true
diff --git a/src/health_check/default.rs b/src/health_check/default.rs
index 96518cd4..636ef909 100644
--- a/src/health_check/default.rs
+++ b/src/health_check/default.rs
@@ -26,6 +26,7 @@ pub fn default_health_checks(
context: context.clone(),
}),
];
+
health_checks
.into_iter()
.filter(|check| check.enabled())
diff --git a/src/health_check/mod.rs b/src/health_check/mod.rs
index 52e1b078..096168f5 100644
--- a/src/health_check/mod.rs
+++ b/src/health_check/mod.rs
@@ -61,10 +61,10 @@ pub struct ErrorData {
/// Trait used to check the health of the app before its services start up.
///
-/// This is a separate trait, vs adding a "health check" method to `AppService`, to allow defining
-/// health checks that apply to multiple services. For example, most services would require
-/// the DB and Redis connections to be valid, so we would want to perform a check for these
-/// resources a single time before starting any service instead of once for every service that
+/// This is a separate trait, vs adding a "health check" method to [`crate::service::AppService`],
+/// to allow defining health checks that apply to multiple services. For example, most services
+/// would require the DB and Redis connections to be valid, so we would want to perform a check for
+/// these resources a single time before starting any service instead of once for every service that
/// needs the resources.
///
/// Another benefit of using a separate trait is, because the health checks are decoupled from
@@ -73,14 +73,14 @@ pub struct ErrorData {
#[cfg_attr(test, mockall::automock)]
#[async_trait]
pub trait HealthCheck: Send + Sync {
- /// The name of the health check.
+ /// The name of the [`HealthCheck`].
fn name(&self) -> String;
/// Whether the health check is enabled. If the health check is not enabled, Roadster will not
- /// run it. However, if a consumer wants, they can certainly create a [HealthCheck] instance
- /// and directly call `HealthCheck#check` even if `HealthCheck#enabled` returns `false`.
+ /// run it. However, if a consumer wants, they can certainly create a [`HealthCheck`] instance
+ /// and directly call [`HealthCheck::check`] even if [`HealthCheck::enabled`] returns `false`.
fn enabled(&self) -> bool;
- /// Run the health check.
+ /// Run the [`HealthCheck`].
async fn check(&self) -> RoadsterResult;
}
diff --git a/src/lib.rs b/src/lib.rs
index 238a02ee..44efe33e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -12,6 +12,7 @@ pub mod app;
pub mod config;
pub mod error;
pub mod health_check;
+pub mod lifecycle;
pub mod middleware;
#[cfg(feature = "db-sql")]
pub mod migration;
diff --git a/src/lifecycle/db_migration.rs b/src/lifecycle/db_migration.rs
new file mode 100644
index 00000000..ebacf385
--- /dev/null
+++ b/src/lifecycle/db_migration.rs
@@ -0,0 +1,108 @@
+//! This [`AppLifecycleHandler`] runs the app's ['up' migration][`MigratorTrait::up`]
+//! in [`AppLifecycleHandler::before_services`].
+
+use crate::app::context::AppContext;
+use crate::app::App;
+use crate::error::RoadsterResult;
+use crate::lifecycle::AppLifecycleHandler;
+use async_trait::async_trait;
+use axum::extract::FromRef;
+use sea_orm_migration::MigratorTrait;
+
+pub struct DbMigrationLifecycleHandler;
+
+#[async_trait]
+impl AppLifecycleHandler for DbMigrationLifecycleHandler
+where
+ S: Clone + Send + Sync + 'static,
+ AppContext: FromRef,
+ A: App + 'static,
+{
+ fn name(&self) -> String {
+ "db-migration".to_string()
+ }
+
+ fn enabled(&self, state: &S) -> bool {
+ let context = AppContext::from_ref(state);
+ context.config().database.auto_migrate
+ && context
+ .config()
+ .lifecycle_handler
+ .db_migration
+ .common
+ .enabled(&context)
+ }
+
+ fn priority(&self, state: &S) -> i32 {
+ let context = AppContext::from_ref(state);
+ context
+ .config()
+ .lifecycle_handler
+ .db_migration
+ .common
+ .priority
+ }
+
+ async fn before_services(&self, state: &S) -> RoadsterResult<()> {
+ let context = AppContext::from_ref(state);
+
+ A::M::up(context.db(), None).await?;
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::app::MockApp;
+ use crate::config::app_config::AppConfig;
+ use rstest::rstest;
+
+ #[rstest]
+ #[case(false, Some(true), true)]
+ #[case(false, Some(false), false)]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn enabled(
+ #[case] default_enable: bool,
+ #[case] enable: Option,
+ #[case] expected_enabled: bool,
+ ) {
+ // Arrange
+ let mut config = AppConfig::test(None).unwrap();
+ config.lifecycle_handler.default_enable = default_enable;
+ config.lifecycle_handler.db_migration.common.enable = enable;
+
+ let context = AppContext::test(Some(config), None, None).unwrap();
+
+ let handler = DbMigrationLifecycleHandler;
+
+ // Act/Assert
+ assert_eq!(
+ AppLifecycleHandler::, AppContext>::enabled(&handler, &context),
+ expected_enabled
+ );
+ }
+
+ #[rstest]
+ #[case(None, 0)]
+ #[case(Some(1234), 1234)]
+ #[cfg_attr(coverage_nightly, coverage(off))]
+ fn priority(#[case] override_priority: Option, #[case] expected_priority: i32) {
+ // Arrange
+ let mut config = AppConfig::test(None).unwrap();
+ if let Some(priority) = override_priority {
+ config.lifecycle_handler.db_migration.common.priority = priority;
+ }
+
+ let context = AppContext::test(Some(config), None, None).unwrap();
+
+ let handler = DbMigrationLifecycleHandler;
+
+ // Act/Assert
+ assert_eq!(
+ AppLifecycleHandler::, AppContext>::priority(&handler, &context),
+ expected_priority
+ );
+ }
+}
diff --git a/src/lifecycle/default.rs b/src/lifecycle/default.rs
new file mode 100644
index 00000000..54a7bcf6
--- /dev/null
+++ b/src/lifecycle/default.rs
@@ -0,0 +1,25 @@
+use crate::app::context::AppContext;
+use crate::app::App;
+use crate::lifecycle::AppLifecycleHandler;
+use axum::extract::FromRef;
+use std::collections::BTreeMap;
+
+pub fn default_lifecycle_handlers(
+ state: &S,
+) -> BTreeMap>>
+where
+ S: Clone + Send + Sync + 'static,
+ AppContext: FromRef,
+ A: App + 'static,
+{
+ let lifecycle_handlers: Vec>> = vec![
+ #[cfg(feature = "db-sql")]
+ Box::new(crate::lifecycle::db_migration::DbMigrationLifecycleHandler),
+ ];
+
+ lifecycle_handlers
+ .into_iter()
+ .filter(|handler| handler.enabled(state))
+ .map(|handler| (handler.name(), handler))
+ .collect()
+}
diff --git a/src/lifecycle/mod.rs b/src/lifecycle/mod.rs
new file mode 100644
index 00000000..875a25ec
--- /dev/null
+++ b/src/lifecycle/mod.rs
@@ -0,0 +1,85 @@
+#[cfg(feature = "db-sql")]
+pub mod db_migration;
+pub mod default;
+pub mod registry;
+
+use crate::app::context::AppContext;
+use crate::app::App;
+use crate::error::RoadsterResult;
+use async_trait::async_trait;
+use axum::extract::FromRef;
+
+/// Trait used to hook into various stages of the app's lifecycle.
+///
+/// The app's lifecycle generally looks something like this:
+/// 1. Parse the [`crate::config::app_config::AppConfig`]
+/// 2. Initialize tracing to enable logs/traces
+/// 3. Build the [`crate::app::context::AppContext`] and the [`crate::app::App`]'s custom state
+/// 4. Run the roadster/app CLI command, if one was specified when the app was started
+/// 5. Register [`AppLifecycleHandler`]s, [`crate::health_check::HealthCheck`]s, and
+/// [`crate::service::AppService`]s
+/// 6. Run any CLI commands that are implemented by [`crate::service::AppService::handle_cli`]
+/// 7. Run the registered [`crate::health_check::HealthCheck`]s
+/// 8. Run the registered [`crate::service::AppService`]s
+/// 9. Wait for a shutdown signal, e.g., `Ctrl+c` or a custom signal from
+/// [`crate::app::App::graceful_shutdown_signal`], and stop the [`crate::service::AppService`]s
+/// when the signal is received.
+/// 9. Run Roadster's graceful shutdown logic
+/// 10. Run the app's [`crate::app::App::graceful_shutdown`] logic.
+#[cfg_attr(test, mockall::automock)]
+#[async_trait]
+pub trait AppLifecycleHandler: Send + Sync
+where
+ S: Clone + Send + Sync + 'static,
+ AppContext: FromRef,
+ A: App + 'static,
+{
+ /// The name of the [`AppLifecycleHandler`].
+ fn name(&self) -> String;
+
+ /// Whether the [`AppLifecycleHandler`] is enabled.
+ fn enabled(&self, _state: &S) -> bool {
+ true
+ }
+
+ /// Used to determine the order in which the [`AppLifecycleHandler`] will run when during app
+ /// startup. Smaller numbers will run before larger numbers. For example, a
+ /// [`AppLifecycleHandler`] with priority `-10` will run before a [`AppLifecycleHandler`]
+ /// with priority `10`.
+ ///
+ /// If two [`AppLifecycleHandler`]s have the same priority, they are not guaranteed to run
+ /// in any particular order relative to each other. This may be fine for many
+ /// [`AppLifecycleHandler`]s .
+ ///
+ /// If the order in which your [`AppLifecycleHandler`] runs doesn't particularly matter, it's
+ /// generally safe to set its priority as `0`.
+ fn priority(&self, _state: &S) -> i32 {
+ 0
+ }
+
+ /// This method is run right before running any CLI commands implemented by
+ /// [`crate::service::AppService::handle_cli`].
+ #[cfg(feature = "cli")]
+ async fn before_service_cli(&self, _state: &S) -> RoadsterResult<()> {
+ Ok(())
+ }
+
+ /// This method is run right before the app's [`crate::health_check::HealthCheck`]s during
+ /// app startup.
+ async fn before_health_checks(&self, _state: &S) -> RoadsterResult<()> {
+ Ok(())
+ }
+
+ /// This method is run right before the app's [`crate::service::AppService`]s are started.
+ async fn before_services(&self, _state: &S) -> RoadsterResult<()> {
+ Ok(())
+ }
+
+ /// This method is run after the app's [`crate::service::AppService`]s have stopped.
+ /// This method is an alternative to implementing [`crate::app::App::graceful_shutdown`].
+ ///
+ /// Note that this method runs after [`crate::app::App::graceful_shutdown`] has completed.
+ async fn on_shutdown(&self, _state: &S) -> RoadsterResult<()> {
+ Ok(())
+ }
+}
diff --git a/src/lifecycle/registry.rs b/src/lifecycle/registry.rs
new file mode 100644
index 00000000..82b802dc
--- /dev/null
+++ b/src/lifecycle/registry.rs
@@ -0,0 +1,73 @@
+use crate::app::context::AppContext;
+use crate::app::App;
+use crate::error::RoadsterResult;
+use crate::lifecycle::default::default_lifecycle_handlers;
+use crate::lifecycle::AppLifecycleHandler;
+use anyhow::anyhow;
+use axum::extract::FromRef;
+use itertools::Itertools;
+use std::collections::BTreeMap;
+use std::ops::Deref;
+use tracing::info;
+
+/// Registry for the app's [`AppLifecycleHandler`]s.
+pub struct LifecycleHandlerRegistry
+where
+ S: Clone + Send + Sync + 'static,
+ AppContext: FromRef,
+ A: App + ?Sized + 'static,
+{
+ state: S,
+ handlers: BTreeMap>>,
+}
+
+impl LifecycleHandlerRegistry
+where
+ S: Clone + Send + Sync + 'static,
+ AppContext: FromRef,
+ A: App + 'static,
+{
+ pub(crate) fn new(state: &S) -> Self {
+ Self {
+ state: state.clone(),
+ handlers: default_lifecycle_handlers(state),
+ }
+ }
+
+ /// Register a new [`AppLifecycleHandler`]. If the [`AppLifecycleHandler`] is not enabled
+ /// (e.g., [[`AppLifecycleHandler::enabled`] returns `false`), the [`AppLifecycleHandler`]
+ /// will not be registered.
+ pub fn register(&mut self, handler: H) -> RoadsterResult<()>
+ where
+ H: AppLifecycleHandler + 'static,
+ {
+ let name = handler.name();
+
+ if !handler.enabled(&self.state) {
+ info!(name=%name, "Lifecycle handler is not enabled, skipping registration");
+ return Ok(());
+ }
+
+ info!(name=%name, "Registering lifecycle handler");
+
+ if self
+ .handlers
+ .insert(name.clone(), Box::new(handler))
+ .is_some()
+ {
+ return Err(anyhow!("Handler `{}` was already registered", name).into());
+ }
+
+ Ok(())
+ }
+
+ /// Get the registered [`AppLifecycleHandler`]s, ordered by their
+ /// [`AppLifecycleHandler::priority`].
+ pub(crate) fn handlers(&self, state: &S) -> Vec<&dyn AppLifecycleHandler> {
+ self.handlers
+ .values()
+ .sorted_by(|a, b| Ord::cmp(&a.priority(state), &b.priority(state)))
+ .map(|handler| handler.deref())
+ .collect_vec()
+ }
+}
diff --git a/src/service/http/initializer/default.rs b/src/service/http/initializer/default.rs
index dfe44ee6..0f17be0e 100644
--- a/src/service/http/initializer/default.rs
+++ b/src/service/http/initializer/default.rs
@@ -10,6 +10,7 @@ where
AppContext: FromRef,
{
let initializers: Vec>> = vec![Box::new(NormalizePathInitializer)];
+
initializers
.into_iter()
.filter(|initializer| initializer.enabled(state))
diff --git a/src/service/http/middleware/default.rs b/src/service/http/middleware/default.rs
index bfe5db31..67542544 100644
--- a/src/service/http/middleware/default.rs
+++ b/src/service/http/middleware/default.rs
@@ -34,6 +34,7 @@ where
Box::new(CorsMiddleware),
Box::new(RequestResponseLoggingMiddleware),
];
+
middleware
.into_iter()
.filter(|middleware| middleware.enabled(state))
diff --git a/src/service/runner.rs b/src/service/runner.rs
index 78a666cb..92c816cb 100644
--- a/src/service/runner.rs
+++ b/src/service/runner.rs
@@ -160,8 +160,6 @@ where
}
}
- info!("Shutdown complete");
-
Ok(())
}