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};
pub struct DocumentationController;
impl DocumentationController {
pub const DOCS_PATH: &'static str = "/docs";
pub const TEST_CLIENT_HEADER: &'static str = "X-Moovable-Test-Client";
pub const TEST_PARAMS_HEADER: &'static str = "X-Moovable-Test-Params";
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(®).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
}
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);
let rel = req_path.trim_start_matches('/').to_string();
if let Some((content_type, bytes)) = assets::lookup(&rel) {
return Self::asset_response(ctx.clone(), bytes, content_type);
}
let candidates = if rel.is_empty() {
vec![format!("{}/index.html", Self::SWAGGER_UI_DIR)]
} else {
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> {
let body = match String::from_utf8(bytes.to_vec()) {
Ok(s) => s,
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
})
})
}
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();
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());
};
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,
¶meters,
)
.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) {
let mode = DocumentationRegistrant::global()
.read()
.map(|r| r.get_documentation_mode())
.unwrap_or(DocumentationMode::Conventional);
if mode != DocumentationMode::External && Self::is_production_environment() {
return;
}
router.set_test_client_handler(Self::test_hook_handler());
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![],
);
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"
}
}