//! Application startup and server configuration. //! //! This module handles: //! - Building the application with routes and middleware //! - Setting up the OpenAPI service and Swagger UI //! - Configuring CORS //! - Starting the HTTP server use poem::middleware::{AddDataEndpoint, Cors, CorsEndpoint}; use poem::{EndpointExt, Route}; use poem_openapi::OpenApiService; use crate::infrastructure::modbus::factory::create_relay_controller; use crate::infrastructure::persistence::factory::create_label_repository; use crate::presentation::api::relay_api::RelayApi; use crate::{ middleware::rate_limit::{RateLimit, RateLimitConfig}, route::Api, settings::Settings, }; use crate::middleware::rate_limit::RateLimitEndpoint; type Server = poem::Server, std::convert::Infallible>; /// The configured application with rate limiting, CORS, and settings data. pub type App = AddDataEndpoint>, Settings>; /// Application builder that holds the server configuration before running. pub struct Application { server: Server, app: poem::Route, host: String, port: u16, settings: Settings, } /// A fully configured application ready to run. pub struct RunnableApplication { server: Server, app: App, } impl RunnableApplication { /// Runs the application server. /// /// # Errors /// /// Returns a `std::io::Error` if the server fails to start or encounters /// an I/O error during runtime (e.g., port already in use, network issues). pub async fn run(self) -> Result<(), std::io::Error> { self.server.run(self.app).await } } impl From for App { fn from(value: RunnableApplication) -> Self { value.app } } impl From for RunnableApplication { fn from(value: Application) -> Self { // Configure rate limiting based on settings let rate_limit_config = if value.settings.rate_limit.enabled { tracing::event!( target: "backend::startup", tracing::Level::INFO, burst_size = value.settings.rate_limit.burst_size, per_seconds = value.settings.rate_limit.per_seconds, "Rate limiting enabled" ); RateLimitConfig::new( value.settings.rate_limit.burst_size, value.settings.rate_limit.per_seconds, ) } else { tracing::event!( target: "backend::startup", tracing::Level::INFO, "Rate limiting disabled (using very high limits)" ); // Use very high limits to effectively disable rate limiting RateLimitConfig::new(u32::MAX, 1) }; let cors = Cors::from(value.settings.cors.clone()); let app = value .app .with(RateLimit::new(&rate_limit_config)) .with(cors) .data(value.settings); let server = value.server; Self { server, app } } } impl Application { fn setup_app(settings: &Settings, relay_api: RelayApi) -> poem::Route { let api_service = OpenApiService::new( (Api::from(settings).apis(), relay_api), settings.application.clone().name, settings.application.clone().version, ) .url_prefix("/api"); let ui = api_service.swagger_ui(); poem::Route::new() .nest("/specs", api_service.spec_endpoint_yaml()) .nest("/api", api_service) .nest("/", ui) } fn setup_server( settings: &Settings, tcp_listener: Option>, ) -> Server { let tcp_listener = tcp_listener.unwrap_or_else(|| { let address = format!( "{}:{}", settings.application.host, settings.application.port ); poem::listener::TcpListener::bind(address) }); poem::Server::new(tcp_listener) } /// Builds a new application with the given settings and optional TCP listener. /// /// If no listener is provided, one will be created based on the settings. /// /// # Errors /// /// Returns an error if dependency injection fails (currently always succeeds). pub async fn build( settings: Settings, tcp_listener: Option>, ) -> Result> { let use_mock = cfg!(test) || std::env::var("CI").is_ok(); let relay_controller = create_relay_controller(&settings.modbus, use_mock).await; let label_repository = create_label_repository(&settings.database.path, use_mock).await?; let relay_api = RelayApi::new(relay_controller, label_repository); let port = settings.application.port; let host = settings.application.clone().host; let app = Self::setup_app(&settings, relay_api); let server = Self::setup_server(&settings, tcp_listener); Ok(Self { server, app, host, port, settings, }) } /// Converts the application into a runnable application. #[must_use] pub fn make_app(self) -> RunnableApplication { self.into() } /// Returns the host address the application is configured to bind to. #[must_use] pub fn host(&self) -> String { self.host.clone() } /// Returns the port the application is configured to bind to. #[must_use] pub const fn port(&self) -> u16 { self.port } } #[cfg(test)] mod tests { use super::*; fn create_test_settings() -> Settings { Settings { application: crate::settings::ApplicationSettings { name: "test-app".to_string(), version: "1.0.0".to_string(), port: 8080, host: "127.0.0.1".to_string(), base_url: "http://localhost:8080".to_string(), protocol: "http".to_string(), }, debug: false, frontend_url: "http://localhost:3000".to_string(), rate_limit: crate::settings::RateLimitSettings { enabled: false, burst_size: 100, per_seconds: 60, }, ..Default::default() } } #[tokio::test] async fn application_build_and_host() { let settings = create_test_settings(); let app = Application::build(settings.clone(), None).await.unwrap(); assert_eq!(app.host(), settings.application.host); } #[tokio::test] async fn application_build_and_port() { let settings = create_test_settings(); let app = Application::build(settings, None).await.unwrap(); assert_eq!(app.port(), 8080); } #[tokio::test] async fn application_host_returns_correct_value() { let settings = create_test_settings(); let app = Application::build(settings, None).await.unwrap(); assert_eq!(app.host(), "127.0.0.1"); } #[tokio::test] async fn application_port_returns_correct_value() { let settings = create_test_settings(); let app = Application::build(settings, None).await.unwrap(); assert_eq!(app.port(), 8080); } #[tokio::test] async fn application_with_custom_listener() { let settings = create_test_settings(); let tcp_listener = std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port"); let port = tcp_listener.local_addr().unwrap().port(); let listener = poem::listener::TcpListener::bind(format!("127.0.0.1:{port}")); let app = Application::build(settings, Some(listener)).await.unwrap(); assert_eq!(app.host(), "127.0.0.1"); assert_eq!(app.port(), 8080); } #[tokio::test] async fn runnable_application_uses_cors_from_settings() { let mut settings = create_test_settings(); settings.cors = crate::settings::CorsSettings { allowed_origins: vec!["http://localhost:5173".to_string()], allow_credentials: false, max_age_secs: 3600, }; let app = Application::build(settings, None).await.unwrap(); let _runnable_app = app.make_app(); // Note: This is a structural test - actual CORS behavior is tested in integration tests (T016) // The fact that this compiles and runs without panic verifies that: // 1. CORS settings are properly loaded // 2. The From trait is correctly implemented // 3. The middleware chain accepts the CORS configuration } #[tokio::test] async fn test_application_build_succeeds_in_test_mode() { let settings = create_test_settings(); let app = Application::build(settings, None).await; assert!( app.is_ok(), "Application::build() should succeed in test mode" ); let app = app.unwrap(); assert_eq!(app.port(), 8080); assert_eq!(app.host(), "127.0.0.1"); let runnable_app = app.make_app(); let _app: App = runnable_app.into(); // Success - the application was built with dependencies and can run } // ============================================================================ // T039d: RelayApi Registration Tests // ============================================================================ // These tests verify that the RelayApi is properly registered in the route // aggregator with correct OpenAPI tagging. // T039d: Test 1 - OpenAPI spec includes /relays endpoints #[tokio::test] async fn test_openapi_spec_includes_relay_endpoints() { let settings = create_test_settings(); let app: App = Application::build(settings, None) .await .unwrap() .make_app() .into(); let cli = poem::test::TestClient::new(app); let resp = cli.get("/specs").send().await; resp.assert_status_is_ok(); let spec = resp.0.into_body().into_string().await.unwrap(); assert!( spec.contains("/relays:"), "OpenAPI spec should include the /relays path, got:\n{spec}" ); assert!( spec.contains("/relays/{id}/toggle:"), "OpenAPI spec should include the /relays/{{id}}/toggle path, got:\n{spec}" ); } // T039d: Test 2 - OpenAPI spec includes the Relays tag #[tokio::test] async fn test_swagger_ui_includes_relays_tag() { let settings = create_test_settings(); let app: App = Application::build(settings, None) .await .unwrap() .make_app() .into(); let cli = poem::test::TestClient::new(app); let resp = cli.get("/specs").send().await; resp.assert_status_is_ok(); let spec = resp.0.into_body().into_string().await.unwrap(); assert!( spec.contains("Relays"), "OpenAPI spec should include a 'Relays' tag, got:\n{spec}" ); } }