Skip to main content

rust_microservice/http/
web.rs

1//! # HTTP Web Module
2//!
3//! This module is responsible for initializing and starting the main API server
4//! and the health-check server.
5//!
6//! It provides a single entry point, `initialize_servers`, which bootstraps the
7//! Actix-Web infrastructure of the application. It configures the primary API
8//! server, the lightweight health-check server, and optionally manages
9//! a Docker Compose environment when enabled in the configuration.
10//!
11//! ## Main Function
12//!
13//! `bootstrap_server`: Initializes and starts the main API server and the
14//! health-check server.
15//!
16//! **Arguments:**
17//!
18//! `settings`: Reference to the application's runtime configuration, including
19//! server settings such as host, ports, worker count, and optional Docker
20//! Compose usage.
21//!
22//! `fnconfig`: Optional callback function used to configure the main Actix-Web
23//! `ServiceConfig`, where routes and middleware are attached.
24
25use crate::http::health::{HealthApiDoc, configure_server_base};
26use crate::metrics::SysInfoCollector;
27use crate::settings::Settings;
28use actix_cors::Cors;
29use actix_web::middleware::Condition;
30use actix_web::web::ServiceConfig;
31use actix_web::{App, HttpServer, middleware::Logger};
32use actix_web_prom::{PrometheusMetrics, PrometheusMetricsBuilder};
33use colored::Colorize;
34use compose_rs::{Compose, ComposeCommand};
35use prometheus::Registry;
36use prometheus::process_collector::ProcessCollector;
37use thiserror::Error;
38use utoipa::OpenApi;
39
40use tokio::join;
41use utoipa_swagger_ui::{Config, SwaggerUi};
42
43/// Initializes and starts the main API server and the health-check server.
44///
45/// This function is responsible for bootstrapping the Actix-Web
46/// infrastructure of the application. It configures the primary API
47/// server, the lightweight health-check server, and optionally manages
48/// a Docker Compose environment when enabled in the configuration.
49///
50/// # Parameters
51/// - `settings`: Reference to the application's runtime configuration,
52///   including server settings such as host, ports, worker count, and
53///   optional Docker Compose usage.
54/// - `fnconfig`: Optional callback function used to configure the main
55///   Actix-Web [`ServiceConfig`], where routes and middleware are attached.
56///
57/// # Returns
58/// Returns an `io::Result` containing:
59/// - `Ok(Some(Compose))` if Docker Compose was enabled and successfully
60///   started.
61/// - `Ok(None)` if Docker Compose was not used.
62/// - `Err(e)` if the server setup or binding fails.
63///
64/// # Behavior
65/// - If `use_docker_compose` is enabled in the settings:
66///   - Executes `docker-compose up` and returns a `Compose` handle for
67///     later shutdown.
68/// - Launches two Actix-Web servers:
69///   1. **Main API Server**
70///      - Uses the host and port defined in the settings.
71///      - Applies CORS configuration.
72///      - Invokes the user-supplied `fnconfig` callback to register routes.
73///   2. **Health Check Server**
74///      - Runs independently with a dedicated port.
75///      - Registers only the base server configuration (e.g., `/health`).
76///
77/// - Applies worker thread settings if provided.
78/// - Prints the listening ports to the console.
79/// - Awaits both the main server and the health server concurrently.
80///
81/// # Notes
82/// - This function blocks the current thread until both servers shut down.
83/// - If Docker Compose is active, the returned `Compose` instance must be
84///   used by the caller to stop the services during shutdown.
85pub(crate) async fn bootstrap_server(
86    settings: &Settings,
87    fnconfig: Option<fn(&mut ServiceConfig)>,
88) -> Result<Option<Compose>> {
89    let server_config = settings
90        .server
91        .as_ref()
92        .ok_or_else(|| HttpServerError::Configuration("Missing server configuration.".into()))?;
93
94    // Get the thread count to configure server workers
95    let num_threads = std::thread::available_parallelism().map_or_else(|_| 1, |p| p.get());
96
97    // Verify docker compose startup
98    let compose = if server_config.use_docker_compose.unwrap_or(false) {
99        Some(run_docker_compose(settings).map_err(|_| HttpServerError::Compose)?)
100    } else {
101        None
102    };
103
104    // Check the server host
105    let host = server_config
106        .host
107        .clone()
108        .ok_or_else(|| HttpServerError::Configuration("Missing server host.".into()))?;
109
110    // Configure Prometheus Metrics
111    let (health_metrics_enabled, prometheus_health) = configure_prometheus(settings, true)?;
112    let (metrics_enabled, prometheus) = configure_prometheus(settings, false)?;
113
114    // Start the servers
115    //rt::System::new().block_on(async {
116    // Configure the Main Server
117    //
118    let server_settings = settings.clone();
119    let mut main_server_builder = HttpServer::new(move || {
120        // Configure Main Server Cors Config
121        let cors_config = configure_cors(&server_settings);
122
123        // Create the Prometheus condition based on settings
124        let metrics_condition = Condition::new(metrics_enabled, prometheus.clone());
125
126        App::new()
127            .wrap(cors_config)
128            .wrap(metrics_condition)
129            .wrap(Logger::default())
130            .configure(fnconfig.unwrap_or(|_| {}))
131    })
132    .bind((host.clone(), server_config.port))
133    .map_err(|e| HttpServerError::Bootstrap(e.to_string()))?
134    .workers(server_config.workers.unwrap_or(num_threads))
135    .shutdown_timeout(60);
136
137    // Configure the Health Server
138    //
139    let mut health_server_builder = HttpServer::new(move || {
140        // Configure OpenApi Doc
141        let health_openapi = HealthApiDoc::openapi();
142
143        // Create the Prometheus condition based on settings
144        let metrics_condition = Condition::new(health_metrics_enabled, prometheus_health.clone());
145
146        // Create the Health Check and Metrics Server App
147        App::new()
148            .wrap(metrics_condition)
149            .configure(configure_server_base)
150            .service(
151                SwaggerUi::new("/actuator/swagger-ui/{_:.*}")
152                    .url("/actuator/api-docs/openapi.json", health_openapi)
153                    .config(Config::default().validator_url("none")),
154            )
155    })
156    .bind((host, server_config.health_check_port))
157    .map_err(|e| HttpServerError::Bootstrap(e.to_string()))?
158    .workers(server_config.health_check_workers.unwrap_or(num_threads))
159    .shutdown_timeout(60);
160
161    // Configure server workers if provided
162    if let Some(workers) = settings.server.as_ref().and_then(|s| s.workers) {
163        main_server_builder = main_server_builder.workers(workers);
164        health_server_builder = health_server_builder.workers(workers);
165    }
166    let main_server = main_server_builder.run();
167    let health_server = health_server_builder.run();
168
169    tracing::info!(
170        "{} {}. {} {}.",
171        "Server listening on port".bright_green(),
172        server_config.port.to_string().bright_blue(),
173        "The Health Check port is".bright_green(),
174        server_config.health_check_port.to_string().bright_blue()
175    );
176
177    let (_, _) = join!(health_server, main_server);
178
179    Ok(compose)
180    //})
181}
182
183/// Configures a Prometheus metrics collector based on the provided settings.
184///
185/// This function takes the application settings as input and returns a tuple
186/// containing a boolean indicating whether metrics collection is enabled
187/// and a `PrometheusMetrics` instance configured with the application
188/// name and endpoint.
189///
190/// The `PrometheusMetrics` instance is configured with the following settings:
191/// - The application name is used as the prefix for all exposed metrics.
192/// - The `"/metrics"` endpoint is used to expose the metrics.
193/// - The `^/swagger-ui/.*` regex is used to exclude the Swagger UI endpoint from
194///   metrics collection.
195///
196/// # Errors
197///
198/// This function will return an error if either the ProcessCollector or
199/// SysInfoCollector fails to initialize.
200fn configure_prometheus(settings: &Settings, base: bool) -> Result<(bool, PrometheusMetrics)> {
201    // Get metrics parameters
202    let metrics_cfg = settings.metrics.as_ref();
203    let metrics_enabled = metrics_cfg.and_then(|m| m.enabled).unwrap_or(false);
204    let metrics_app_name = metrics_cfg
205        .and_then(|m| m.app_name.clone())
206        .unwrap_or_else(|| "api".to_string());
207
208    // Metrics registry
209    let registry = build_metrics_registry(&metrics_app_name)?;
210
211    let endpoint = if base {
212        "/actuator/metrics"
213    } else {
214        "/metrics"
215    };
216    let prometheus = PrometheusMetricsBuilder::new(&metrics_app_name)
217        .endpoint(endpoint)
218        .exclude_regex("^/swagger-ui/.*")
219        .exclude_regex("^/actuator/swagger-ui/.*")
220        .registry(registry)
221        .build()
222        .map_err(|e| HttpServerError::Bootstrap(e.to_string()))?;
223
224    Ok((metrics_enabled, prometheus))
225}
226
227/// Builds a Prometheus registry with the given application name.
228///
229/// The registry is initialized with both the ProcessCollector and
230/// SysInfoCollector. The ProcessCollector is used to expose process
231/// metrics, such as memory and CPU usage. The SysInfoCollector is used
232/// to expose system metrics, such as CPU count, memory usage, and
233/// network connections.
234///
235/// # Errors
236///
237/// This function will return an error if either the ProcessCollector or
238/// SysInfoCollector cannot be registered with the registry.
239fn build_metrics_registry(app_name: &str) -> Result<Registry> {
240    let pid = std::process::id() as i32;
241    let registry = Registry::default();
242
243    registry
244        .register(Box::new(ProcessCollector::new(pid, app_name.to_string())))
245        .map_err(|e| HttpServerError::Configuration(e.to_string()))?;
246
247    let collector = SysInfoCollector::with_process_and_namespace(pid, app_name.to_string())
248        .map_err(|e| HttpServerError::Configuration(e.to_string()))?;
249
250    registry
251        .register(Box::new(collector))
252        .map_err(|e| HttpServerError::Configuration(e.to_string()))?;
253
254    Ok(registry)
255}
256
257/// Starts the Docker Compose environment defined in the server settings.
258///
259/// This function loads the `docker-compose.yml` path from the application
260/// configuration and attempts to start all declared services. After startup,
261/// it prints the status of each container and returns a [`Compose`] handle
262/// used for later shutdown.
263///
264/// # Parameters
265/// - `settings`: Reference to the application configuration containing
266///   Docker Compose settings.
267///
268/// # Returns
269/// - `Ok(Compose)` if the Compose services start successfully.
270/// - `Err(())` if a failure occurs during startup.
271///
272/// # Notes
273/// - Panics if the Compose file is not found or if service startup fails.
274/// - Should be used only when `use_docker_compose` is enabled.
275fn run_docker_compose(settings: &Settings) -> Result<Compose> {
276    let server_config = settings
277        .server
278        .as_ref()
279        .ok_or_else(|| HttpServerError::Configuration("Server configuration is missing".into()))?;
280
281    let compose_file = server_config.docker_compose_file.as_ref().ok_or_else(|| {
282        HttpServerError::Configuration("Docker Compose file not configured".into())
283    })?;
284
285    let compose = Compose::builder()
286        .path(compose_file)
287        .build()
288        .map_err(|e| HttpServerError::Custom(format!("Failed to build Docker Compose: {e}")))?;
289
290    tracing::info!(
291        "{} {}. {}",
292        "Starting the docker compose from".to_string().green(),
293        compose_file.to_string().bright_blue(),
294        "Please wait...".to_string().green(),
295    );
296
297    if let Err(error) = compose.up().exec() {
298        tracing::error!("Error starting Docker Compose: {error}");
299
300        // Best-effort cleanup
301        if let Err(down_error) = compose.down().exec() {
302            tracing::warn!("Error while stopping Docker Compose after failure: {down_error}");
303        }
304
305        return Err(HttpServerError::Custom(format!(
306            "Docker Compose startup failed: {error}"
307        )));
308    }
309
310    log_compose_status(&compose);
311
312    tracing::info!(
313        "{}",
314        "The Docker Compose containers are running! Starting the server...".green()
315    );
316
317    Ok(compose)
318}
319
320/// Logs the status of all containers in the given Docker Compose environment.
321///
322/// This function retrieves the status of all containers in the Compose environment
323/// and logs their name, status, since when they were started, and exit code
324/// if applicable. If the retrieval of the container status fails, it logs a
325/// warning message with the error details.
326///
327/// # Parameters
328/// - `compose`: Reference to the Docker Compose environment to retrieve
329///   the container status from.
330///
331/// # Behavior
332/// - Retrieves the status of all containers in the given Compose environment.
333/// - Logs the name, status, since when they were started, and exit code of each
334///   container if applicable.
335/// - Logs a warning message with the error details if the retrieval of the
336///   container status fails.
337fn log_compose_status(compose: &Compose) {
338    match compose.ps().exec() {
339        Ok(services) => {
340            tracing::info!("{}", "Containers:".bright_green());
341
342            for service in services {
343                let status = format!("{:?}", service.status.status);
344
345                tracing::info!(
346                    "  {} {:<25} {} {:?}, {} {}{}",
347                    "Name:".white(),
348                    service.name.bright_blue(),
349                    "Status:".white().dimmed(),
350                    service.status.status,
351                    "Since:".white().dimmed(),
352                    service.status.since.bright_blue().dimmed(),
353                    service
354                        .status
355                        .exit_code
356                        .filter(|_| status == "Exited")
357                        .map(|code| {
358                            format!(
359                                "{} {}",
360                                ", Exit Code:".bright_blue().dimmed(),
361                                code.to_string().bright_blue().dimmed()
362                            )
363                        })
364                        .unwrap_or_default()
365                );
366            }
367        }
368        Err(error) => {
369            tracing::warn!("Failed to retrieve Docker Compose status: {error}");
370        }
371    }
372}
373
374/// Builds and returns a CORS configuration based on the server settings.
375///
376/// This function reads the CORS options defined in the application
377/// configuration and applies rules for allowed origins and headers.
378/// When no CORS configuration is provided, a permissive default policy
379/// is applied.
380///
381/// # Parameters
382/// - `settings`: Reference to the application settings used to load
383///   CORS rules.
384///
385/// # Returns
386/// A configured [`Cors`] instance ready to be applied to an Actix-Web
387/// application.
388fn configure_cors(settings: &Settings) -> Cors {
389    if let Some(cors_config) = settings.server.as_ref().and_then(|sc| sc.cors.as_ref()) {
390        let mut cors = Cors::default();
391
392        // Configure CORS origins
393        if let Some(pattern) = &cors_config.allowed_origins_pattern {
394            let origins = pattern.split(',').collect::<Vec<&str>>();
395            if origins.len() == 1 && origins[0].trim() == "*" {
396                cors = cors.allow_any_origin();
397            } else {
398                for origin in origins {
399                    cors = cors.allowed_origin(origin.trim());
400                }
401            }
402        };
403
404        // Configure CORS Allowed Headers
405        if let Some(allowed_headers) = &cors_config.allowed_headers {
406            let headers = allowed_headers.split(',').collect::<Vec<&str>>();
407            if headers.len() == 1 && headers[0].trim() == "*" {
408                cors = cors.allow_any_header()
409            } else {
410                for header in headers {
411                    cors = cors.allowed_header(header.trim());
412                }
413            }
414        }
415
416        cors
417    } else {
418        Cors::permissive()
419    }
420}
421
422/// Represents the middleware wrappers applied to the HTTP server.
423///
424/// This structure groups cross-cutting concerns that are attached to the
425/// request/response pipeline, such as metrics collection and CORS handling.
426///
427/// # Fields
428///
429/// - `metrics_enabled`:
430///   Indicates whether Prometheus metrics are exposed and collected.
431///
432/// - `prometheus`:
433///   Configuration and instance responsible for exporting Prometheus metrics.
434///
435/// - `cors`:
436///   Cross-Origin Resource Sharing (CORS) configuration applied to incoming requests.
437///
438/// # Usage
439///
440/// This struct is typically initialized during server bootstrap and injected
441/// into the HTTP server builder to register middleware components.
442///
443/// The `rust_microservice::create_server_wrappers` function can be used to create an instance of
444/// this struct.
445///
446/// # Example
447///
448/// ```no_run
449/// use rust_microservice::{server_wrappers, ServerWrappers, settings::Settings};
450///
451/// let settings = Settings::new("./config.toml")
452///     .unwrap_or_else(|e| panic!("Failed to load settings: {e}"));
453/// let wrappers = server_wrappers(&settings).unwrap();
454///
455/// ```
456pub struct ServerWrappers {
457    pub metrics_enabled: bool,
458    pub prometheus: PrometheusMetrics,
459    pub cors: Cors,
460}
461
462/// Creates a `ServerWrappers` instance from the given `Settings`.
463///
464/// This function initializes the server wrappers, including:
465///
466/// - Prometheus metrics
467/// - CORS configuration
468///
469/// # Parameters
470///
471/// - `settings`: Reference to the application's runtime configuration.
472///
473/// # Returns
474///
475/// Returns a `Result` containing a `ServerWrappers` instance if successful, or an error if configuration fails.
476pub fn create_server_wrappers(settings: &Settings) -> Result<ServerWrappers> {
477    let (metrics_enabled, prometheus) = configure_prometheus(settings, false)?;
478    let cors = configure_cors(settings);
479
480    Ok(ServerWrappers {
481        metrics_enabled,
482        prometheus,
483        cors,
484    })
485}
486
487/// A type alias for a `Result` with the `HttpServerError` error type.
488pub type Result<T, E = HttpServerError> = std::result::Result<T, E>;
489
490#[derive(Debug, Error)]
491pub enum HttpServerError {
492    #[error("Invalid HTTP server configuration: {0}")]
493    Configuration(String),
494
495    #[error("{0}")]
496    Custom(String),
497
498    #[error("Error on Docker Compose.")]
499    Compose,
500
501    #[error("Error initializing the HTTP server: {0}")]
502    Bootstrap(String),
503}