1use 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
18pub struct DocumentationController;
24
25impl DocumentationController {
26 pub const DOCS_PATH: &'static str = "/docs";
28 pub const TEST_CLIENT_HEADER: &'static str = "X-Moovable-Test-Client";
30 pub const TEST_PARAMS_HEADER: &'static str = "X-Moovable-Test-Params";
32 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(®).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 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 let rel = req_path.trim_start_matches('/').to_string();
82 if let Some((content_type, bytes)) = assets::lookup(&rel) {
84 return Self::asset_response(ctx.clone(), bytes, content_type);
85 }
86 let candidates = if rel.is_empty() {
88 vec![format!("{}/index.html", Self::SWAGGER_UI_DIR)]
89 } else {
90 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 let body = match String::from_utf8(bytes.to_vec()) {
126 Ok(s) => s,
127 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 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 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 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 ¶meters,
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 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 router.set_test_client_handler(Self::test_hook_handler());
293
294 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 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}