From ea9ec6b6552e3c1394d6b54a2e6b6bc6725bb522 Mon Sep 17 00:00:00 2001 From: Kilerd Date: Mon, 14 Sep 2026 14:06:53 +0900 Subject: [PATCH] fix(types)!: scope initialization bounds to loading and default constructors --- MIGRATION.md | 39 +++++ README.md | 38 ++++- gotcha/src/builder.rs | 304 +++++++++++++++++++++------------- gotcha/src/config.rs | 9 +- gotcha/src/lib.rs | 39 +++-- gotcha/src/message.rs | 12 +- gotcha/src/task.rs | 11 +- gotcha/tests/runtime_types.rs | 141 ++++++++++++++++ gotcha_macro/src/lib.rs | 10 +- 9 files changed, 443 insertions(+), 160 deletions(-) create mode 100644 gotcha/tests/runtime_types.rs diff --git a/MIGRATION.md b/MIGRATION.md index 755e264..f77bbf1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,6 @@ # Migration Guide +- [Unreleased: runtime type bounds](#unreleased-runtime-type-bounds) - [Unreleased: HTTP response contracts](#unreleased-http-response-contracts) - [Unreleased: application and route composition](#unreleased-application-and-route-composition) - [Unreleased: documented method routers](#unreleased-documented-method-routers) @@ -12,6 +13,44 @@ --- +# Unreleased: runtime type bounds + +Initialized values no longer have to implement loading or default-construction traits just to +be stored in a context, extracted by handlers, or used by messages and scheduled tasks. + +- Use `Gotcha::from_state(value)` for state without `Default` and automatic empty configuration. +- Use `Gotcha::from_context(GotchaContext { state, config })` when both values are already ready. + State and application configuration only need `Clone + Send + Sync + 'static` for runtime use. +- `with_state::()`, `with_config::()`, and `with_types::()` retain their default-based + convenience behavior. Only these constructors choose automatic initialization and require its + bounds; creating defaults and reading files still happen at startup, after any explicit overrides. +- `.state(...)`, `.config(...)`, route composition, task registration, and serving no longer carry + unrelated `Default` or serde requirements. `.build_config(...)` requires `C: DeserializeOwned`. + +`ConfigWrapper` and `GotchaContext` impose no bounds at their type definitions. +`ConfigWrapper`'s derived implementations request `Serialize`, `Deserialize`, `Clone`, or `Default` +only for the corresponding operation. `#[state]`, `#[config]`, and `FromRef` extraction no longer +impose loading bounds on the stored configuration or unrelated state type. + +For the trait API, `GotchaApp::Config` only requires the runtime bounds. `build_router(context)` +can therefore accept an explicit configuration without serde or `Default`. `config()` and `run()` +require `Self::Config: DeserializeOwned`, because their contract includes configuration loading. +This method bound applies even when `config()` is overridden; use an explicit context if the +configuration cannot be deserialized. Generic callers of these methods must now state that bound +explicitly. `Serialize` and `Default` are no longer required by trait-based loading. + +The existing `GotchaConfig` marker retains its original bounds for compatibility with user generic +code. Runtime containers and the extraction macros no longer require it. + +Explicit configuration still wins over registered sources. Selected sources remain strict, and +automatic default loading retains its warning and fallback behavior. Listener address precedence +is unchanged: builder `run()` uses `.host()`/`.port()`, while `listen()`/`listen_on()` use their +argument; providing `ConfigWrapper::server` does not change that builder policy. + +See the [initialized-values example](README.md#initialized-state-and-configuration). + +--- + # Unreleased: HTTP response contracts **`Schematic` no longer implies `Responsible`.** A data schema does not determine how a value is diff --git a/README.md b/README.md index dc9ad33..42ddbcf 100644 --- a/README.md +++ b/README.md @@ -48,22 +48,52 @@ struct User { } ``` +### Initialized state and configuration + +Use `Gotcha::from_state(value)` for state such as an initialized connection pool. To supply both +state and configuration, use `Gotcha::from_context(context)`. These values only need +`Clone + Send + Sync + 'static`; neither needs `Default`, and configuration needs no serde traits: + +```rust,no_run +use gotcha::prelude::*; + +#[state] +#[derive(Clone)] +struct AppState { started_at: std::time::Instant } + +#[config] +#[derive(Clone)] +struct AppConfig { deployment: String } + +let app = Gotcha::from_context(GotchaContext { + state: AppState { started_at: std::time::Instant::now() }, + config: ConfigWrapper { + server: ServerConfig::default(), + app: AppConfig { deployment: "production".into() }, + }, +}) +.get("/deployment", |State(config): State| async move { config.deployment }); +``` + +`with_state::()` and `with_types::()` remain available when you want automatic default +construction. File loading requires `Deserialize`; serializing a configuration requires `Serialize`. + ### Advanced Trait API (For complex applications) ```rust,no_run use gotcha::prelude::*; #[config] -#[derive(Debug, Deserialize, Serialize, Clone, Default)] +#[derive(Debug, Deserialize, Clone)] pub struct Config { pub database_url: String, pub redis_url: String, } #[state] -#[derive(Clone, Default)] +#[derive(Clone)] pub struct AppState { - pub started_at: u64, + pub started_at: std::time::Instant, } pub struct App {} @@ -82,7 +112,7 @@ impl GotchaApp for App { async fn state(&self, config: &ConfigWrapper) -> GotchaResult { // Open database connections here; `config` is already loaded. let _ = &config.database_url; - Ok(AppState::default()) + Ok(AppState { started_at: std::time::Instant::now() }) } } diff --git a/gotcha/src/builder.rs b/gotcha/src/builder.rs index ffb9c8b..1db60af 100644 --- a/gotcha/src/builder.rs +++ b/gotcha/src/builder.rs @@ -26,6 +26,7 @@ use std::str::FromStr; use axum::extract::Request; use axum::handler::Handler; use axum::routing::MethodRouter as AxumMethodRouter; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use tower_layer::Layer; use tower_service::Service; @@ -41,6 +42,70 @@ use crate::GotchaContext; #[cfg(feature = "task")] type TaskRegistrar = Box) + Send>; +enum StateSource { + Provided(S), + Default(fn() -> S), +} + +impl StateSource { + fn resolve(&self) -> S { + match self { + Self::Provided(state) => state.clone(), + Self::Default(create) => create(), + } + } +} + +enum Configuration { + Provided(ConfigWrapper), + Deferred { + sources: Option, + load: fn(Option<&ConfigBuilder>, ServerConfig) -> GotchaResult>, + }, +} + +impl Configuration { + fn automatic() -> Self + where + C: DeserializeOwned + Default, + { + Self::Deferred { + sources: None, + load: load_config::, + } + } + + fn update_sources(&mut self, update: impl FnOnce(Option) -> ConfigBuilder) { + // Explicit configuration always wins, regardless of when sources are registered. + if let Self::Deferred { sources, .. } = self { + *sources = Some(update(sources.take())); + } + } + + fn resolve(&self, server: ServerConfig) -> GotchaResult> + where + C: Clone, + { + match self { + Self::Provided(config) => Ok(config.clone()), + Self::Deferred { sources, load } => load(sources.as_ref(), server), + } + } +} + +fn load_config(sources: Option<&ConfigBuilder>, server: ServerConfig) -> GotchaResult> { + if let Some(builder) = sources { + return Ok(builder.clone().build()?); + } + match GotchaConfigLoader::load::>(std::env::var("GOTCHA_ACTIVE_PROFILE").ok()) { + Ok(config) => Ok(config), + Err(e) => { + tracing::warn!("Failed to load configuration: {e}, using defaults"); + Ok(ConfigWrapper { server, app: C::default() }) + } + } +} + /// Default empty configuration for simple applications #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct EmptyConfig {} @@ -53,14 +118,13 @@ pub struct EmptyState {} pub struct Gotcha where S: Clone + Send + Sync + 'static, - C: Clone + Send + Sync + 'static + Serialize + for<'de> Deserialize<'de> + Default, + C: Clone + Send + Sync + 'static, { router: GotchaRouter>, host: String, port: u16, - state: Option, - config: Option>, - config_builder: Option, + state: StateSource, + config: Configuration, #[cfg(feature = "task")] tasks: Vec>, } @@ -82,16 +146,7 @@ impl Gotcha { /// .get("/", || async { "Hello World" }); /// ``` pub fn new() -> Self { - Self { - router: GotchaRouter::default(), - host: "127.0.0.1".to_string(), - port: 3000, - state: None, - config: None, - config_builder: None, - #[cfg(feature = "task")] - tasks: Vec::new(), - } + Self::with_types::() } } @@ -100,6 +155,8 @@ impl Gotcha { /// /// This is a convenience method that allows you to specify custom types /// without needing to provide dummy type parameters. + /// Defaults and configuration loading are deferred until startup. For already initialized + /// values, use [`Self::from_state`] or [`Self::from_context`] without those bounds. /// /// # Example /// ```no_run @@ -119,21 +176,13 @@ impl Gotcha { pub fn with_types() -> Gotcha where S: Clone + Send + Sync + 'static + Default, - C: Clone + Send + Sync + 'static + Serialize + for<'de> Deserialize<'de> + Default, + C: Clone + Send + Sync + 'static + DeserializeOwned + Default, { - Gotcha { - router: GotchaRouter::default(), - host: "127.0.0.1".to_string(), - port: 3000, - state: None, - config: None, - config_builder: None, - #[cfg(feature = "task")] - tasks: Vec::new(), - } + Gotcha::from_sources(StateSource::Default(S::default), Configuration::automatic()) } /// Create a Gotcha builder with custom state type and default config + /// Use [`Self::from_state`] when the state has no meaningful `Default`. /// /// # Example /// ```no_run @@ -153,16 +202,7 @@ impl Gotcha { where S: Clone + Send + Sync + 'static + Default, { - Gotcha { - router: GotchaRouter::default(), - host: "127.0.0.1".to_string(), - port: 3000, - state: None, - config: None, - config_builder: None, - #[cfg(feature = "task")] - tasks: Vec::new(), - } + Self::with_types::() } /// Create a Gotcha builder with custom config type and default state @@ -186,35 +226,69 @@ impl Gotcha { /// ``` pub fn with_config() -> Gotcha where - C: Clone + Send + Sync + 'static + Serialize + for<'de> Deserialize<'de> + Default, + C: Clone + Send + Sync + 'static + DeserializeOwned + Default, + { + Self::with_types::() + } + + /// Create an application from initialized state, with automatic empty configuration. + /// The state does not need `Default`. Use [`Self::from_context`] to also supply configuration. + /// + /// ```no_run + /// use gotcha::{Gotcha, State, state}; + /// #[state] + /// #[derive(Clone)] + /// struct AppState { started_at: std::time::Instant } + /// let app = Gotcha::from_state(AppState { started_at: std::time::Instant::now() }) + /// .get("/uptime", |State(state): State| async move { + /// state.started_at.elapsed().as_secs().to_string() + /// }); + /// ``` + pub fn from_state(state: S) -> Gotcha + where + S: Clone + Send + Sync + 'static, + { + Gotcha::from_sources(StateSource::Provided(state), Configuration::automatic()) + } + + /// Create an application from initialized state and configuration, without loading files. + /// Neither type needs `Default`; the configuration does not need serde traits. + /// Listener address selection remains controlled by `host`/`port`, `run`, or `listen`. + pub fn from_context(context: GotchaContext) -> Gotcha + where + S: Clone + Send + Sync + 'static, + C: Clone + Send + Sync + 'static, { - Gotcha { + Gotcha::from_sources(StateSource::Provided(context.state), Configuration::Provided(context.config)) + } +} + +impl Gotcha +where + S: Clone + Send + Sync + 'static, + C: Clone + Send + Sync + 'static, +{ + fn from_sources(state: StateSource, config: Configuration) -> Self { + Self { router: GotchaRouter::default(), host: "127.0.0.1".to_string(), port: 3000, - state: None, - config: None, - config_builder: None, + state, + config, #[cfg(feature = "task")] tasks: Vec::new(), } } -} -impl Gotcha -where - S: Clone + Send + Sync + 'static + Default, - C: Clone + Send + Sync + 'static + Serialize + for<'de> Deserialize<'de> + Default, -{ /// Set the application state pub fn state(mut self, state: S) -> Self { - self.state = Some(state); + self.state = StateSource::Provided(state); self } /// Set the application configuration pub fn config(mut self, config: ConfigWrapper) -> Self { - self.config = Some(config); + self.config = Configuration::Provided(config); self } @@ -234,12 +308,12 @@ where pub fn build_config(mut self, builder_fn: F) -> GotchaResult where F: FnOnce(ConfigBuilder) -> ConfigBuilder, + C: DeserializeOwned, { let builder = Config::builder(); let configured_builder = builder_fn(builder); let config: ConfigWrapper = configured_builder.build()?; - self.config = Some(config); - self.config_builder = None; // Clear any accumulated builder + self.config = Configuration::Provided(config); Ok(self) } @@ -275,8 +349,8 @@ where /// .with_env_config("GOTCHA"); // Both prefixes will be used /// ``` pub fn with_env_config>(mut self, prefix: P) -> Self { - let builder = self.config_builder.take().unwrap_or_else(|| ConfigBuilder::new().enable_vars()); - self.config_builder = Some(builder.env(prefix.as_ref())); + self.config + .update_sources(|builder| builder.unwrap_or_else(|| ConfigBuilder::new().enable_vars()).env(prefix.as_ref())); self } @@ -295,8 +369,8 @@ where /// .with_file_config("local.toml"); // Both files will be loaded /// ``` pub fn with_file_config>(mut self, path: P) -> Self { - let builder = self.config_builder.take().unwrap_or_else(|| ConfigBuilder::new().enable_vars()); - self.config_builder = Some(builder.file(path)); + self.config + .update_sources(|builder| builder.unwrap_or_else(|| ConfigBuilder::new().enable_vars()).file(path)); self } @@ -315,8 +389,8 @@ where /// .with_optional_config("local.toml"); // Both files will be loaded if they exist /// ``` pub fn with_optional_config>(mut self, path: P) -> Self { - let builder = self.config_builder.take().unwrap_or_else(|| ConfigBuilder::new().enable_vars()); - self.config_builder = Some(builder.file_optional(path)); + self.config + .update_sources(|builder| builder.unwrap_or_else(|| ConfigBuilder::new().enable_vars()).file_optional(path)); self } @@ -333,18 +407,13 @@ where /// .with_default_files(); /// ``` pub fn with_default_files(mut self) -> Self { - let mut builder = self.config_builder.take().unwrap_or_default(); - - // Add default file paths - builder = builder.file_optional("configurations/application.toml"); - - // Add profile-specific file if profile is set - if let Ok(profile) = std::env::var("GOTCHA_ACTIVE_PROFILE") { - let profile_path = format!("configurations/application_{}.toml", profile); - builder = builder.file_optional(profile_path); - } - - self.config_builder = Some(builder); + self.config.update_sources(|builder| { + let mut builder = builder.unwrap_or_default().file_optional("configurations/application.toml"); + if let Ok(profile) = std::env::var("GOTCHA_ACTIVE_PROFILE") { + builder = builder.file_optional(format!("configurations/application_{}.toml", profile)); + } + builder + }); self } @@ -360,8 +429,7 @@ where /// .with_default_env(); /// ``` pub fn with_default_env(mut self) -> Self { - let builder = self.config_builder.take().unwrap_or_default(); - self.config_builder = Some(builder.env("APP")); + self.config.update_sources(|builder| builder.unwrap_or_default().env("APP")); self } @@ -378,8 +446,7 @@ where /// .enable_variable_substitution(); /// ``` pub fn enable_variable_substitution(mut self) -> Self { - let builder = self.config_builder.take().unwrap_or_default(); - self.config_builder = Some(builder.enable_vars()); + self.config.update_sources(|builder| builder.unwrap_or_default().enable_vars()); self } @@ -708,7 +775,7 @@ where pub async fn listen_on(self, addr: SocketAddr) -> GotchaResult<()> { tracing::info!("🚀 Starting Gotcha server on {}", addr); - let context = self.build_context().await?; + let context = self.build_context()?; #[cfg(feature = "task")] { @@ -744,34 +811,15 @@ where /// /// Explicitly configured sources propagate loading errors. With no explicit config or /// sources, automatic default loading still warns and falls back to defaults on failure. - async fn build_context(&self) -> GotchaResult> { - let config = match (&self.config, &self.config_builder) { - // Explicit config wins - (Some(config), _) => config.clone(), - // Accumulated configuration sources - (None, Some(builder)) => builder.clone().build::>()?, - // Default loading, falling back to defaults on failure - (None, None) => match GotchaConfigLoader::load::>(std::env::var("GOTCHA_ACTIVE_PROFILE").ok()) { - Ok(config) => config, - Err(e) => { - tracing::warn!("Failed to load configuration: {e}, using defaults"); - ConfigWrapper { - server: ServerConfig { - host: self.host.clone(), - port: self.port, - }, - app: C::default(), - } - } - }, - }; - - let state = match &self.state { - Some(state) => state.clone(), - None => S::default(), - }; - - Ok(GotchaContext { config, state }) + fn build_context(&self) -> GotchaResult> { + let config = self.config.resolve(ServerConfig { + host: self.host.clone(), + port: self.port, + })?; + Ok(GotchaContext { + config, + state: self.state.resolve(), + }) } } @@ -803,6 +851,19 @@ impl Gotcha { mod tests { use super::*; + #[test] + fn provided_state_does_not_construct_the_unused_default() { + #[derive(Clone)] + struct Initialized; + impl Default for Initialized { + fn default() -> Self { + panic!("state was already initialized"); + } + } + let app = Gotcha::with_state::().state(Initialized).config(ConfigWrapper::default()); + app.build_context().unwrap(); + } + #[tokio::test] async fn nested_and_merged_routes_share_the_application_context() { use axum::{ @@ -815,23 +876,28 @@ mod tests { }; use tower::ServiceExt; - async fn read(State(context): State, TestConfig>>) -> String { - format!("{}:{}", context.config.value, context.state.fetch_add(1, Ordering::SeqCst)) + #[derive(Clone)] + struct RuntimeState(Arc); + #[derive(Clone)] + struct RuntimeConfig { + value: String, + } + async fn read(State(context): State>) -> String { + format!("{}:{}", context.config.value, context.state.0.fetch_add(1, Ordering::SeqCst)) } let calls = Arc::new(AtomicUsize::new(41)); - let app = Gotcha::with_types::, TestConfig>() - .state(calls.clone()) - .config(ConfigWrapper { - app: TestConfig { - value: "parent".into(), - ..Default::default() - }, + let app = Gotcha::from_context(GotchaContext { + state: RuntimeState(calls.clone()), + config: ConfigWrapper { + app: RuntimeConfig { value: "parent".into() }, server: ServerConfig::default(), - }) - .nest("/api", GotchaRouter::default().get("/context", read)) - .merge(GotchaRouter::default().get("/context", read)); - let context = app.build_context().await.unwrap(); + }, + }) + .with_file_config("unused-required-file.toml") + .nest("/api", GotchaRouter::default().get("/context", read)) + .merge(GotchaRouter::default().get("/context", read)); + let context = app.build_context().unwrap(); let router = app.router.into_axum_router(context); for (path, expected) in [("/api/context", "parent:41"), ("/context", "parent:42")] { @@ -904,8 +970,8 @@ mod tests { reference: String, } - #[tokio::test] - async fn configuration_sources_preserve_order_and_explicit_error_policy() { + #[test] + fn configuration_sources_preserve_order_and_explicit_error_policy() { const CHILD: &str = "GOTCHA_CONFIG_BUILDER_CHILD"; if std::env::var_os(CHILD).is_none() { let dir = tempfile::tempdir().unwrap(); @@ -959,15 +1025,15 @@ mod tests { "custom-env", ), ] { - let context = builder.enable_variable_substitution().build_context().await.unwrap(); + let context = builder.enable_variable_substitution().build_context().unwrap(); assert_eq!(context.config.value, expected); assert_eq!(context.config.reference, expected, "variables use the final merged configuration"); } std::fs::write("configurations/application.toml", "value = [").unwrap(); - assert!(matches!(app().with_default_config().build_context().await, Err(GotchaError::Config(_)))); + assert!(matches!(app().with_default_config().build_context(), Err(GotchaError::Config(_)))); // Automatic default loading retains its existing fallback until the startup-policy work. - let context = app().build_context().await.unwrap(); + let context = app().build_context().unwrap(); assert_eq!(context.config.value, TestConfig::default().value); } } diff --git a/gotcha/src/config.rs b/gotcha/src/config.rs index e879649..1a667a8 100644 --- a/gotcha/src/config.rs +++ b/gotcha/src/config.rs @@ -3,7 +3,6 @@ use std::path::{Path, PathBuf}; use mofa::{ConfigLoader, EnvironmentSource, FileSource, Source}; -use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -35,18 +34,20 @@ pub type ConfigResult = Result; /// This derefs to the application config, so `config.name` reads the application's field directly /// rather than going through a wrapper level. Handlers usually skip the wrapper entirely and /// extract `State` — see the `#[config]` attribute. +/// Merely storing or reading `T` adds no bounds. The derived serde, `Clone`, and `Default` +/// implementations require only the corresponding trait on `T`. #[derive(Clone, Serialize, Deserialize, Debug, Default)] -pub struct ConfigWrapper { +pub struct ConfigWrapper { /// Framework settings, from the reserved `[server]` section. #[serde(default)] pub server: ServerConfig, /// The application's own settings, living at the top level of the file. - #[serde(bound = "", flatten)] + #[serde(flatten)] pub app: T, } -impl std::ops::Deref for ConfigWrapper { +impl std::ops::Deref for ConfigWrapper { type Target = T; fn deref(&self) -> &T { &self.app diff --git a/gotcha/src/lib.rs b/gotcha/src/lib.rs index e545b14..e100dc3 100644 --- a/gotcha/src/lib.rs +++ b/gotcha/src/lib.rs @@ -119,7 +119,9 @@ pub use tower_http::services::{ServeDir, ServeFile}; /// /// Handlers rarely name this directly — `#[state]` and `#[config]` make `State` and /// `State` extractable instead. -pub struct GotchaContext Deserialize<'de> + Default> { +/// This container imposes no loading or initialization bounds; runtime APIs require +/// `Clone + Send + Sync + 'static` when sharing its values between handlers or tasks. +pub struct GotchaContext { /// The loaded configuration. pub config: ConfigWrapper, /// The application state. @@ -128,8 +130,7 @@ pub struct GotchaContext FromRef> for ConfigWrapper where - State: Clone + Send + Sync + 'static, - Config: Clone + Send + Sync + 'static + Serialize + for<'de> Deserialize<'de> + Default, + Config: Clone, { fn from_ref(context: &GotchaContext) -> Self { context.config.clone() @@ -138,21 +139,17 @@ where /// Lets a handler take `State` to read the bind address. `ServerConfig` is one of /// this crate's own types, so unlike the application's config this needs no attribute macro. -impl FromRef> for crate::config::ServerConfig -where - State: Clone + Send + Sync + 'static, - Config: Clone + Send + Sync + 'static + Serialize + for<'de> Deserialize<'de> + Default, -{ +impl FromRef> for crate::config::ServerConfig { fn from_ref(context: &GotchaContext) -> Self { context.config.server.clone() } } -/// Marker trait bundling the bounds every Gotcha application `Config` must meet. +/// Compatibility bundle of the original configuration bounds. /// -/// Blanket-implemented for every qualifying type. It exists so macro-generated -/// code (the `#[state]` attribute) can bound the config type with a single path -/// (`::gotcha::GotchaConfig`) instead of restating the serde bounds. +/// Existing generic code can keep using this trait. Runtime containers, extractors, messages, +/// and tasks do not require it; loading, serialization, and default construction impose their +/// individual bounds only where needed. pub trait GotchaConfig: Clone + Send + Sync + 'static + Serialize + for<'de> Deserialize<'de> + Default {} impl GotchaConfig for T where T: Clone + Send + Sync + 'static + Serialize + for<'de> Deserialize<'de> + Default {} @@ -163,11 +160,16 @@ impl GotchaConfig for T where T: Clone + Send + Sync + 'static + Serialize + pub trait GotchaApp: Sized + Send + Sync { /// The application state, shared by every handler. type State: Clone + Send + Sync + 'static; - /// The application's own configuration, read from the top level of the config file. - type Config: Clone + Send + Sync + 'static + Serialize + for<'de> Deserialize<'de> + Default; + /// The application's own configuration. Loading via `config()`/`run()` additionally + /// requires `Deserialize`; `build_router()` accepts an already initialized context. + type Config: Clone + Send + Sync + 'static; /// Load the configuration. The default honours `GOTCHA_ACTIVE_PROFILE`. - fn config(&self) -> impl std::future::Future>> + Send { + /// This method's contract requires `Deserialize`, including when overridden. + fn config(&self) -> impl std::future::Future>> + Send + where + Self::Config: for<'de> Deserialize<'de>, + { async move { let config = GotchaConfigLoader::load::>(std::env::var("GOTCHA_ACTIVE_PROFILE").ok())?; Ok(config) @@ -211,7 +213,12 @@ pub trait GotchaApp: Sized + Send + Sync { } /// Load configuration, build state and routes, then serve until shutdown. - fn run(self) -> impl std::future::Future> + Send { + /// For configuration without `Deserialize`, assemble an explicit context using + /// [`Self::build_router`] or [`Gotcha::from_context`]. + fn run(self) -> impl std::future::Future> + Send + where + Self::Config: for<'de> Deserialize<'de>, + { async move { use std::net::{Ipv4Addr, SocketAddrV4}; use std::str::FromStr; diff --git a/gotcha/src/message.rs b/gotcha/src/message.rs index 090cece..0e74cd3 100644 --- a/gotcha/src/message.rs +++ b/gotcha/src/message.rs @@ -34,7 +34,7 @@ use async_trait::async_trait; use axum::extract::FromRef; -use crate::{GotchaConfig, GotchaContext}; +use crate::GotchaContext; /// A unit of asynchronous work, dispatched by a [`Messager`]. /// @@ -44,7 +44,7 @@ use crate::{GotchaConfig, GotchaContext}; pub trait Message: Send + 'static where S: Clone + Send + Sync + 'static, - C: GotchaConfig, + C: Clone + Send + Sync + 'static, { /// The value produced by handling this message. type Output: Send + 'static; @@ -60,7 +60,7 @@ where pub struct Messager where S: Clone + Send + Sync + 'static, - C: GotchaConfig, + C: Clone + Send + Sync + 'static, { context: GotchaContext, } @@ -68,7 +68,7 @@ where impl Clone for Messager where S: Clone + Send + Sync + 'static, - C: GotchaConfig, + C: Clone + Send + Sync + 'static, { fn clone(&self) -> Self { Self { context: self.context.clone() } @@ -78,7 +78,7 @@ where impl Messager where S: Clone + Send + Sync + 'static, - C: GotchaConfig, + C: Clone + Send + Sync + 'static, { /// Create a `Messager` bound to an application context. pub fn new(context: GotchaContext) -> Self { @@ -110,7 +110,7 @@ where impl FromRef> for Messager where S: Clone + Send + Sync + 'static, - C: GotchaConfig, + C: Clone + Send + Sync + 'static, { fn from_ref(context: &GotchaContext) -> Self { Messager::new(context.clone()) diff --git a/gotcha/src/task.rs b/gotcha/src/task.rs index 7efa406..80c839e 100644 --- a/gotcha/src/task.rs +++ b/gotcha/src/task.rs @@ -16,7 +16,7 @@ //! use gotcha::TaskScheduler; //! use std::time::Duration; //! -//! # #[derive(Clone, Default, serde::Serialize, serde::Deserialize)] +//! # #[derive(Clone)] //! # struct Config {} //! fn setup(scheduler: &TaskScheduler<(), Config>) { //! // Schedule a cron task (the expression is a `String`) @@ -44,21 +44,20 @@ use std::str::FromStr; use chrono::Utc; use cron::Schedule; -use serde::{Deserialize, Serialize}; use tracing::info; use crate::GotchaContext; /// Registers background tasks that run alongside the server, each with access to the application /// context. -pub struct TaskScheduler Deserialize<'de> + Default> { +pub struct TaskScheduler { context: GotchaContext, } impl TaskScheduler where T1: Clone + Send + Sync + 'static, - T2: Clone + Send + Sync + 'static + Serialize + for<'de> Deserialize<'de> + Default, + T2: Clone + Send + Sync + 'static, { /// Create a scheduler bound to the application context tasks will receive. pub fn new(context: GotchaContext) -> Self { @@ -113,7 +112,7 @@ where pub async fn cron_proc_macro_wrapper(context: GotchaContext, schedule: Schedule, name: String, task: F) where T1: Clone + Send + Sync + 'static, - T2: Clone + Send + Sync + 'static + Serialize + for<'de> Deserialize<'de> + Default, + T2: Clone + Send + Sync + 'static, F: Fn(GotchaContext) -> FF + Send + 'static, FF: Future + Send + 'static, { @@ -131,7 +130,7 @@ where pub async fn interval_proc_macro_wrapper(context: GotchaContext, interval: std::time::Duration, name: String, task: F) where T1: Clone + Send + Sync + 'static, - T2: Clone + Send + Sync + 'static + Serialize + for<'de> Deserialize<'de> + Default, + T2: Clone + Send + Sync + 'static, F: Fn(GotchaContext) -> FF + Send + 'static, FF: Future + Send + 'static, { diff --git a/gotcha/tests/runtime_types.rs b/gotcha/tests/runtime_types.rs new file mode 100644 index 0000000..b2a933a --- /dev/null +++ b/gotcha/tests/runtime_types.rs @@ -0,0 +1,141 @@ +use gotcha::axum::{ + body::{to_bytes, Body}, + http::Request, +}; +use gotcha::{ConfigWrapper, Gotcha, GotchaApp, GotchaContext, GotchaResult, GotchaRouter, Message, Messager, ServerConfig, State}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; +use tower::ServiceExt; + +// These consumer types deliberately have no Default or serde implementations. +#[gotcha::state] +#[derive(Clone)] +struct RuntimeState { + requests: Arc, +} + +#[gotcha::config] +#[derive(Clone)] +struct RuntimeConfig { + label: String, +} + +fn context() -> GotchaContext { + GotchaContext { + state: RuntimeState { + requests: Arc::new(AtomicUsize::new(7)), + }, + config: ConfigWrapper { + app: RuntimeConfig { label: "ready".into() }, + server: ServerConfig { + host: "127.0.0.1".into(), + port: 8123, + }, + }, + } +} + +struct ReadConfig; +#[gotcha::async_trait] +impl Message for ReadConfig { + type Output = String; + async fn handle(self, messager: Messager) -> String { + messager.context().config.label.clone() + } +} + +#[cfg_attr(feature = "openapi", gotcha::api)] +async fn read( + State(state): State, State(config): State, State(wrapper): State>, + State(server): State, State(messager): State>, +) -> String { + format!( + "{}:{}:{}:{}:{}", + state.requests.fetch_add(1, Ordering::SeqCst), + config.label, + wrapper.label, + server.port, + messager.send(ReadConfig).await + ) +} + +struct App; +impl GotchaApp for App { + type State = RuntimeState; + type Config = RuntimeConfig; + + async fn state(&self, _: &ConfigWrapper) -> GotchaResult { + Ok(context().state) + } + + fn routes(&self, router: GotchaRouter>) -> GotchaRouter> { + router.get("/context", read) + } +} + +#[tokio::test] +async fn runtime_context_supports_extractors_messages_and_http_without_loading_bounds() { + let context = context(); + let requests = context.state.requests.clone(); + let router = App.build_router(context).await.unwrap(); + let response = router.oneshot(Request::builder().uri("/context").body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(), 200); + assert_eq!(to_bytes(response.into_body(), 1024).await.unwrap(), "7:ready:ready:8123:ready"); + assert_eq!(requests.load(Ordering::SeqCst), 8); +} + +#[test] +fn initialized_builders_can_reach_every_serving_entry_point() { + fn assert_send(_: T) {} + fn app() -> Gotcha { + let app = Gotcha::from_context(context()) + .state(context().state) + .config(context().config) + .get("/context", read); + #[cfg(feature = "task")] + let app = app.tasks(|scheduler| { + scheduler.interval("read-config", std::time::Duration::from_secs(60), |context| async move { + let _ = context.config.label; + }); + }); + app + } + // Type-check the public startup futures without opening a listener. + assert_send(app().listen("127.0.0.1:0")); + assert_send(app().listen_on("127.0.0.1:0".parse().unwrap())); + assert_send(app().run()); + assert_send(Gotcha::from_state(context().state).run()); +} + +#[test] +fn config_operations_only_require_the_traits_they_use() { + #[derive(Clone, serde::Deserialize)] + struct LoadOnly { + label: String, + } + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "label = 'loaded'").unwrap(); + let loaded: ConfigWrapper = gotcha::config::Config::builder().file(&path).build().unwrap(); + assert_eq!(loaded.label, "loaded"); + assert_eq!(loaded.server.port, 3000); + // Explicit loading on the builder needs Deserialize, but neither Serialize nor Default. + let _ = Gotcha::from_context(GotchaContext { state: (), config: loaded }) + .build_config(|builder| builder.file(&path)) + .unwrap(); + + #[derive(serde::Serialize)] + struct SaveOnly<'a> { + label: &'a str, + } + let label = String::from("borrowed"); + let wrapper = ConfigWrapper { + app: SaveOnly { label: &label }, + server: ServerConfig::default(), + }; + let serialized = serde_json::to_value(wrapper).unwrap(); + assert_eq!(serialized["label"], "borrowed"); + assert_eq!(serialized["server"]["port"], 3000); +} diff --git a/gotcha_macro/src/lib.rs b/gotcha_macro/src/lib.rs index 1ee35c2..d5fcc02 100644 --- a/gotcha_macro/src/lib.rs +++ b/gotcha_macro/src/lib.rs @@ -149,7 +149,7 @@ pub fn derive_parameter(input: TokenStream) -> TokenStream { /// Marks a struct as a Gotcha application state so it can be extracted directly /// with axum's `State` in handlers. /// -/// It generates `impl FromRef> for T`, which +/// It generates `impl FromRef> for T`, which /// pulls the state out of the `GotchaContext` that the framework injects as the /// axum state. Without this, handlers would have to extract the whole /// `State>` and reach into `.state`. @@ -177,8 +177,8 @@ pub fn derive_parameter(input: TokenStream) -> TokenStream { /// settings out of the loaded configuration. Without this, handlers would have to extract /// `State>` and reach through the wrapper. /// -/// The struct must be `Clone` and non-generic, and must satisfy the config bounds -/// (`Serialize + Deserialize + Default`). +/// The struct must be `Clone` and non-generic. Extraction itself does not require +/// `Serialize`, `Deserialize`, or `Default`. /// /// ```ignore /// use gotcha::prelude::*; @@ -201,7 +201,7 @@ pub fn config(_attr: TokenStream, item: TokenStream) -> TokenStream { let generated = if input.generics.params.is_empty() { quote::quote! { - impl<__GotchaState: ::core::clone::Clone + ::core::marker::Send + ::core::marker::Sync + 'static> + impl<__GotchaState> ::gotcha::axum::extract::FromRef<::gotcha::GotchaContext<__GotchaState, #ident>> for #ident { fn from_ref(context: &::gotcha::GotchaContext<__GotchaState, #ident>) -> Self { @@ -231,7 +231,7 @@ pub fn state(_attr: TokenStream, item: TokenStream) -> TokenStream { let generated = if input.generics.params.is_empty() { quote::quote! { - impl<__GotchaConfig: ::gotcha::GotchaConfig> ::gotcha::axum::extract::FromRef<::gotcha::GotchaContext<#ident, __GotchaConfig>> for #ident { + impl<__GotchaConfig> ::gotcha::axum::extract::FromRef<::gotcha::GotchaContext<#ident, __GotchaConfig>> for #ident { fn from_ref(context: &::gotcha::GotchaContext<#ident, __GotchaConfig>) -> Self { ::core::clone::Clone::clone(&context.state) }