diff --git a/MIGRATION.md b/MIGRATION.md index f77bbf1..b6f5560 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,6 @@ # Migration Guide +- [Unreleased: shared startup](#unreleased-shared-startup) - [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) @@ -13,6 +14,75 @@ --- +# Unreleased: shared startup + +**Builder and trait applications now share configuration policy, address resolution, binding, +initialization, and serving.** This changes the builder's default error policy and listener source. + +## Listener address + +Previously, builder `run()` selected its address before loading configuration and ignored +`config.server`. It now applies this priority: + +| Priority | Source | +| --- | --- | +| 1 | Explicit `listen(...)` / `listen_on(...)` socket address | +| 2 | Builder `.host(...)` and `.port(...)`, independently | +| 3 | Loaded or explicitly supplied `ConfigWrapper::server` | +| 4 | Framework defaults from a missing server section | + +An explicit `.port(3000)` overrides a configured 8080, even though 3000 is the framework default. +Setting only the port preserves the configured host. `run()` supports IPv4 and IPv6 literals; +use an unbracketed IP in `server.host` and a bracketed IPv6 socket address for `listen()`. +Hostname resolution is not provided. + +Applications relying on the old builder behavior should explicitly call `.host("127.0.0.1")` +and `.port(3000)`, or pass an explicit listen address. `Gotcha::from_context` now honors its supplied +server settings as well. + +## Configuration errors + +Automatic loading and explicitly registered sources now both fail startup on loading errors unless +a fallback is explicitly selected. Optional files still ignore only missing files. Explicitly supplied +configuration still takes precedence over sources. To opt into fallback in the builder: + +```rust +use gotcha::{ConfigErrorPolicy, Gotcha}; +let app = Gotcha::new().config_error_policy(ConfigErrorPolicy::fallback_to_default()); +``` + +For the trait API, add this method to your `GotchaApp` implementation when its config supports Default: + +```rust,ignore +fn config_error_policy(&self) -> gotcha::ConfigErrorPolicy { + gotcha::ConfigErrorPolicy::fallback_to_default() +} +``` + +`ConfigErrorPolicy::Fallback(factory)` accepts a function returning `ConfigWrapper` for custom +fallback values. Fallback logs the original error, replaces the whole configuration, and then applies +the normal address overrides. It applies to the configuration-loading step of startup, including a +custom trait `config()` error; it does not swallow address, state, router, task, or serve errors. +Eager `.build_config(...)` and direct `GotchaApp::config()` calls keep returning their errors. + +`with_config::()` and the config parameter of `with_types::()` no longer require `Default`. +Only selecting `fallback_to_default()` needs `C: Default`; state default construction retains its +existing bound. Initialized contexts still require no serde or Default implementations. + +## Initialization order + +After the existing logging hook, startup loads configuration, selects and binds the address, +initializes state, assembles the router, registers tasks, and serves. This order is identical for +both APIs. A bind failure occurs before state construction or task registration; a later startup +error releases the listener. The initialized context's server settings contain the actual bound +address, including a dynamically assigned port, so state initialization and handlers agree. + +State initialization now runs while the port is reserved, before requests are accepted. Applications +that depended on initializing state before a bind attempt must account for the new order. Task +cancellation and waiting on shutdown are unchanged and tracked separately in #93. + +--- + # Unreleased: runtime type bounds Initialized values no longer have to implement loading or default-construction traits just to @@ -21,9 +91,9 @@ be stored in a context, extracted by handlers, or used by messages and scheduled - 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. +- `with_state::()`, `with_config::()`, and `with_types::()` retain automatic initialization. + Default state construction requires `S: Default`; configuration loading requires `Deserialize`. + Creating state 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`. @@ -42,10 +112,8 @@ explicitly. `Serialize` and `Default` are no longer required by trait-based load 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. +Explicit configuration still wins over registered sources. See [shared startup](#unreleased-shared-startup) +for the unified strict loading policy, explicit fallback, and listener address precedence. See the [initialized-values example](README.md#initialized-state-and-configuration). @@ -383,9 +451,8 @@ through `run()` / `listen()` instead of replacing the configuration with `Defaul by `with_default_files` are still optional, but errors in existing files propagate. An already supplied `.config(...)` value still takes precedence over accumulated sources. -With no explicit configuration or sources, automatic default loading retains its existing -warning-and-fallback behavior. A broader unification of startup policies is tracked separately -in [#92](https://github.com/Kilerd/gotcha/issues/92). +Automatic loading now follows the same strict default policy; see [shared startup](#unreleased-shared-startup) +for explicitly opting into fallback. --- diff --git a/README.md b/README.md index 42ddbcf..e3c316d 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,34 @@ let app = Gotcha::from_context(GotchaContext { `with_state::()` and `with_types::()` remain available when you want automatic default construction. File loading requires `Deserialize`; serializing a configuration requires `Serialize`. +### Startup behavior + +Both APIs load configuration before selecting a listener address. The priority is: + +1. The explicit `listen(...)` / `listen_on(...)` argument. +2. Builder `.host(...)` / `.port(...)` overrides, applied independently. +3. `ConfigWrapper::server`, including settings loaded from files or environment variables. +4. Framework defaults: `127.0.0.1:3000` when the server section is absent. + +Both APIs bind before state initialization, route assembly, and task registration. State, handlers, +and tasks see the effective address in `config.server`, including the actual port selected for `0`. +Binding failure therefore does not start background tasks. + +Configuration errors stop startup by default. To explicitly allow a default fallback: + +```rust,no_run +use gotcha::{ConfigErrorPolicy, Gotcha}; + +let app = Gotcha::new() + .config_error_policy(ConfigErrorPolicy::fallback_to_default()); +``` + +For the trait API, override `config_error_policy()` to return the same policy. A custom fallback +can use `ConfigErrorPolicy::Fallback(factory)`, where the factory returns a `ConfigWrapper`. +Fallback applies only to startup configuration loading; eager `build_config()` and direct +`GotchaApp::config()` calls still return errors. See the [migration guide](MIGRATION.md#unreleased-shared-startup) +for behavior changes and the trait example. + ### Advanced Trait API (For complex applications) ```rust,no_run diff --git a/gotcha/src/builder.rs b/gotcha/src/builder.rs index 1db60af..80e816f 100644 --- a/gotcha/src/builder.rs +++ b/gotcha/src/builder.rs @@ -20,8 +20,7 @@ //! } //! ``` -use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; -use std::str::FromStr; +use std::net::SocketAddr; use axum::extract::Request; use axum::handler::Handler; @@ -31,10 +30,11 @@ use serde::{Deserialize, Serialize}; use tower_layer::Layer; use tower_service::Service; -use crate::config::{Config, ConfigBuilder, ConfigWrapper, GotchaConfigLoader, ServerConfig}; +use crate::config::{Config, ConfigBuilder, ConfigErrorPolicy, ConfigWrapper, GotchaConfigLoader}; use crate::error::{GotchaError, GotchaResult}; use crate::router::{GotchaRouter, Responder}; use crate::routing::MethodRouter; +use crate::startup::{self, ListenOptions}; use crate::GotchaContext; /// A one-shot closure that registers background tasks on the scheduler when the @@ -60,14 +60,14 @@ enum Configuration { Provided(ConfigWrapper), Deferred { sources: Option, - load: fn(Option<&ConfigBuilder>, ServerConfig) -> GotchaResult>, + load: fn(Option<&ConfigBuilder>) -> GotchaResult>, }, } impl Configuration { fn automatic() -> Self where - C: DeserializeOwned + Default, + C: DeserializeOwned, { Self::Deferred { sources: None, @@ -82,28 +82,22 @@ impl Configuration { } } - fn resolve(&self, server: ServerConfig) -> GotchaResult> + fn resolve(&self) -> GotchaResult> where C: Clone, { match self { Self::Provided(config) => Ok(config.clone()), - Self::Deferred { sources, load } => load(sources.as_ref(), server), + Self::Deferred { sources, load } => load(sources.as_ref()), } } } -fn load_config(sources: Option<&ConfigBuilder>, server: ServerConfig) -> GotchaResult> { +fn load_config(sources: Option<&ConfigBuilder>) -> 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() }) - } - } + Ok(GotchaConfigLoader::load::>(std::env::var("GOTCHA_ACTIVE_PROFILE").ok())?) } /// Default empty configuration for simple applications @@ -121,8 +115,8 @@ where C: Clone + Send + Sync + 'static, { router: GotchaRouter>, - host: String, - port: u16, + listen_options: ListenOptions, + config_error_policy: ConfigErrorPolicy, state: StateSource, config: Configuration, #[cfg(feature = "task")] @@ -176,7 +170,7 @@ impl Gotcha { pub fn with_types() -> Gotcha where S: Clone + Send + Sync + 'static + Default, - C: Clone + Send + Sync + 'static + DeserializeOwned + Default, + C: Clone + Send + Sync + 'static + DeserializeOwned, { Gotcha::from_sources(StateSource::Default(S::default), Configuration::automatic()) } @@ -226,7 +220,7 @@ impl Gotcha { /// ``` pub fn with_config() -> Gotcha where - C: Clone + Send + Sync + 'static + DeserializeOwned + Default, + C: Clone + Send + Sync + 'static + DeserializeOwned, { Self::with_types::() } @@ -253,7 +247,7 @@ impl Gotcha { /// 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`. + /// `run()` uses the supplied server settings unless `host`/`port` or `listen` override them. pub fn from_context(context: GotchaContext) -> Gotcha where S: Clone + Send + Sync + 'static, @@ -271,8 +265,8 @@ where fn from_sources(state: StateSource, config: Configuration) -> Self { Self { router: GotchaRouter::default(), - host: "127.0.0.1".to_string(), - port: 3000, + listen_options: ListenOptions::default(), + config_error_policy: ConfigErrorPolicy::Strict, state, config, #[cfg(feature = "task")] @@ -292,6 +286,18 @@ where self } + /// Choose how startup handles configuration loading errors. The default is strict. + /// Applies to automatic and registered sources, but not eager `build_config()` calls. + /// + /// ```no_run + /// use gotcha::{ConfigErrorPolicy, Gotcha}; + /// let app = Gotcha::new().config_error_policy(ConfigErrorPolicy::fallback_to_default()); + /// ``` + pub fn config_error_policy(mut self, policy: ConfigErrorPolicy) -> Self { + self.config_error_policy = policy; + self + } + /// Build configuration using a custom configuration builder function /// /// This replaces any existing configuration sources with the result of the builder function. @@ -461,13 +467,13 @@ where /// .port(8080); /// ``` pub fn host>(mut self, host: H) -> Self { - self.host = host.into(); + self.listen_options.host = Some(host.into()); self } /// Set the port number pub fn port(mut self, port: u16) -> Self { - self.port = port; + self.listen_options.port = Some(port); self } @@ -773,53 +779,51 @@ where /// Start the server on a specific socket address pub async fn listen_on(self, addr: SocketAddr) -> GotchaResult<()> { - tracing::info!("🚀 Starting Gotcha server on {}", addr); - - let context = self.build_context()?; - - #[cfg(feature = "task")] - { - let tasks = self.tasks; - if !tasks.is_empty() { - let mut scheduler = crate::TaskScheduler::new(context.clone()); - for register in tasks { - register(&mut scheduler); - } - } - } + startup::run(self, Some(addr)).await + } - let app_router = self.router.into_axum_router(context); + /// Start using `host`/`port` overrides, then loaded server settings, then framework defaults. + /// Bind before initializing state, assembling routes, or registering tasks. The context + /// contains the effective bound address, including the port chosen for port 0. + pub async fn run(self) -> GotchaResult<()> { + startup::run(self, None).await + } +} - let listener = tokio::net::TcpListener::bind(addr).await.map_err(|source| GotchaError::Bind { - addr: addr.to_string(), - source, - })?; - tracing::info!("✅ Server listening on http://{}", addr); +impl startup::Application for Gotcha +where + S: Clone + Send + Sync + 'static, + C: Clone + Send + Sync + 'static, +{ + type State = S; + type Config = C; - axum::serve(listener, app_router).await.map_err(GotchaError::Io)?; - Ok(()) + fn listen_options(&mut self) -> ListenOptions { + std::mem::take(&mut self.listen_options) } - /// Start the server using the configured host and port - pub async fn run(self) -> GotchaResult<()> { - let ip = Ipv4Addr::from_str(&self.host).map_err(|_| GotchaError::InvalidAddress(self.host.clone()))?; - let addr = SocketAddrV4::new(ip, self.port); - self.listen_on(SocketAddr::V4(addr)).await + fn config_error_policy(&mut self) -> ConfigErrorPolicy { + std::mem::take(&mut self.config_error_policy) } - /// Build the application context (loads configuration and resolves state). - /// - /// Explicitly configured sources propagate loading errors. With no explicit config or - /// sources, automatic default loading still warns and falls back to defaults on failure. - 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(), - }) + async fn config(&mut self) -> GotchaResult> { + self.config.resolve() + } + + async fn state(&mut self, _: &ConfigWrapper) -> GotchaResult { + Ok(self.state.resolve()) + } + + async fn router(&mut self, context: GotchaContext) -> GotchaResult { + Ok(std::mem::take(&mut self.router).into_axum_router(context)) + } + + #[cfg(feature = "task")] + async fn tasks(&mut self, scheduler: &mut crate::TaskScheduler) -> GotchaResult<()> { + for register in std::mem::take(&mut self.tasks) { + register(scheduler); + } + Ok(()) } } @@ -850,9 +854,10 @@ impl Gotcha { #[cfg(test)] mod tests { use super::*; + use crate::ServerConfig; - #[test] - fn provided_state_does_not_construct_the_unused_default() { + #[tokio::test] + async fn provided_state_does_not_construct_the_unused_default() { #[derive(Clone)] struct Initialized; impl Default for Initialized { @@ -861,7 +866,7 @@ mod tests { } } let app = Gotcha::with_state::().state(Initialized).config(ConfigWrapper::default()); - app.build_context().unwrap(); + startup::prepare(app, Some("127.0.0.1:0".parse().unwrap())).await.unwrap(); } #[tokio::test] @@ -897,8 +902,8 @@ mod tests { .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); + let prepared = startup::prepare(app, Some("127.0.0.1:0".parse().unwrap())).await.unwrap(); + let router = prepared.router; for (path, expected) in [("/api/context", "parent:41"), ("/context", "parent:42")] { let response = router.clone().oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()).await.unwrap(); @@ -1025,15 +1030,13 @@ mod tests { "custom-env", ), ] { - 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"); + let config = builder.enable_variable_substitution().config.resolve().unwrap(); + assert_eq!(config.value, expected); + assert_eq!(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(), Err(GotchaError::Config(_)))); - // Automatic default loading retains its existing fallback until the startup-policy work. - let context = app().build_context().unwrap(); - assert_eq!(context.config.value, TestConfig::default().value); + assert!(matches!(app().with_default_config().config.resolve(), Err(GotchaError::Config(_)))); + assert!(matches!(app().config.resolve(), Err(GotchaError::Config(_)))); } } diff --git a/gotcha/src/config.rs b/gotcha/src/config.rs index 1a667a8..f0a1554 100644 --- a/gotcha/src/config.rs +++ b/gotcha/src/config.rs @@ -17,6 +17,39 @@ pub enum ConfigError { /// Configuration result type pub type ConfigResult = Result; +/// How application startup handles a failed configuration load. +/// Strict loading is the default for both the builder and [`crate::GotchaApp`]. +#[derive(Default)] +pub enum ConfigErrorPolicy { + /// Return the loading error without initializing state, binding, or starting tasks. + #[default] + Strict, + /// Log the loading error and use an explicitly chosen fallback configuration. + Fallback(fn() -> ConfigWrapper), +} + +impl ConfigErrorPolicy { + /// Opt into fallback to default server and application settings after a loading error. + /// Only selecting this policy requires `C: Default`. + pub fn fallback_to_default() -> Self + where + C: Default, + { + Self::Fallback(ConfigWrapper::default) + } + + pub(crate) fn apply(self, result: crate::GotchaResult>) -> crate::GotchaResult> { + match (result, self) { + (Ok(config), _) => Ok(config), + (Err(error), Self::Strict) => Err(error), + (Err(error), Self::Fallback(fallback)) => { + tracing::warn!("Failed to load configuration: {error}, using explicitly configured fallback"); + Ok(fallback()) + } + } + } +} + /// The loaded configuration: the application's own settings plus the framework's. /// /// The application's settings are **flattened to the top level** of the file, so they read as the @@ -244,6 +277,21 @@ impl GotchaConfigLoader { mod tests { use super::*; + #[test] + fn error_policy_only_constructs_a_fallback_after_a_loading_error() { + let config = ConfigWrapper { + server: ServerConfig::default(), + app: String::from("loaded"), + }; + let policy = ConfigErrorPolicy::Fallback(|| panic!("successful loading must not construct fallback values")); + assert_eq!(policy.apply(Ok(config)).unwrap().app, "loaded"); + let failure = || Err(ConfigError::Error("invalid config".into()).into()); + assert!(ConfigErrorPolicy::::Strict.apply(failure()).is_err()); + let fallback = ConfigErrorPolicy::::fallback_to_default().apply(failure()).unwrap(); + assert_eq!(fallback.app, ""); + assert_eq!(fallback.server.port, 3000); + } + #[derive(Serialize, Deserialize, Default, Debug, Clone)] struct TestConfig { name: String, diff --git a/gotcha/src/lib.rs b/gotcha/src/lib.rs index e100dc3..070d75e 100644 --- a/gotcha/src/lib.rs +++ b/gotcha/src/lib.rs @@ -23,7 +23,7 @@ pub use axum::middleware; pub use axum::response::sse::{self, Event, KeepAlive, Sse}; pub use axum::response::IntoResponse as Responder; pub use axum_macros::debug_handler; -pub use config::{ConfigWrapper, ServerConfig}; +pub use config::{ConfigErrorPolicy, ConfigWrapper, ServerConfig}; pub use either::Either; pub use routing::{delete, get, head, on, options, patch, post, put, trace, MethodRouter}; @@ -79,6 +79,7 @@ pub mod openapi; pub mod params; pub mod prelude; pub mod response; +mod startup; pub use response::WithStatus; /// The router that tracks OpenAPI operations alongside axum routes. pub mod router; @@ -176,6 +177,13 @@ pub trait GotchaApp: Sized + Send + Sync { } } + /// Startup is strict by default. Override to opt into a fallback after a configuration error. + /// This applies to `run()`, including errors returned by a custom `config()` implementation. + /// Direct calls to `config()` still return their loading errors. + fn config_error_policy(&self) -> ConfigErrorPolicy { + ConfigErrorPolicy::Strict + } + /// Install the tracing subscriber. The default reads `RUST_LOG`. fn logger(&self) -> GotchaResult<()> { tracing_subscriber::registry() @@ -212,41 +220,16 @@ pub trait GotchaApp: Sized + Send + Sync { } } - /// Load configuration, build state and routes, then serve until shutdown. + /// Load configuration, bind its address, build state and routes, register tasks, then serve. + /// State initialization and handlers receive the effective address, including the port chosen + /// for port 0. Binding failure does not initialize state or register background tasks. /// 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; - self.logger()?; - tracing::info!("logger has been initialized"); - let config: ConfigWrapper = self.config().await?; - let state = self.state(&config).await?; - - let context = GotchaContext { config: config.clone(), state }; - - let router = self.build_router(context.clone()).await?; - - cfg_if::cfg_if! { - if #[cfg(feature = "task")] { - let mut task_scheduler = TaskScheduler::new(context.clone()); - self.tasks(&mut task_scheduler).await?; - } - } - - let ip = Ipv4Addr::from_str(&config.server.host).map_err(|_| GotchaError::InvalidAddress(config.server.host.clone()))?; - let addr = SocketAddrV4::new(ip, config.server.port); - let listener = tokio::net::TcpListener::bind(addr).await.map_err(|source| GotchaError::Bind { - addr: addr.to_string(), - source, - })?; - axum::serve(listener, router).await.map_err(GotchaError::Io)?; - Ok(()) - } + async move { startup::run(&self, None).await } } } diff --git a/gotcha/src/prelude.rs b/gotcha/src/prelude.rs index 6d30124..c2f29c8 100644 --- a/gotcha/src/prelude.rs +++ b/gotcha/src/prelude.rs @@ -25,7 +25,7 @@ pub use crate::builder::{EmptyConfig, EmptyState, Gotcha}; // Essential traits and types -pub use crate::config::{ConfigWrapper, GotchaConfigLoader, ServerConfig}; +pub use crate::config::{ConfigErrorPolicy, ConfigWrapper, GotchaConfigLoader, ServerConfig}; pub use crate::error::{GotchaError, GotchaResult}; pub use crate::response::WithStatus; pub use crate::router::Responder; diff --git a/gotcha/src/startup.rs b/gotcha/src/startup.rs new file mode 100644 index 0000000..00b0e84 --- /dev/null +++ b/gotcha/src/startup.rs @@ -0,0 +1,120 @@ +//! Shared application startup. API adapters provide the application-specific hooks. + +use std::future::Future; +use std::net::{IpAddr, SocketAddr}; + +use axum::Router; +use tokio::net::TcpListener; + +use crate::{ConfigErrorPolicy, ConfigWrapper, GotchaApp, GotchaContext, GotchaError, GotchaResult, ServerConfig}; + +#[derive(Default)] +pub(crate) struct ListenOptions { + pub host: Option, + pub port: Option, +} + +impl ListenOptions { + fn resolve(&self, config: &ServerConfig, explicit: Option) -> GotchaResult { + if let Some(address) = explicit { + return Ok(address); + } + let host = self.host.as_deref().unwrap_or(&config.host); + let ip: IpAddr = host.parse().map_err(|_| GotchaError::InvalidAddress(host.into()))?; + Ok(SocketAddr::new(ip, self.port.unwrap_or(config.port))) + } +} + +pub(crate) trait Application: Send { + type State: Clone + Send + Sync + 'static; + type Config: Clone + Send + Sync + 'static; + + fn logger(&mut self) -> GotchaResult<()> { + Ok(()) + } + fn listen_options(&mut self) -> ListenOptions { + ListenOptions::default() + } + fn config_error_policy(&mut self) -> ConfigErrorPolicy; + fn config(&mut self) -> impl Future>> + Send; + fn state(&mut self, config: &ConfigWrapper) -> impl Future> + Send; + fn router(&mut self, context: GotchaContext) -> impl Future> + Send; + #[cfg(feature = "task")] + fn tasks(&mut self, scheduler: &mut crate::TaskScheduler) -> impl Future> + Send; +} + +pub(crate) struct PreparedServer { + listener: TcpListener, + pub(crate) router: Router, +} + +pub(crate) async fn prepare(mut app: A, explicit: Option) -> GotchaResult { + app.logger()?; + let policy = app.config_error_policy(); + let mut config = policy.apply(app.config().await)?; + let address = app.listen_options().resolve(&config.server, explicit)?; + let listener = TcpListener::bind(address).await.map_err(|source| GotchaError::Bind { + addr: address.to_string(), + source, + })?; + // Resolve port 0 before state initialization and make all consumers see the effective address. + let bound = listener.local_addr().map_err(GotchaError::Io)?; + config.server = ServerConfig { + host: bound.ip().to_string(), + port: bound.port(), + }; + let state = app.state(&config).await?; + let context = GotchaContext { config, state }; + let router = app.router(context.clone()).await?; + #[cfg(feature = "task")] + app.tasks(&mut crate::TaskScheduler::new(context)).await?; + Ok(PreparedServer { listener, router }) +} + +pub(crate) async fn run(app: A, explicit: Option) -> GotchaResult<()> { + prepare(app, explicit).await?.serve().await +} + +impl PreparedServer { + async fn serve(self) -> GotchaResult<()> { + tracing::info!("Server listening on http://{}", self.listener.local_addr().map_err(GotchaError::Io)?); + axum::serve(self.listener, self.router).await.map_err(GotchaError::Io) + } +} + +#[cfg(test)] +mod tests; + +impl Application for &A +where + A: GotchaApp, + A::Config: serde::de::DeserializeOwned, +{ + type State = A::State; + type Config = A::Config; + + fn logger(&mut self) -> GotchaResult<()> { + A::logger(self) + } + + fn config_error_policy(&mut self) -> ConfigErrorPolicy { + A::config_error_policy(self) + } + + async fn config(&mut self) -> GotchaResult> { + A::config(self).await + } + + async fn state(&mut self, config: &ConfigWrapper) -> GotchaResult { + A::state(self, config).await + } + + async fn router(&mut self, context: GotchaContext) -> GotchaResult { + A::build_router(self, context).await + } + + #[cfg(feature = "task")] + async fn tasks(&mut self, scheduler: &mut crate::TaskScheduler) -> GotchaResult<()> { + A::tasks(self, scheduler).await + } +} diff --git a/gotcha/src/startup/tests.rs b/gotcha/src/startup/tests.rs new file mode 100644 index 0000000..58adf89 --- /dev/null +++ b/gotcha/src/startup/tests.rs @@ -0,0 +1,236 @@ +use super::*; +use crate::config::ConfigBuilder; +use crate::{Gotcha, GotchaRouter, State}; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +#[test] +fn listen_address_precedence_preserves_explicit_default_values() { + let config = ServerConfig { + host: "0.0.0.0".into(), + port: 8080, + }; + for (host, port, explicit, expected) in [ + (None, None, None, "0.0.0.0:8080"), + (Some("127.0.0.1"), None, None, "127.0.0.1:8080"), + (None, Some(3000), None, "0.0.0.0:3000"), + (Some("127.0.0.1"), Some(3000), None, "127.0.0.1:3000"), + (None, Some(0), None, "0.0.0.0:0"), + (Some("::1"), Some(8081), None, "[::1]:8081"), + (Some("invalid"), Some(3000), Some("127.0.0.1:9090"), "127.0.0.1:9090"), + ] { + let options = ListenOptions { + host: host.map(str::to_owned), + port, + }; + assert_eq!( + options.resolve(&config, explicit.map(|value| value.parse().unwrap())).unwrap(), + expected.parse::().unwrap() + ); + } + assert_eq!( + ListenOptions::default().resolve(&ServerConfig::default(), None).unwrap(), + "127.0.0.1:3000".parse::().unwrap() + ); + let invalid = ListenOptions { + host: Some("not-an-ip".into()), + port: None, + }; + assert!(matches!(invalid.resolve(&config, None), Err(GotchaError::InvalidAddress(_)))); +} + +// Loading a configuration no longer requires Serialize or Default. +#[derive(Clone, serde::Deserialize)] +struct Settings { + label: String, +} + +type Events = Arc>>; + +struct TraitApp { + source: ConfigBuilder, + fallback: bool, + fail_at: Option<&'static str>, + events: Events, + bound: Mutex>, +} + +impl TraitApp { + fn new(path: &Path) -> Self { + Self { + source: ConfigBuilder::new().file(path), + fallback: false, + fail_at: None, + events: Events::default(), + bound: Mutex::new(None), + } + } + + fn stage(&self, name: &'static str) -> GotchaResult<()> { + self.events.lock().unwrap().push(name); + if self.fail_at == Some(name) { + return Err(GotchaError::message(name)); + } + Ok(()) + } +} + +async fn read(State(config): State>) -> String { + format!("{}:{}:{}", config.label, config.server.host, config.server.port) +} + +fn fallback_settings() -> ConfigWrapper { + ConfigWrapper { + app: Settings { label: "fallback".into() }, + server: ServerConfig { + host: "127.0.0.1".into(), + port: 0, + }, + } +} + +impl GotchaApp for TraitApp { + type State = (); + type Config = Settings; + + fn logger(&self) -> GotchaResult<()> { + self.stage("logger") + } + + fn config_error_policy(&self) -> ConfigErrorPolicy { + if self.fallback { + ConfigErrorPolicy::Fallback(fallback_settings) + } else { + ConfigErrorPolicy::Strict + } + } + + async fn config(&self) -> GotchaResult> { + self.stage("config")?; + Ok(self.source.clone().build()?) + } + + async fn state(&self, config: &ConfigWrapper) -> GotchaResult<()> { + assert_ne!(config.server.port, 0, "state must see the actual port before initialization"); + *self.bound.lock().unwrap() = Some(SocketAddr::new(config.server.host.parse().unwrap(), config.server.port)); + self.stage("state") + } + + fn routes(&self, router: GotchaRouter>) -> GotchaRouter> { + router.get("/config", read) + } + + async fn build_router(&self, context: GotchaContext<(), Settings>) -> GotchaResult { + self.stage("router")?; + Ok(self.routes(GotchaRouter::default()).into_axum_router(context)) + } + + #[cfg(feature = "task")] + async fn tasks(&self, _: &mut crate::TaskScheduler<(), Settings>) -> GotchaResult<()> { + self.stage("tasks") + } +} + +async fn assert_http(prepared: PreparedServer, label: &str) { + let address = prepared.listener.local_addr().unwrap(); + let server = tokio::spawn(prepared.serve()); + let response = tokio::task::spawn_blocking(move || { + use std::io::{Read, Write}; + let mut stream = std::net::TcpStream::connect(address).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + stream.write_all(b"GET /config HTTP/1.0\r\nHost: localhost\r\n\r\n").unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + response + }) + .await; + server.abort(); + let _ = server.await; + let response = response.unwrap(); + assert!(response.starts_with("HTTP/1.0 200"), "{response}"); + assert!(response.ends_with(&format!("{label}:{}:{}", address.ip(), address.port())), "{response}"); +} + +#[tokio::test] +async fn both_apis_serve_the_loaded_config_and_actual_bound_address() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "label = 'loaded'\n[server]\nhost = '127.0.0.1'\nport = 0").unwrap(); + let app = TraitApp::new(&path); + let builder = Gotcha::with_config::().with_file_config(&path).get("/config", read); + let prepared = prepare(builder, None).await.unwrap(); + assert_http(prepared, "loaded").await; + let prepared = prepare(&app, None).await.unwrap(); + assert_eq!(*app.bound.lock().unwrap(), Some(prepared.listener.local_addr().unwrap())); + assert_http(prepared, "loaded").await; + let mut expected = vec!["logger", "config", "state", "router"]; + if cfg!(feature = "task") { + expected.push("tasks"); + } + assert_eq!(*app.events.lock().unwrap(), expected); +} + +#[tokio::test] +async fn run_uses_configured_address_and_does_not_initialize_after_bind_failure() { + let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = occupied.local_addr().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, format!("label = 'occupied'\n[server]\nhost = '127.0.0.1'\nport = {}", address.port())).unwrap(); + let app = TraitApp::new(&path); + let events = app.events.clone(); + let builder = Gotcha::with_config::().with_file_config(&path); + #[cfg(feature = "task")] + let builder = builder.tasks(|_| panic!("must bind before task registration")); + let builder_error = tokio::time::timeout(Duration::from_secs(2), builder.run()).await.unwrap().unwrap_err(); + let trait_error = tokio::time::timeout(Duration::from_secs(2), GotchaApp::run(app)).await.unwrap().unwrap_err(); + for error in [builder_error, trait_error] { + assert!(matches!(error, GotchaError::Bind { addr, .. } if addr == address.to_string())); + } + assert_eq!(*events.lock().unwrap(), ["logger", "config"]); + // An explicit builder override can use the same configuration on an available port. + let builder = Gotcha::with_config::().with_file_config(&path).port(0).get("/config", read); + assert_http(prepare(builder, None).await.unwrap(), "occupied").await; +} + +#[tokio::test] +async fn configuration_fallback_is_explicit_and_shared_by_both_apis() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "label = [").unwrap(); + let mut app = TraitApp::new(&path); + let builder = || Gotcha::with_config::().with_file_config(&path).get("/config", read); + assert!(matches!(prepare(builder(), None).await, Err(GotchaError::Config(_)))); + assert!(matches!(prepare(&app, None).await, Err(GotchaError::Config(_)))); + assert_eq!(*app.events.lock().unwrap(), ["logger", "config"]); + app.fallback = true; + assert_http( + prepare(builder().config_error_policy(ConfigErrorPolicy::Fallback(fallback_settings)), None) + .await + .unwrap(), + "fallback", + ) + .await; + assert_http(prepare(&app, None).await.unwrap(), "fallback").await; +} + +#[tokio::test] +async fn initialization_errors_release_the_listener_and_stop_later_stages() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "label = 'ready'\n[server]\nhost = '127.0.0.1'\nport = 0").unwrap(); + let mut stages = vec!["state", "router"]; + if cfg!(feature = "task") { + stages.push("tasks"); + } + for stage in stages { + let mut app = TraitApp::new(&path); + app.fail_at = Some(stage); + app.fallback = true; // A config fallback must not swallow errors from other stages. + assert!(matches!(prepare(&app, None).await, Err(GotchaError::Message(message)) if message == stage)); + let bound = app.bound.lock().unwrap().unwrap(); + let _rebound = TcpListener::bind(bound).await.expect("failed startup must release its listener"); + assert_eq!(app.events.lock().unwrap().last(), Some(&stage)); + } +}