shared-framework 0.0.17

Reusable building blocks for HTTP services — Hyper routing, SeaORM data layer, validation, OpenAPI docs, jobs, queues, cache.
Documentation
//! Documentation endpoints: OpenAPI JSON at `GET /docs` and the Swagger UI.
//!
//! [`DocumentationController`] mounts the JSON spec route plus a static-file
//! handler for the bundled UI. Call [`DocumentationController::build_specs`]
//! after all controllers are mounted, so the cached spec includes every route.
//!
//! Use this controller when an HTTP service should expose its registered API
//! docs. In production the routes are skipped unless the registrant mode is
//! [`DocumentationMode::External`](DocumentationMode::External).
use crate::controller::RouteDescription;
use crate::controller::{BoxHandler, RouteController, RouteControllerExt, Router};
use crate::doc::{assets, DocumentationMode, DocumentationRegistrant, OpenApi3Generator};
use crate::logging::CorrelationContext;
use crate::response::{ErrorResult, ServiceResult};
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};

/// Serves the OpenAPI document and the Swagger UI for the service.
///
/// Mounts `GET /docs` (OpenAPI 3.1 JSON) and a `GET /*` handler for the
/// bundled UI assets. In production the routes are skipped unless the
/// documentation mode is `External`.
pub struct DocumentationController;

impl DocumentationController {
    /// Path of the OpenAPI JSON document.
    pub const DOCS_PATH: &'static str = "/docs";
    /// Request header naming the test client to execute.
    pub const TEST_CLIENT_HEADER: &'static str = "X-Moovable-Test-Client";
    /// Request header carrying the JSON-encoded test parameters.
    pub const TEST_PARAMS_HEADER: &'static str = "X-Moovable-Test-Params";
    /// Filesystem fallback directory for the Swagger UI distribution (see [`assets::FS_DIR`]).
    /// The same files are embedded in the binary via [`assets`], so resolution never
    /// depends on the process-working directory.
    pub const SWAGGER_UI_DIR: &'static str = assets::FS_DIR;

    fn cached_spec() -> &'static OnceLock<String> {
        static CACHE: OnceLock<String> = OnceLock::new();
        &CACHE
    }

    fn resolve_openapi_spec() -> String {
        if let Some(cached) = Self::cached_spec().get() {
            return cached.clone();
        }
        let spec = match DocumentationRegistrant::global().read() {
            Ok(reg) => OpenApi3Generator::generate(&reg).to_string(),
            Err(_) => serde_json::json!({"openapi": "3.1.0", "info": {"title": "API", "version": "1.0.0"}, "paths": {}}).to_string(),
        };
        let _ = Self::cached_spec().set(spec.clone());
        spec
    }

    /// Builds and caches the OpenAPI document once.
    ///
    /// Call after all controllers are mounted so every registered route is
    /// included. Later requests reuse the cached JSON string.
    pub fn build_specs() {
        let _ = Self::resolve_openapi_spec();
    }

    fn is_production_environment() -> bool {
        crate::env::AppEnvironment::try_get()
            .map(|e| e.is_production())
            .unwrap_or(false)
    }

    fn normalize_client_name(name: &str) -> String {
        name.replace(' ', "-").to_lowercase()
    }

    fn is_basic_client(name: &str) -> bool {
        Self::normalize_client_name(name) == Self::normalize_client_name("Classic HTTP Client")
    }

    fn swagger_ui_handler() -> BoxHandler {
        std::sync::Arc::new(|ctx, _headers, _method, req_path, _body| {
            Box::pin(async move {
                let ctx = Arc::new(ctx);
                // Route is mounted as `/*`; strip the leading `/` to get the asset name.
                let rel = req_path.trim_start_matches('/').to_string();
                // 1. Embedded binary assets — always resolve, independent of cwd.
                if let Some((content_type, bytes)) = assets::lookup(&rel) {
                    return Self::asset_response(ctx.clone(), bytes, content_type);
                }
                // 2. Filesystem fallback under `doc/static/` (picks up local edits).
                let candidates = if rel.is_empty() {
                    vec![format!("{}/index.html", Self::SWAGGER_UI_DIR)]
                } else {
                    // Guard: never serve Rust sources or escape the asset dir.
                    if rel.ends_with(".rs") || rel.contains("..") {
                        return crate::response::error_response(
                            &ErrorResult::not_found(format!(
                                "The requested resource was not found: /{}",
                                rel
                            )),
                            ctx.clone(),
                        );
                    }
                    vec![format!("{}/{}", Self::SWAGGER_UI_DIR, rel)]
                };
                for candidate in &candidates {
                    if let Ok(bytes) = std::fs::read(candidate) {
                        return Self::asset_response(ctx.clone(), &bytes, guess_asset_type(candidate));
                    }
                }
                crate::response::error_response(
                    &ErrorResult::not_found(format!(
                        "The requested resource was not found: /{}",
                        rel
                    )),
                    ctx.clone(),
                )
            })
        })
    }

    fn asset_response(
        ctx: Arc<CorrelationContext>,
        bytes: &[u8],
        content_type: &str,
    ) -> http::Response<String> {
        // Response bodies are `String`; binary assets (favicons) are passed through
        // byte-identically. Hyper only writes the bytes — no UTF-8 validation occurs.
        let body = match String::from_utf8(bytes.to_vec()) {
            Ok(s) => s,
            // SAFETY: the bytes are forwarded untouched to the socket; no `str`
            // methods are ever called on the value. Lengths are byte lengths.
            Err(_) => unsafe { String::from_utf8_unchecked(bytes.to_vec()) },
        };
        http::Response::builder()
            .status(200)
            .header("X-Request-ID", ctx.request_id())
            .header("Content-Type", content_type)
            .body(body)
            .unwrap()
    }

    fn test_hook_handler() -> BoxHandler {
        std::sync::Arc::new(|ctx, headers, method, url, body| {
            Box::pin(async move {
                Self::handle_test_request(Arc::new(ctx), &headers, &method, &url, &body).await
            })
        })
    }

    //noinspection HttpUrlsUsage
    async fn handle_test_request(
        ctx: Arc<CorrelationContext>,
        headers: &http::HeaderMap,
        method: &http::Method,
        url: &str,
        body: &[u8],
    ) -> http::Response<String> {
        let client_name = headers
            .get("x-moovable-test-client")
            .or_else(|| headers.get("x-tm30-test-client"))
            .and_then(|v| v.to_str().ok())
            .unwrap_or("")
            .to_string();
        // Forwarded headers minus the reserved test headers (both generations)
        let mut forwarded: HashMap<String, String> = HashMap::new();
        for (k, v) in headers.iter() {
            let key = k.as_str();
            if key.eq_ignore_ascii_case(Self::TEST_CLIENT_HEADER)
                || key.eq_ignore_ascii_case(Self::TEST_PARAMS_HEADER)
            {
                continue;
            }
            if let Ok(val) = v.to_str() {
                forwarded.insert(key.to_string(), val.to_string());
            }
        }
        let body_str = String::from_utf8_lossy(body).into_owned();
        let params_raw = headers
            .get("x-moovable-test-params")
            .or_else(|| headers.get("x-tm30-test-params"))
            .and_then(|v| v.to_str().ok())
            .unwrap_or("{}");
        let params_json: serde_json::Value =
            serde_json::from_str(params_raw).unwrap_or(serde_json::json!({}));
        let mut parameters: HashMap<String, String> = HashMap::new();
        if let Some(obj) = params_json.as_object() {
            for (k, v) in obj {
                parameters.insert(
                    k.clone(),
                    if v.is_string() {
                        v.as_str().unwrap_or("").to_string()
                    } else {
                        v.to_string()
                    },
                );
            }
        }

        let production = Self::is_production_environment();
        let clients = match DocumentationRegistrant::global().read() {
            Ok(reg) => reg.get_http_clients(),
            Err(_) => {
                let err = ErrorResult::internal("documentation registry unavailable");
                return crate::response::error_response(&err, ctx.clone());
            }
        };
        let available: Vec<_> = if production {
            clients
                .into_iter()
                .filter(|c| Self::is_basic_client(c.name()))
                .collect()
        } else {
            clients
        };
        let selected = available.into_iter().find(|c| {
            Self::normalize_client_name(c.name()) == Self::normalize_client_name(&client_name)
        });
        let Some(client) = selected else {
            let msg = if production {
                format!("HTTP Client not allowed in production: {}", client_name)
            } else {
                format!("HTTP Client not found: {}", client_name)
            };
            let err = ErrorResult::new(msg, None, 400);
            return crate::response::error_response(&err, ctx.clone());
        };

        // Reconstruct the absolute URL (path here; host from headers).
        let full_url = if url.starts_with("http") {
            url.to_string()
        } else {
            let host = headers
                .get("host")
                .and_then(|v| v.to_str().ok())
                .unwrap_or("localhost");
            format!("http://{}{}", host, url)
        };
        let body_opt = if body_str.is_empty() {
            None
        } else {
            Some(body_str.as_str())
        };
        match client
            .execute(
                method.as_str(),
                &full_url,
                &forwarded,
                body_opt,
                &parameters,
            )
            .await
        {
            Ok(res) => {
                let data = serde_json::json!({
                    "statusCode": res.status_code,
                    "headers": res.headers,
                    "body": res.body,
                });
                let result = ServiceResult::ok("Request executed", data);
                crate::response::build_response(&result, ctx.clone())
            }
            Err(e) => {
                let err = ErrorResult::new(e.to_string(), None, 500);
                crate::response::error_response(&err, ctx.clone())
            }
        }
    }

    async fn get_docs(
        _ctx: CorrelationContext,
    ) -> Result<ServiceResult<serde_json::Value>, ErrorResult> {
        let spec = Self::resolve_openapi_spec();
        let value: serde_json::Value = serde_json::from_str(&spec).unwrap_or(serde_json::json!({}));
        Ok(ServiceResult::ok("Docs", value).stripped())
    }
}

#[async_trait::async_trait]
impl RouteController for DocumentationController {
    fn base_path(&self) -> &str {
        "/"
    }

    async fn register_routes(&self, router: &mut Router) {
        // No swagger docs in production unless EXTERNAL mode.
        let mode = DocumentationRegistrant::global()
            .read()
            .map(|r| r.get_documentation_mode())
            .unwrap_or(DocumentationMode::Conventional);
        if mode != DocumentationMode::External && Self::is_production_environment() {
            return;
        }

        // Catch-all guard that only intercepts reserved test-execution requests.
        router.set_test_client_handler(Self::test_hook_handler());

        // OpenAPI 3.1 specification as JSON.
        self.mount_get(
            router,
            Self::DOCS_PATH,
            RouteDescription::new("OpenAPI specification")
                .description("Returns the OpenAPI 3.1 JSON document for the API")
                .group("Documentation"),
            DocumentationController::get_docs,
            vec![],
        );

        // Swagger UI static distribution at the root anchor. Embedded assets
        // resolve first, so the UI works regardless of cwd; `doc/static/` on
        // disk is the edit-friendly fallback.
        router.mount_raw(
            "/",
            0,
            "/*",
            http::Method::GET,
            Self::swagger_ui_handler(),
            vec![],
        );
    }
}

fn guess_asset_type(path: &str) -> &'static str {
    let lower = path.to_ascii_lowercase();
    if lower.ends_with(".html") || lower.ends_with(".htm") {
        "text/html"
    } else if lower.ends_with(".js") {
        "application/javascript"
    } else if lower.ends_with(".css") {
        "text/css"
    } else if lower.ends_with(".json") {
        "application/json"
    } else if lower.ends_with(".png") {
        "image/png"
    } else if lower.ends_with(".svg") {
        "image/svg+xml"
    } else {
        "text/plain"
    }
}