From 2246d569369a78cedb3171622357d50b2cc3ef06 Mon Sep 17 00:00:00 2001 From: Spencer Ferris <3319370+spencewenski@users.noreply.github.com> Date: Sat, 16 Nov 2024 00:20:29 -0800 Subject: [PATCH] feat: `Provide` and `ProvideRef` traits to provide `AppContext` objects (#510) Also, annotate with `mockall::automock` when the new `testing-mocks` feature is enabled to allow consumers to easily provide mocks to methods expecting a trait impl. This is significantly simpler than mocking the `AppContext` struct itself, so we will not do that for now. We can consider doing it later if people really want it. Closes https://github.com/roadster-rs/roadster/issues/495 --- Cargo.toml | 5 +- examples/full/src/app_state.rs | 19 +++++ src/app/context.rs | 143 +++++++++++++++++++++++++++++++-- 3 files changed, 161 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b495ac2d..eb57bdc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ otel = ["dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-otlp", grpc = ["dep:tonic"] testing = ["dep:insta", "dep:rstest", "dep:testcontainers-modules"] test-containers = ["testing", "dep:testcontainers-modules"] +testing-mocks = ["testing", "dep:mockall"] config-yml = ["config/yaml"] [dependencies] @@ -96,6 +97,7 @@ tonic = { workspace = true, optional = true } insta = { workspace = true, optional = true } rstest = { workspace = true, optional = true } testcontainers-modules = { workspace = true, features = ["postgres", "redis"], optional = true } +mockall = { workspace = true, optional = true } # Others anyhow = { workspace = true } @@ -128,7 +130,7 @@ reqwest = { workspace = true } [dev-dependencies] cargo-husky = { version = "1.5.0", default-features = false, features = ["user-hooks"] } insta = { workspace = true, features = ["json"] } -mockall = "0.13.0" +mockall = { workspace = true } mockall_double = "0.3.1" rstest = { workspace = true } @@ -174,6 +176,7 @@ rusty-sidekiq = { version = "0.11.0", default-features = false } insta = { version = "1.39.0", features = ["toml", "filters"] } rstest = { version = "0.23.0", default-features = false } testcontainers-modules = { version = "0.11.3" } +mockall = "0.13.0" # Others # Todo: minimize tokio features included in `roadster` diff --git a/examples/full/src/app_state.rs b/examples/full/src/app_state.rs index fed15f6f..8c8586d3 100644 --- a/examples/full/src/app_state.rs +++ b/examples/full/src/app_state.rs @@ -1,7 +1,26 @@ use axum::extract::FromRef; use roadster::app::context::AppContext; +use roadster::app::context::{Provide, ProvideRef}; #[derive(Clone, FromRef)] pub struct AppState { pub app_context: AppContext, } + +impl Provide for AppState +where + AppContext: Provide, +{ + fn provide(&self) -> T { + Provide::provide(&self.app_context) + } +} + +impl ProvideRef for AppState +where + AppContext: ProvideRef, +{ + fn provide(&self) -> &T { + ProvideRef::provide(&self.app_context) + } +} diff --git a/src/app/context.rs b/src/app/context.rs index 87b464ad..2feea347 100644 --- a/src/app/context.rs +++ b/src/app/context.rs @@ -56,7 +56,7 @@ impl AppContext { let sidekiq_config = &config.service.sidekiq; let redis_config = &sidekiq_config.custom.redis; let redis = sidekiq::RedisConnectionManager::new(redis_config.uri.to_string())?; - let redis_enqueue = { + let redis_enqueue = RedisEnqueue({ let pool = bb8::Pool::builder().min_idle(redis_config.enqueue_pool.min_idle); let pool = redis_config .enqueue_pool @@ -64,7 +64,7 @@ impl AppContext { .iter() .fold(pool, |pool, max_conns| pool.max_size(*max_conns)); pool.build(redis.clone()).await? - }; + }); let redis_fetch = if redis_config .fetch_pool .max_connections @@ -136,7 +136,9 @@ impl AppContext { #[cfg(feature = "sidekiq")] if let Some(redis) = redis { - inner.expect_redis_enqueue().return_const(redis.clone()); + inner + .expect_redis_enqueue() + .return_const(RedisEnqueue(redis.clone())); inner.expect_redis_fetch().return_const(Some(redis)); } else { inner.expect_redis_fetch().return_const(None); @@ -170,11 +172,13 @@ impl AppContext { self.inner.db() } + // todo: In the next breaking version, return the pool wrapped in the `RedisEnqueue` new-type #[cfg(feature = "sidekiq")] pub fn redis_enqueue(&self) -> &sidekiq::RedisPool { self.inner.redis_enqueue() } + // todo: In the next breaking version, return the pool wrapped in the `RedisFetch` new-type #[cfg(feature = "sidekiq")] pub fn redis_fetch(&self) -> &Option { self.inner.redis_fetch() @@ -196,6 +200,134 @@ impl AppContext { } } +#[cfg_attr(any(test, feature = "testing-mocks"), mockall::automock)] +pub trait ProvideRef { + fn provide(&self) -> &T; +} + +#[cfg_attr(any(test, feature = "testing-mocks"), mockall::automock)] +pub trait Provide { + fn provide(&self) -> T; +} + +impl ProvideRef for AppContext { + fn provide(&self) -> &AppConfig { + self.config() + } +} + +impl Provide for AppContext { + fn provide(&self) -> AppConfig { + self.config().clone() + } +} + +impl ProvideRef for AppContext { + fn provide(&self) -> &AppMetadata { + self.metadata() + } +} + +impl Provide for AppContext { + fn provide(&self) -> AppMetadata { + self.metadata().clone() + } +} + +impl Provide>> for AppContext { + fn provide(&self) -> Vec> { + self.health_checks() + } +} + +#[cfg(feature = "db-sql")] +impl ProvideRef for AppContext { + fn provide(&self) -> &DatabaseConnection { + self.db() + } +} + +#[cfg(feature = "db-sql")] +impl Provide for AppContext { + fn provide(&self) -> DatabaseConnection { + self.db().clone() + } +} + +#[cfg(feature = "email-smtp")] +impl ProvideRef for AppContext { + fn provide(&self) -> &lettre::SmtpTransport { + self.smtp() + } +} + +#[cfg(feature = "email-smtp")] +impl Provide for AppContext { + fn provide(&self) -> lettre::SmtpTransport { + self.smtp().clone() + } +} + +#[cfg(feature = "email-sendgrid")] +impl ProvideRef for AppContext { + fn provide(&self) -> &sendgrid::v3::Sender { + self.sendgrid() + } +} + +#[cfg(feature = "email-sendgrid")] +impl Provide for AppContext { + fn provide(&self) -> sendgrid::v3::Sender { + self.sendgrid().clone() + } +} + +#[cfg(feature = "sidekiq")] +pub struct RedisEnqueue(sidekiq::RedisPool); +#[cfg(feature = "sidekiq")] +pub struct RedisFetch(sidekiq::RedisPool); + +#[cfg(feature = "sidekiq")] +impl std::ops::Deref for RedisEnqueue { + type Target = sidekiq::RedisPool; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(feature = "sidekiq")] +impl std::ops::Deref for RedisFetch { + type Target = sidekiq::RedisPool; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(feature = "sidekiq")] +impl Provide for AppContext { + fn provide(&self) -> RedisEnqueue { + RedisEnqueue(self.redis_enqueue().clone()) + } +} + +#[cfg(feature = "sidekiq")] +impl ProvideRef for AppContext { + fn provide(&self) -> &RedisEnqueue { + self.inner.redis_enqueue() + } +} + +#[cfg(feature = "sidekiq")] +impl Provide> for AppContext { + fn provide(&self) -> Option { + self.redis_fetch() + .as_ref() + .map(|redis_fetch| RedisFetch(redis_fetch.clone())) + } +} + #[cfg(all(feature = "db-sql", feature = "test-containers"))] #[cfg_attr(test, allow(dead_code))] async fn db_test_container( @@ -289,10 +421,11 @@ struct AppContextInner { >, >, #[cfg(feature = "sidekiq")] - redis_enqueue: sidekiq::RedisPool, + redis_enqueue: RedisEnqueue, /// The Redis connection pool used by [sidekiq::Processor] to fetch Sidekiq jobs from Redis. /// May be `None` if the [fetch_pool.max_connections][crate::config::service::worker::sidekiq::ConnectionPool] /// config is set to zero, in which case the [sidekiq::Processor] would also not be started. + // todo: In the next breaking version, wrap the pool in the `RedisFetch` new-type #[cfg(feature = "sidekiq")] redis_fetch: Option, #[cfg(all(feature = "sidekiq", feature = "test-containers"))] @@ -340,7 +473,7 @@ impl AppContextInner { } #[cfg(feature = "sidekiq")] - fn redis_enqueue(&self) -> &sidekiq::RedisPool { + fn redis_enqueue(&self) -> &RedisEnqueue { &self.redis_enqueue }