Skip to main content

shared_framework/doc/
controller.rs

1//! Documentation endpoints: OpenAPI JSON at `GET /docs` and the Swagger UI.
2//!
3//! [`DocumentationController`] mounts the JSON spec route plus a static-file
4//! handler for the bundled UI. Call [`DocumentationController::build_specs`]
5//! after all controllers are mounted, so the cached spec includes every route.
6//!
7//! Use this controller when an HTTP service should expose its registered API
8//! docs. In production the routes are skipped unless the registrant mode is
9//! [`DocumentationMode::External`](DocumentationMode::External).
10use crate::controller::RouteDescription;
11use crate::controller::{BoxHandler, RouteController, RouteControllerExt, Router};
12use crate::doc::{assets, DocumentationMode, DocumentationRegistrant, OpenApi3Generator};
13use crate::logging::CorrelationContext;
14use crate::response::{ErrorResult, ServiceResult};
15use std::collections::HashMap;
16use std::sync::{Arc, OnceLock};
17
18/// Serves the OpenAPI document and the Swagger UI for the service.
19///
20/// Mounts `GET /docs` (OpenAPI 3.1 JSON) and a `GET /*` handler for the
21/// bundled UI assets. In production the routes are skipped unless the
22/// documentation mode is `External`.
23pub struct DocumentationController;
24
25impl DocumentationController {
26    /// Path of the OpenAPI JSON document.
27    pub const DOCS_PATH: &'static str = "/docs";
28    /// Request header naming the test client to execute.
29    pub const TEST_CLIENT_HEADER: &'static str = "X-Moovable-Test-Client";
30    /// Request header carrying the JSON-encoded test parameters.
31    pub const TEST_PARAMS_HEADER: &'static str = "X-Moovable-Test-Params";
32    /// Filesystem fallback directory for the Swagger UI distribution (see [`assets::FS_DIR`]).
33    /// The same files are embedded in the binary via [`assets`], so resolution never
34    /// depends on the process-working directory.
35    pub const SWAGGER_UI_DIR: &'static str = assets::FS_DIR;
36
37    fn cached_spec() -> &'static OnceLock<String> {
38        static CACHE: OnceLock<String> = OnceLock::new();
39        &CACHE
40    }
41
42    fn resolve_openapi_spec() -> String {
43        if let Some(cached) = Self::cached_spec().get() {
44            return cached.clone();
45        }
46        let spec = match DocumentationRegistrant::global().read() {
47            Ok(reg) => OpenApi3Generator::generate(&reg).to_string(),
48            Err(_) => serde_json::json!({"openapi": "3.1.0", "info": {"title": "API", "version": "1.0.0"}, "paths": {}}).to_string(),
49        };
50        let _ = Self::cached_spec().set(spec.clone());
51        spec
52    }
53
54    /// Builds and caches the OpenAPI document once.
55    ///
56    /// Call after all controllers are mounted so every registered route is
57    /// included. Later requests reuse the cached JSON string.
58    pub fn build_specs() {
59        let _ = Self::resolve_openapi_spec();
60    }
61
62    fn is_production_environment() -> bool {
63        crate::env::AppEnvironment::try_get()
64            .map(|e| e.is_production())
65            .unwrap_or(false)
66    }
67
68    fn normalize_client_name(name: &str) -> String {
69        name.replace(' ', "-").to_lowercase()
70    }
71
72    fn is_basic_client(name: &str) -> bool {
73        Self::normalize_client_name(name) == Self::normalize_client_name("Classic HTTP Client")
74    }
75
76    fn swagger_ui_handler() -> BoxHandler {
77        std::sync::Arc::new(|ctx, _headers, _method, req_path, _body| {
78            Box::pin(async move {
79                let ctx = Arc::new(ctx);
80                // Route is mounted as `/*`; strip the leading `/` to get the asset name.
81                let rel = req_path.trim_start_matches('/').to_string();
82                // 1. Embedded binary assets — always resolve, independent of cwd.
83                if let Some((content_type, bytes)) = assets::lookup(&rel) {
84                    return Self::asset_response(ctx.clone(), bytes, content_type);
85                }
86                // 2. Filesystem fallback under `doc/static/` (picks up local edits).
87                let candidates = if rel.is_empty() {
88                    vec![format!("{}/index.html", Self::SWAGGER_UI_DIR)]
89                } else {
90                    // Guard: never serve Rust sources or escape the asset dir.
91                    if rel.ends_with(".rs") || rel.contains("..") {
92                        return crate::response::error_response(
93                            &ErrorResult::not_found(format!(
94                                "The requested resource was not found: /{}",
95                                rel
96                            )),
97                            ctx.clone(),
98                        );
99                    }
100                    vec![format!("{}/{}", Self::SWAGGER_UI_DIR, rel)]
101                };
102                for candidate in &candidates {
103                    if let Ok(bytes) = std::fs::read(candidate) {
104                        return Self::asset_response(ctx.clone(), &bytes, guess_asset_type(candidate));
105                    }
106                }
107                crate::response::error_response(
108                    &ErrorResult::not_found(format!(
109                        "The requested resource was not found: /{}",
110                        rel
111                    )),
112                    ctx.clone(),
113                )
114            })
115        })
116    }
117
118    fn asset_response(
119        ctx: Arc<CorrelationContext>,
120        bytes: &[u8],
121        content_type: &str,
122    ) -> http::Response<String> {
123        // Response bodies are `String`; binary assets (favicons) are passed through
124        // byte-identically. Hyper only writes the bytes — no UTF-8 validation occurs.
125        let body = match String::from_utf8(bytes.to_vec()) {
126            Ok(s) => s,
127            // SAFETY: the bytes are forwarded untouched to the socket; no `str`
128            // methods are ever called on the value. Lengths are byte lengths.
129            Err(_) => unsafe { String::from_utf8_unchecked(bytes.to_vec()) },
130        };
131        http::Response::builder()
132            .status(200)
133            .header("X-Request-ID", ctx.request_id())
134            .header("Content-Type", content_type)
135            .body(body)
136            .unwrap()
137    }
138
139    fn test_hook_handler() -> BoxHandler {
140        std::sync::Arc::new(|ctx, headers, method, url, body| {
141            Box::pin(async move {
142                Self::handle_test_request(Arc::new(ctx), &headers, &method, &url, &body).await
143            })
144        })
145    }
146
147    //noinspection HttpUrlsUsage
148    async fn handle_test_request(
149        ctx: Arc<CorrelationContext>,
150        headers: &http::HeaderMap,
151        method: &http::Method,
152        url: &str,
153        body: &[u8],
154    ) -> http::Response<String> {
155        let client_name = headers
156            .get("x-moovable-test-client")
157            .or_else(|| headers.get("x-tm30-test-client"))
158            .and_then(|v| v.to_str().ok())
159            .unwrap_or("")
160            .to_string();
161        // Forwarded headers minus the reserved test headers (both generations)
162        let mut forwarded: HashMap<String, String> = HashMap::new();
163        for (k, v) in headers.iter() {
164            let key = k.as_str();
165            if key.eq_ignore_ascii_case(Self::TEST_CLIENT_HEADER)
166                || key.eq_ignore_ascii_case(Self::TEST_PARAMS_HEADER)
167            {
168                continue;
169            }
170            if let Ok(val) = v.to_str() {
171                forwarded.insert(key.to_string(), val.to_string());
172            }
173        }
174        let body_str = String::from_utf8_lossy(body).into_owned();
175        let params_raw = headers
176            .get("x-moovable-test-params")
177            .or_else(|| headers.get("x-tm30-test-params"))
178            .and_then(|v| v.to_str().ok())
179            .unwrap_or("{}");
180        let params_json: serde_json::Value =
181            serde_json::from_str(params_raw).unwrap_or(serde_json::json!({}));
182        let mut parameters: HashMap<String, String> = HashMap::new();
183        if let Some(obj) = params_json.as_object() {
184            for (k, v) in obj {
185                parameters.insert(
186                    k.clone(),
187                    if v.is_string() {
188                        v.as_str().unwrap_or("").to_string()
189                    } else {
190                        v.to_string()
191                    },
192                );
193            }
194        }
195
196        let production = Self::is_production_environment();
197        let clients = match DocumentationRegistrant::global().read() {
198            Ok(reg) => reg.get_http_clients(),
199            Err(_) => {
200                let err = ErrorResult::internal("documentation registry unavailable");
201                return crate::response::error_response(&err, ctx.clone());
202            }
203        };
204        let available: Vec<_> = if production {
205            clients
206                .into_iter()
207                .filter(|c| Self::is_basic_client(c.name()))
208                .collect()
209        } else {
210            clients
211        };
212        let selected = available.into_iter().find(|c| {
213            Self::normalize_client_name(c.name()) == Self::normalize_client_name(&client_name)
214        });
215        let Some(client) = selected else {
216            let msg = if production {
217                format!("HTTP Client not allowed in production: {}", client_name)
218            } else {
219                format!("HTTP Client not found: {}", client_name)
220            };
221            let err = ErrorResult::new(msg, None, 400);
222            return crate::response::error_response(&err, ctx.clone());
223        };
224
225        // Reconstruct the absolute URL (path here; host from headers).
226        let full_url = if url.starts_with("http") {
227            url.to_string()
228        } else {
229            let host = headers
230                .get("host")
231                .and_then(|v| v.to_str().ok())
232                .unwrap_or("localhost");
233            format!("http://{}{}", host, url)
234        };
235        let body_opt = if body_str.is_empty() {
236            None
237        } else {
238            Some(body_str.as_str())
239        };
240        match client
241            .execute(
242                method.as_str(),
243                &full_url,
244                &forwarded,
245                body_opt,
246                &parameters,
247            )
248            .await
249        {
250            Ok(res) => {
251                let data = serde_json::json!({
252                    "statusCode": res.status_code,
253                    "headers": res.headers,
254                    "body": res.body,
255                });
256                let result = ServiceResult::ok("Request executed", data);
257                crate::response::build_response(&result, ctx.clone())
258            }
259            Err(e) => {
260                let err = ErrorResult::new(e.to_string(), None, 500);
261                crate::response::error_response(&err, ctx.clone())
262            }
263        }
264    }
265
266    async fn get_docs(
267        _ctx: CorrelationContext,
268    ) -> Result<ServiceResult<serde_json::Value>, ErrorResult> {
269        let spec = Self::resolve_openapi_spec();
270        let value: serde_json::Value = serde_json::from_str(&spec).unwrap_or(serde_json::json!({}));
271        Ok(ServiceResult::ok("Docs", value).stripped())
272    }
273}
274
275#[async_trait::async_trait]
276impl RouteController for DocumentationController {
277    fn base_path(&self) -> &str {
278        "/"
279    }
280
281    async fn register_routes(&self, router: &mut Router) {
282        // No swagger docs in production unless EXTERNAL mode.
283        let mode = DocumentationRegistrant::global()
284            .read()
285            .map(|r| r.get_documentation_mode())
286            .unwrap_or(DocumentationMode::Conventional);
287        if mode != DocumentationMode::External && Self::is_production_environment() {
288            return;
289        }
290
291        // Catch-all guard that only intercepts reserved test-execution requests.
292        router.set_test_client_handler(Self::test_hook_handler());
293
294        // OpenAPI 3.1 specification as JSON.
295        self.mount_get(
296            router,
297            Self::DOCS_PATH,
298            RouteDescription::new("OpenAPI specification")
299                .description("Returns the OpenAPI 3.1 JSON document for the API")
300                .group("Documentation"),
301            DocumentationController::get_docs,
302            vec![],
303        );
304
305        // Swagger UI static distribution at the root anchor. Embedded assets
306        // resolve first, so the UI works regardless of cwd; `doc/static/` on
307        // disk is the edit-friendly fallback.
308        router.mount_raw(
309            "/",
310            0,
311            "/*",
312            http::Method::GET,
313            Self::swagger_ui_handler(),
314            vec![],
315        );
316    }
317}
318
319fn guess_asset_type(path: &str) -> &'static str {
320    let lower = path.to_ascii_lowercase();
321    if lower.ends_with(".html") || lower.ends_with(".htm") {
322        "text/html"
323    } else if lower.ends_with(".js") {
324        "application/javascript"
325    } else if lower.ends_with(".css") {
326        "text/css"
327    } else if lower.ends_with(".json") {
328        "application/json"
329    } else if lower.ends_with(".png") {
330        "image/png"
331    } else if lower.ends_with(".svg") {
332        "image/svg+xml"
333    } else {
334        "text/plain"
335    }
336}