From 299e72dda3f6979503192b6276a07d2be1b13345 Mon Sep 17 00:00:00 2001 From: Spencer Ferris <3319370+spencewenski@users.noreply.github.com> Date: Sun, 28 Apr 2024 17:18:30 -0700 Subject: [PATCH] Add `AppService` trait and use it to implement the HTTP service Add an `AppService` trait to allow consumers to define custom services that will be run as tokio tasks. This is similar (I believe) to Laraval's `Provider` concept. Use the `AppService` trait to add an HTTP server using Axum instead of putting http/axum specific methods directly on the `App` trait --- examples/minimal/src/app.rs | 23 ++- src/app.rs | 187 ++++++----------------- src/app_context.rs | 7 - src/cli/list_routes.rs | 27 ---- src/cli/mod.rs | 12 +- src/cli/open_api_schema.rs | 39 +---- src/lib.rs | 1 + src/service/http/http_service.rs | 109 +++++++++++++ src/service/http/http_service_builder.rs | 147 ++++++++++++++++++ src/service/http/mod.rs | 2 + src/service/mod.rs | 32 ++++ 11 files changed, 362 insertions(+), 224 deletions(-) create mode 100644 src/service/http/http_service.rs create mode 100644 src/service/http/http_service_builder.rs create mode 100644 src/service/http/mod.rs create mode 100644 src/service/mod.rs diff --git a/examples/minimal/src/app.rs b/examples/minimal/src/app.rs index 86cf1003..2ee729fb 100644 --- a/examples/minimal/src/app.rs +++ b/examples/minimal/src/app.rs @@ -1,12 +1,12 @@ -use aide::axum::ApiRouter; use async_trait::async_trait; use migration::Migrator; use roadster::app::App as RoadsterApp; use roadster::app_context::AppContext; -use roadster::config::app_config::AppConfig; -use roadster::controller::default_routes; +use roadster::service::http::http_service_builder::HttpServiceBuilder; +use roadster::service::AppService; use roadster::worker::app_worker::AppWorker; use roadster::worker::registry::WorkerRegistry; +use std::vec; use crate::app_state::AppState; use crate::cli::AppCli; @@ -24,10 +24,6 @@ impl RoadsterApp for App { type Cli = AppCli; type M = Migrator; - fn router(config: &AppConfig) -> ApiRouter { - default_routes(BASE, config).merge(controller::routes(BASE)) - } - async fn workers( registry: &mut WorkerRegistry, _context: &AppContext, @@ -36,4 +32,17 @@ impl RoadsterApp for App { registry.register_app_worker(ExampleWorker::build(state)); Ok(()) } + + async fn services( + context: &AppContext, + state: &Self::State, + ) -> anyhow::Result>>> { + let http_service = Box::new( + HttpServiceBuilder::::new(BASE, context) + .router(controller::routes(BASE)) + .build(context, state)?, + ); + + Ok(vec![http_service]) + } } diff --git a/src/app.rs b/src/app.rs index 071280f9..4276b795 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,21 +1,21 @@ -use std::future; -use std::future::Future; -use std::sync::Arc; - -#[cfg(feature = "open-api")] -use aide::axum::ApiRouter; -#[cfg(feature = "open-api")] -use aide::openapi::OpenApi; -#[cfg(feature = "open-api")] -use aide::transform::TransformOpenApi; +use crate::app_context::AppContext; +#[cfg(feature = "cli")] +use crate::cli::{RoadsterCli, RunCommand, RunRoadsterCommand}; +use crate::config::app_config::AppConfig; +#[cfg(not(feature = "cli"))] +use crate::config::environment::Environment; +#[cfg(feature = "sidekiq")] +use crate::config::worker::StaleCleanUpBehavior; +use crate::service::AppService; +use crate::tracing::init_tracing; +#[cfg(feature = "sidekiq")] +use crate::worker::registry::WorkerRegistry; #[cfg(feature = "sidekiq")] use anyhow::anyhow; use async_trait::async_trait; -#[cfg(feature = "open-api")] -use axum::Extension; -use axum::Router; #[cfg(feature = "cli")] use clap::{Args, Command, FromArgMatches}; +#[cfg(feature = "sidekiq")] use itertools::Itertools; #[cfg(feature = "sidekiq")] use num_traits::ToPrimitive; @@ -25,28 +25,15 @@ use sea_orm::{ConnectOptions, Database}; use sea_orm_migration::MigratorTrait; #[cfg(feature = "sidekiq")] use sidekiq::{periodic, Processor, ProcessorConfig}; +use std::future; +use std::future::Future; +use std::sync::Arc; use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; #[cfg(feature = "sidekiq")] use tracing::debug; use tracing::{error, info, instrument}; -use crate::app_context::AppContext; -#[cfg(feature = "cli")] -use crate::cli::{RoadsterCli, RunCommand, RunRoadsterCommand}; -use crate::config::app_config::AppConfig; -#[cfg(not(feature = "cli"))] -use crate::config::environment::Environment; -#[cfg(feature = "sidekiq")] -use crate::config::worker::StaleCleanUpBehavior; -use crate::controller::middleware::default::default_middleware; -use crate::controller::middleware::Middleware; -use crate::initializer::default::default_initializers; -use crate::initializer::Initializer; -use crate::tracing::init_tracing; -#[cfg(feature = "sidekiq")] -use crate::worker::registry::WorkerRegistry; - // todo: this method is getting unweildy, we should break it up pub async fn start( // This parameter is (currently) not used when no features are enabled. @@ -144,16 +131,6 @@ where (redis_enqueue, redis_fetch) }; - let router = A::router(&config); - #[cfg(feature = "open-api")] - let (router, api) = { - let mut api = OpenApi::default(); - let router = router.finish_api_with(&mut api, A::api_docs(&config)); - // Arc is very important here or we will face massive memory and performance issues - let api = Arc::new(api); - let router = router.layer(Extension(api.clone())); - (router, api) - }; let context = AppContext::new( config, #[cfg(feature = "db-sql")] @@ -162,14 +139,11 @@ where redis_enqueue.clone(), #[cfg(feature = "sidekiq")] redis_fetch.clone(), - #[cfg(feature = "open-api")] - api, ) .await?; let context = Arc::new(context); let state = A::context_to_state(context.clone()).await?; - let router = router.with_state::<()>(state.clone()); let state = Arc::new(state); #[cfg(feature = "cli")] @@ -182,58 +156,23 @@ where } } + let services = A::services(&context, &state).await?; + + #[cfg(feature = "cli")] + for service in services.iter() { + if service + .handle_cli(&roadster_cli, &app_cli, &context, &state) + .await? + { + return Ok(()); + } + } + #[cfg(feature = "db-sql")] if context.config.database.auto_migrate { A::M::up(&context.db, None).await?; } - let initializers = default_initializers() - .into_iter() - .chain(A::initializers(&context)) - .filter(|initializer| initializer.enabled(&context, &state)) - .unique_by(|initializer| initializer.name()) - .sorted_by(|a, b| Ord::cmp(&a.priority(&context, &state), &b.priority(&context, &state))) - .collect_vec(); - - let router = initializers - .iter() - .try_fold(router, |router, initializer| { - initializer.after_router(router, &context, &state) - })?; - - let router = initializers - .iter() - .try_fold(router, |router, initializer| { - initializer.before_middleware(router, &context, &state) - })?; - - // Install middleware, both the default middleware and any provided by the consumer. - info!("Installing middleware. Note: the order of installation is the inverse of the order middleware will run when handling a request."); - let router = default_middleware() - .into_iter() - .chain(A::middleware(&context, &state).into_iter()) - .filter(|middleware| middleware.enabled(&context, &state)) - .unique_by(|middleware| middleware.name()) - .sorted_by(|a, b| Ord::cmp(&a.priority(&context, &state), &b.priority(&context, &state))) - // Reverse due to how Axum's `Router#layer` method adds middleware. - .rev() - .try_fold(router, |router, middleware| { - info!("Installing middleware: `{}`", middleware.name()); - middleware.install(router, &context, &state) - })?; - - let router = initializers - .iter() - .try_fold(router, |router, initializer| { - initializer.after_middleware(router, &context, &state) - })?; - - let router = initializers - .iter() - .try_fold(router, |router, initializer| { - initializer.before_serve(router, &context, &state) - })?; - #[cfg(feature = "sidekiq")] let (processor, sidekiq_cancellation_token, _sidekiq_cancellation_token_drop_guard) = if redis_fetch.is_some() && context.config.worker.sidekiq.queues.is_empty() { @@ -297,17 +236,17 @@ where let cancel_token = CancellationToken::new(); let mut join_set = JoinSet::new(); - // Task to serve the app. - join_set.spawn(cancel_on_error( - cancel_token.clone(), - context.clone(), - A::serve( - router, - token_shutdown_signal(cancel_token.clone()), - context.clone(), - state.clone(), - ), - )); + + // Spawn tasks for the app's services + for service in services { + let context = context.clone(); + let state = state.clone(); + let cancel_token = cancel_token.clone(); + join_set.spawn(Box::pin(async move { + service.run(context, state, cancel_token).await + })); + } + // Task to run the sidekiq processor #[cfg(feature = "sidekiq")] join_set.spawn(Box::pin(async { @@ -403,31 +342,6 @@ pub trait App: Send + Sync { Ok(state) } - #[cfg(not(feature = "open-api"))] - fn router(_config: &AppConfig) -> Router; - - #[cfg(feature = "open-api")] - fn router(_config: &AppConfig) -> ApiRouter; - - #[cfg(feature = "open-api")] - fn api_docs(config: &AppConfig) -> impl Fn(TransformOpenApi) -> TransformOpenApi { - |api| { - api.title(&config.app.name) - .description(&format!("# {}", config.app.name)) - } - } - - fn middleware( - _context: &AppContext, - _state: &Self::State, - ) -> Vec>> { - Default::default() - } - - fn initializers(_context: &AppContext) -> Vec>> { - Default::default() - } - /// Worker queue names can either be provided here, or as config values. If provided here /// the consumer is able to use string constants, which can be used when creating a worker /// instance. This can reduce the risk of copy/paste errors and typos. @@ -445,24 +359,11 @@ pub trait App: Send + Sync { Ok(()) } - async fn serve( - router: Router, - shutdown_signal: F, - context: Arc, - _state: Arc, - ) -> anyhow::Result<()> - where - F: Future + Send + 'static, - { - let server_addr = context.config.server.url(); - info!("Server will start at {server_addr}"); - - let app_listener = tokio::net::TcpListener::bind(server_addr).await?; - axum::serve(app_listener, router) - .with_graceful_shutdown(shutdown_signal) - .await?; - - Ok(()) + async fn services( + _context: &AppContext, + _state: &Self::State, + ) -> anyhow::Result>>> { + Ok(Default::default()) } /// Override to provide a custom shutdown signal. Roadster provides some default shutdown diff --git a/src/app_context.rs b/src/app_context.rs index 4dd8a48e..023538fa 100644 --- a/src/app_context.rs +++ b/src/app_context.rs @@ -1,7 +1,5 @@ use std::sync::Arc; -#[cfg(feature = "open-api")] -use aide::openapi::OpenApi; #[cfg(feature = "db-sql")] use sea_orm::DatabaseConnection; @@ -20,8 +18,6 @@ pub struct AppContext { /// config is set to zero, in which case the [sidekiq::Processor] would also not be started. #[cfg(feature = "sidekiq")] pub redis_fetch: Option, - #[cfg(feature = "open-api")] - pub api: Arc, // Prevent consumers from directly creating an AppContext _private: (), } @@ -32,7 +28,6 @@ impl AppContext { #[cfg(feature = "db-sql")] db: DatabaseConnection, #[cfg(feature = "sidekiq")] redis_enqueue: sidekiq::RedisPool, #[cfg(feature = "sidekiq")] redis_fetch: Option, - #[cfg(feature = "open-api")] api: Arc, ) -> anyhow::Result { let context = Self { config, @@ -42,8 +37,6 @@ impl AppContext { redis_enqueue, #[cfg(feature = "sidekiq")] redis_fetch, - #[cfg(feature = "open-api")] - api, _private: (), }; Ok(context) diff --git a/src/cli/list_routes.rs b/src/cli/list_routes.rs index f8d82a64..297bb59e 100644 --- a/src/cli/list_routes.rs +++ b/src/cli/list_routes.rs @@ -1,31 +1,4 @@ -use async_trait::async_trait; use clap::Parser; -use tracing::info; - -use crate::app::App; -use crate::app_context::AppContext; -use crate::cli::{RoadsterCli, RunRoadsterCommand}; #[derive(Debug, Parser)] pub struct ListRoutesArgs {} - -#[async_trait] -impl RunRoadsterCommand for ListRoutesArgs -where - A: App, -{ - async fn run( - &self, - _app: &A, - _cli: &RoadsterCli, - context: &AppContext, - ) -> anyhow::Result { - info!("API routes:"); - context - .api - .as_ref() - .operations() - .for_each(|(path, method, _operation)| info!("[{method}]\t{path}")); - Ok(true) - } -} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 007600c9..25ed69c3 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -154,9 +154,17 @@ where async fn run(&self, app: &A, cli: &RoadsterCli, context: &AppContext) -> anyhow::Result { match self { #[cfg(feature = "open-api")] - RoadsterSubCommand::ListRoutes(args) => args.run(app, cli, context).await, + RoadsterSubCommand::ListRoutes(_) => { + #[allow(unused_doc_comments)] + /// Implemented by [crate::service::http::http_service::HttpService] + Ok(false) + } #[cfg(feature = "open-api")] - RoadsterSubCommand::OpenApi(args) => args.run(app, cli, context).await, + RoadsterSubCommand::OpenApi(_) => { + #[allow(unused_doc_comments)] + /// Implemented by [crate::service::http::http_service::HttpService] + Ok(false) + } #[cfg(feature = "db-sql")] RoadsterSubCommand::Migrate(args) => args.run(app, cli, context).await, RoadsterSubCommand::PrintConfig(args) => args.run(app, cli, context).await, diff --git a/src/cli/open_api_schema.rs b/src/cli/open_api_schema.rs index eb2fb85c..3f37aeda 100644 --- a/src/cli/open_api_schema.rs +++ b/src/cli/open_api_schema.rs @@ -1,14 +1,5 @@ -use std::fs::File; -use std::io::Write; -use std::path::PathBuf; - -use async_trait::async_trait; use clap::Parser; -use tracing::info; - -use crate::app::App; -use crate::app_context::AppContext; -use crate::cli::{RoadsterCli, RunRoadsterCommand}; +use std::path::PathBuf; #[derive(Debug, Parser)] pub struct OpenApiArgs { @@ -19,31 +10,3 @@ pub struct OpenApiArgs { #[clap(short, long, default_value_t = false)] pub pretty_print: bool, } - -#[async_trait] -impl RunRoadsterCommand for OpenApiArgs -where - A: App, -{ - async fn run( - &self, - _app: &A, - _cli: &RoadsterCli, - context: &AppContext, - ) -> anyhow::Result { - let schema_json = if self.pretty_print { - serde_json::to_string_pretty(context.api.as_ref())? - } else { - serde_json::to_string(context.api.as_ref())? - }; - if let Some(path) = &self.output { - info!("Writing schema to {:?}", path); - write!(File::create(path)?, "{schema_json}")?; - } else { - info!("OpenAPI schema:"); - info!("{schema_json}"); - }; - - Ok(true) - } -} diff --git a/src/lib.rs b/src/lib.rs index 7ae7abe6..0f504ba2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ pub mod cli; pub mod config; pub mod controller; pub mod initializer; +pub mod service; pub mod tracing; pub mod util; pub mod view; diff --git a/src/service/http/http_service.rs b/src/service/http/http_service.rs new file mode 100644 index 00000000..636f0fed --- /dev/null +++ b/src/service/http/http_service.rs @@ -0,0 +1,109 @@ +use crate::app::App; +use crate::app_context::AppContext; +#[cfg(feature = "open-api")] +use crate::cli::{RoadsterCli, RoadsterCommand, RoadsterSubCommand}; +use crate::service::http::http_service_builder::HttpServiceBuilder; +use crate::service::AppService; +#[cfg(feature = "open-api")] +use aide::openapi::OpenApi; +use async_trait::async_trait; +use axum::Router; +#[cfg(all(feature = "cli", feature = "open-api"))] +use std::fs::File; +#[cfg(all(feature = "cli", feature = "open-api"))] +use std::io::Write; +#[cfg(all(feature = "cli", feature = "open-api"))] +use std::path::PathBuf; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; +use tracing::info; + +pub struct HttpService { + pub(crate) router: Router, + #[cfg(feature = "open-api")] + pub(crate) api: Arc, +} + +#[async_trait] +impl AppService for HttpService { + #[cfg(feature = "cli")] + async fn handle_cli( + &self, + roadster_cli: &RoadsterCli, + _app_cli: &A::Cli, + _app_context: &AppContext, + _app_state: &A::State, + ) -> anyhow::Result { + if let Some(command) = roadster_cli.command.as_ref() { + match command { + RoadsterCommand::Roadster(args) => match &args.command { + #[cfg(feature = "open-api")] + RoadsterSubCommand::ListRoutes(_) => { + self.list_routes(); + return Ok(true); + } + #[cfg(feature = "open-api")] + RoadsterSubCommand::OpenApi(args) => { + self.open_api_schema(args.pretty_print, args.output.as_ref())?; + return Ok(true); + } + _ => {} + }, + } + } + Ok(false) + } + + async fn run( + &self, + app_context: Arc, + _app_state: Arc, + cancel_token: CancellationToken, + ) -> anyhow::Result<()> { + let server_addr = app_context.config.server.url(); + info!("Server will start at {server_addr}"); + + let app_listener = tokio::net::TcpListener::bind(server_addr).await?; + axum::serve(app_listener, self.router.clone()) + .with_graceful_shutdown(Box::pin(async move { cancel_token.cancelled().await })) + .await?; + + Ok(()) + } +} + +impl HttpService { + pub fn builder(path_root: &str, context: &AppContext) -> HttpServiceBuilder { + HttpServiceBuilder::new(path_root, context) + } + + #[cfg(feature = "open-api")] + pub fn list_routes(&self) { + info!("API routes:"); + self.api + .as_ref() + .operations() + .for_each(|(path, method, _operation)| info!("[{method}]\t{path}")); + } + + #[cfg(feature = "open-api")] + pub fn open_api_schema( + &self, + pretty_print: bool, + output: Option<&PathBuf>, + ) -> anyhow::Result<()> { + let schema_json = if pretty_print { + serde_json::to_string_pretty(self.api.as_ref())? + } else { + serde_json::to_string(self.api.as_ref())? + }; + if let Some(path) = output { + info!("Writing schema to {:?}", path); + write!(File::create(path)?, "{schema_json}")?; + } else { + info!("OpenAPI schema:"); + info!("{schema_json}"); + }; + Ok(()) + } +} diff --git a/src/service/http/http_service_builder.rs b/src/service/http/http_service_builder.rs new file mode 100644 index 00000000..5a85f43f --- /dev/null +++ b/src/service/http/http_service_builder.rs @@ -0,0 +1,147 @@ +use crate::app::App; +use crate::app_context::AppContext; +use crate::controller::default_routes; +use crate::controller::middleware::default::default_middleware; +use crate::controller::middleware::Middleware; +use crate::initializer::default::default_initializers; +use crate::initializer::Initializer; +use crate::service::http::http_service::HttpService; +#[cfg(feature = "open-api")] +use aide::axum::ApiRouter; +#[cfg(feature = "open-api")] +use aide::openapi::OpenApi; +#[cfg(feature = "open-api")] +use aide::transform::TransformOpenApi; +#[cfg(feature = "open-api")] +use axum::Extension; +#[cfg(not(feature = "open-api"))] +use axum::Router; +use itertools::Itertools; +#[cfg(feature = "open-api")] +use std::sync::Arc; +use tracing::info; + +pub struct HttpServiceBuilder { + #[cfg(not(feature = "open-api"))] + router: Router, + #[cfg(feature = "open-api")] + router: ApiRouter, + #[cfg(feature = "open-api")] + api_docs: Box TransformOpenApi>, + middleware: Vec>>, + initializers: Vec>>, +} + +impl HttpServiceBuilder { + pub fn new(path_root: &str, app_context: &AppContext) -> Self { + #[cfg(feature = "open-api")] + let app_name = app_context.config.app.name.clone(); + Self { + router: default_routes(path_root, &app_context.config), + #[cfg(feature = "open-api")] + api_docs: Box::new(move |api| { + api.title(&app_name).description(&format!("# {}", app_name)) + }), + middleware: default_middleware(), + initializers: default_initializers(), + } + } + + pub fn build(self, context: &AppContext, state: &A::State) -> anyhow::Result { + #[cfg(not(feature = "open-api"))] + let router = self.router; + + #[cfg(feature = "open-api")] + let (router, api) = { + let mut api = OpenApi::default(); + let api_docs = self.api_docs; + let router = self.router.finish_api_with(&mut api, api_docs); + // Arc is very important here or we will face massive memory and performance issues + let api = Arc::new(api); + let router = router.layer(Extension(api.clone())); + (router, api) + }; + + let router = router.with_state::<()>(state.clone()); + + let initializers = self + .initializers + .into_iter() + .filter(|initializer| initializer.enabled(context, state)) + .unique_by(|initializer| initializer.name()) + .sorted_by(|a, b| Ord::cmp(&a.priority(context, state), &b.priority(context, state))) + .collect_vec(); + + let router = initializers + .iter() + .try_fold(router, |router, initializer| { + initializer.after_router(router, context, state) + })?; + + let router = initializers + .iter() + .try_fold(router, |router, initializer| { + initializer.before_middleware(router, context, state) + })?; + + info!("Installing middleware. Note: the order of installation is the inverse of the order middleware will run when handling a request."); + let router = self + .middleware + .into_iter() + .filter(|middleware| middleware.enabled(context, state)) + .unique_by(|middleware| middleware.name()) + .sorted_by(|a, b| Ord::cmp(&a.priority(context, state), &b.priority(context, state))) + // Reverse due to how Axum's `Router#layer` method adds middleware. + .rev() + .try_fold(router, |router, middleware| { + info!("Installing middleware: `{}`", middleware.name()); + middleware.install(router, context, state) + })?; + + let router = initializers + .iter() + .try_fold(router, |router, initializer| { + initializer.after_middleware(router, context, state) + })?; + + let router = initializers + .iter() + .try_fold(router, |router, initializer| { + initializer.before_serve(router, context, state) + })?; + + Ok(HttpService { + router, + #[cfg(feature = "open-api")] + api, + }) + } + + #[cfg(not(feature = "open-api"))] + pub fn router(mut self, router: Router) -> Self { + self.router = self.router.merge(router); + self + } + + #[cfg(feature = "open-api")] + pub fn router(mut self, router: ApiRouter) -> Self { + self.router = self.router.merge(router); + self + } + + #[cfg(feature = "open-api")] + pub fn api_docs(mut self, api_docs: Box TransformOpenApi>) -> Self { + self.api_docs = api_docs; + self + } + + pub fn initializer(mut self, initializer: Box>) -> Self { + self.initializers.push(initializer); + self + } + + pub fn middleware(mut self, middleware: Box>) -> Self { + self.middleware.push(middleware); + self + } +} diff --git a/src/service/http/mod.rs b/src/service/http/mod.rs new file mode 100644 index 00000000..10fe8046 --- /dev/null +++ b/src/service/http/mod.rs @@ -0,0 +1,2 @@ +pub mod http_service; +pub mod http_service_builder; diff --git a/src/service/mod.rs b/src/service/mod.rs new file mode 100644 index 00000000..b11b56e6 --- /dev/null +++ b/src/service/mod.rs @@ -0,0 +1,32 @@ +use crate::app::App; +use crate::app_context::AppContext; +#[cfg(feature = "cli")] +use crate::cli::RoadsterCli; +use async_trait::async_trait; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; + +pub mod http; + +// Todo: add doc comments +// Todo: add/re-arrange app config fields to allow configuring services via app config +#[async_trait] +pub trait AppService: Send + Sync { + #[cfg(feature = "cli")] + async fn handle_cli( + &self, + _roadster_cli: &RoadsterCli, + _app_cli: &A::Cli, + _app_context: &AppContext, + _app_state: &A::State, + ) -> anyhow::Result { + Ok(false) + } + + async fn run( + &self, + app_context: Arc, + app_state: Arc, + cancel_token: CancellationToken, + ) -> anyhow::Result<()>; +}