pub mod assets;
pub mod controller;
use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashMap};
use std::fmt::Debug;
use std::sync::{Arc, OnceLock, RwLock};
pub trait DocumentableDTO:
Send + Sync + Debug + DeserializeOwned + Serialize + JsonSchema + Clone
{
fn get_example() -> Self;
fn make_example() -> Result<Value, serde_json::Error> {
let example = Self::get_example();
serde_json::to_value(example)
}
}
pub use crate::controller::{FileParameter, FileParameterType, RouteDescription};
#[derive(Debug, Clone)]
pub struct RegisteredRoute {
pub path: String,
pub method: String,
pub controller_class: String,
pub description: RouteDescription,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DocumentationMode {
#[default]
Conventional,
External,
}
#[derive(Debug, Clone)]
pub struct DocHttpResponse {
pub status_code: u16,
pub headers: HashMap<String, String>,
pub body: String,
}
#[async_trait::async_trait]
pub trait DocHttpClient: Send + Sync + Debug {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn usage_snippet(&self) -> &str;
async fn execute(
&self,
method: &str,
url: &str,
headers: &HashMap<String, String>,
body: Option<&str>,
parameters: &HashMap<String, String>,
) -> anyhow::Result<DocHttpResponse>;
}
fn append_query_params(url: &str, params: &HashMap<String, String>) -> String {
if params.is_empty() {
return url.to_string();
}
let mut out = url.to_string();
let sep = if url.contains('?') { '&' } else { '?' };
out.push(sep);
let mut first = true;
for (k, v) in params {
if !first {
out.push('&');
}
first = false;
out.push_str(&urlencoding_fallback(k));
out.push('=');
out.push_str(&urlencoding_fallback(v));
}
out
}
fn urlencoding_fallback(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{:02X}", b));
}
}
out
}
async fn execute_with_reqwest(
method: &str,
url: &str,
headers: &HashMap<String, String>,
body: Option<&str>,
parameters: &HashMap<String, String>,
timeout_secs: u64,
) -> anyhow::Result<DocHttpResponse> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_secs))
.build()?;
let url = append_query_params(url, parameters);
let mut req = client.request(method.parse().unwrap_or(reqwest::Method::GET), &url);
for (k, v) in headers {
req = req.header(k.as_str(), v.as_str());
}
if let Some(b) = body {
if !b.is_empty() {
req = req.body(b.to_string());
}
}
let resp = req.send().await?;
let status_code = resp.status().as_u16();
let mut out_headers = HashMap::new();
for (k, v) in resp.headers() {
out_headers.insert(k.to_string(), v.to_str().unwrap_or("").to_string());
}
let body = resp.text().await.unwrap_or_default();
Ok(DocHttpResponse {
status_code,
headers: out_headers,
body,
})
}
#[derive(Debug, Default)]
pub struct ClassicHttpClient;
#[async_trait::async_trait]
impl DocHttpClient for ClassicHttpClient {
fn name(&self) -> &str {
"Classic HTTP Client"
}
fn description(&self) -> &str {
"A simple HTTP client for scalability."
}
fn usage_snippet(&self) -> &str {
"reqwest::Client::new().get(url).send().await"
}
async fn execute(
&self,
method: &str,
url: &str,
headers: &HashMap<String, String>,
body: Option<&str>,
parameters: &HashMap<String, String>,
) -> anyhow::Result<DocHttpResponse> {
execute_with_reqwest(method, url, headers, body, parameters, 30).await
}
}
#[derive(Debug, Default)]
pub struct ThrottledHttpClient;
#[async_trait::async_trait]
impl DocHttpClient for ThrottledHttpClient {
fn name(&self) -> &str {
"Throttled HTTP Client"
}
fn description(&self) -> &str {
"Rate-limited HTTP client that spaces out requests."
}
fn usage_snippet(&self) -> &str {
"throttled: sleep 100ms between requests"
}
async fn execute(
&self,
method: &str,
url: &str,
headers: &HashMap<String, String>,
body: Option<&str>,
parameters: &HashMap<String, String>,
) -> anyhow::Result<DocHttpResponse> {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
execute_with_reqwest(method, url, headers, body, parameters, 30).await
}
}
#[derive(Debug, Default)]
pub struct BurstHttpClient;
#[async_trait::async_trait]
impl DocHttpClient for BurstHttpClient {
fn name(&self) -> &str {
"Burst HTTP Client"
}
fn description(&self) -> &str {
"Short-timeout client for burst testing."
}
fn usage_snippet(&self) -> &str {
"burst: 5s timeout, no delay"
}
async fn execute(
&self,
method: &str,
url: &str,
headers: &HashMap<String, String>,
body: Option<&str>,
parameters: &HashMap<String, String>,
) -> anyhow::Result<DocHttpResponse> {
execute_with_reqwest(method, url, headers, body, parameters, 5).await
}
}
pub struct DocumentationRegistrant {
base_path: String,
server_paths: Vec<String>,
global_rate_limit: Option<u32>,
auth_settings: HashMap<String, String>,
group_descriptions: HashMap<String, String>,
registered_routes: Vec<RegisteredRoute>,
legacy_global_headers: HashMap<String, String>,
unauthenticated_global_headers: HashMap<String, String>,
authenticated_global_headers: HashMap<String, String>,
http_clients: Vec<Arc<dyn DocHttpClient>>,
documentation_mode: DocumentationMode,
}
impl DocumentationRegistrant {
fn new() -> Self {
let mut s = Self {
base_path: "/".to_string(),
server_paths: vec!["/".to_string()],
global_rate_limit: None,
auth_settings: HashMap::new(),
group_descriptions: HashMap::new(),
registered_routes: Vec::new(),
legacy_global_headers: HashMap::new(),
unauthenticated_global_headers: HashMap::new(),
authenticated_global_headers: HashMap::new(),
http_clients: Vec::new(),
documentation_mode: DocumentationMode::Conventional,
};
s.http_clients.push(Arc::new(ClassicHttpClient));
s.http_clients.push(Arc::new(ThrottledHttpClient));
s.http_clients.push(Arc::new(BurstHttpClient));
s
}
pub fn global() -> &'static RwLock<Self> {
static INSTANCE: OnceLock<RwLock<DocumentationRegistrant>> = OnceLock::new();
INSTANCE.get_or_init(|| RwLock::new(Self::new()))
}
pub fn get_instance() -> std::sync::RwLockReadGuard<'static, Self> {
Self::global().read().unwrap()
}
pub fn get_instance_mut() -> std::sync::RwLockWriteGuard<'static, Self> {
Self::global().write().unwrap()
}
pub fn bind_base(&mut self, base: impl Into<String>) {
let b = base.into();
self.base_path = if b.is_empty() { "/".to_string() } else { b };
self.server_paths = vec![self.base_path.clone()];
}
pub fn bind_base_list(&mut self, roots: &[String]) {
let mut paths = Vec::new();
for root in roots {
let path = if root.trim().is_empty() {
"/".to_string()
} else {
root.clone()
};
if !paths.contains(&path) {
paths.push(path);
}
}
if paths.is_empty() {
paths.push("/".to_string());
}
self.base_path = paths[0].clone();
self.server_paths = paths;
}
pub fn base_path(&self) -> &str {
&self.base_path
}
pub fn server_paths(&self) -> &[String] {
&self.server_paths
}
pub fn set_global_rate_limit(&mut self, limit: Option<u32>) {
self.global_rate_limit = limit;
}
pub fn get_global_rate_limit(&self) -> Option<u32> {
self.global_rate_limit
}
pub fn set_auth_settings(&mut self, m: HashMap<String, String>) {
self.auth_settings = m;
}
pub fn get_auth_settings(&self) -> &HashMap<String, String> {
&self.auth_settings
}
pub fn set_global_headers(&mut self, headers: HashMap<String, String>) {
self.legacy_global_headers = headers;
}
pub fn set_global_headers_scoped(
&mut self,
headers: HashMap<String, String>,
authenticated_only: bool,
) {
if authenticated_only {
self.authenticated_global_headers = headers;
} else {
self.unauthenticated_global_headers = headers;
}
}
pub fn get_global_headers(&self) -> HashMap<String, String> {
let mut combined = self.legacy_global_headers.clone();
combined.extend(self.unauthenticated_global_headers.clone());
combined.extend(self.authenticated_global_headers.clone());
combined
}
pub fn get_authenticated_global_headers(&self) -> HashMap<String, String> {
let mut combined = self.legacy_global_headers.clone();
combined.extend(self.authenticated_global_headers.clone());
combined
}
pub fn get_authenticated_only_headers(&self) -> &HashMap<String, String> {
&self.authenticated_global_headers
}
pub fn get_unauthenticated_global_headers(&self) -> HashMap<String, String> {
let mut combined = self.legacy_global_headers.clone();
combined.extend(self.unauthenticated_global_headers.clone());
combined
}
pub fn set_documentation_mode(&mut self, mode: DocumentationMode) {
self.documentation_mode = mode;
}
pub fn get_documentation_mode(&self) -> DocumentationMode {
self.documentation_mode
}
pub fn register_http_client(&mut self, client: Arc<dyn DocHttpClient>) {
self.http_clients.push(client);
}
pub fn get_http_clients(&self) -> Vec<Arc<dyn DocHttpClient>> {
self.http_clients.clone()
}
pub fn set_group_description(&mut self, group: impl Into<String>, desc: impl Into<String>) {
self.group_descriptions.insert(group.into(), desc.into());
}
pub fn set_group_descriptions(&mut self, descriptions: HashMap<String, String>) {
self.group_descriptions.clear();
for (k, v) in descriptions {
if !k.trim().is_empty() {
self.group_descriptions
.insert(k.trim().to_string(), v.trim().to_string());
}
}
}
pub fn get_group_description(&self, group: &str) -> String {
if group.trim().is_empty() {
return "N/A".to_string();
}
match self.group_descriptions.get(group.trim()) {
Some(d) if !d.trim().is_empty() => d.clone(),
_ => "N/A".to_string(),
}
}
pub fn get_group_descriptions(&self) -> &HashMap<String, String> {
&self.group_descriptions
}
pub fn register_route(
&mut self,
path: &str,
method: &str,
controller_class: &str,
description: RouteDescription,
) {
self.registered_routes.push(RegisteredRoute {
path: path.to_string(),
method: method.to_string(),
controller_class: controller_class.to_string(),
description,
});
}
pub fn get_registered_routes(&self) -> &[RegisteredRoute] {
&self.registered_routes
}
pub fn clear(&mut self) {
self.registered_routes.clear();
}
}
impl Default for DocumentationRegistrant {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteDoc {
pub path: String,
pub method: String,
pub controller: String,
pub summary: String,
pub tags: Vec<String>,
}
#[derive(Debug, Default)]
struct ComponentRegistry {
schemas: serde_json::Map<String, Value>,
request_bodies: serde_json::Map<String, Value>,
responses: serde_json::Map<String, Value>,
}
impl ComponentRegistry {
fn section_mut(&mut self, name: &str) -> &mut serde_json::Map<String, Value> {
match name {
"schemas" => &mut self.schemas,
"requestBodies" => &mut self.request_bodies,
"responses" => &mut self.responses,
_ => panic!("unsupported component section: {}", name),
}
}
fn to_json(self) -> Value {
let mut out = serde_json::Map::new();
if !self.schemas.is_empty() {
out.insert("schemas".to_string(), Value::Object(self.schemas));
}
if !self.request_bodies.is_empty() {
out.insert(
"requestBodies".to_string(),
Value::Object(self.request_bodies),
);
}
if !self.responses.is_empty() {
out.insert("responses".to_string(), Value::Object(self.responses));
}
Value::Object(out)
}
fn component_ref(&mut self, section: &str, name: &str, definition: Value) -> Value {
let entry = self.section_mut(section);
if !entry.contains_key(name) {
entry.insert(name.to_string(), definition);
}
json!({ "$ref": format!("#/components/{}/{}", section, name) })
}
}
pub struct OpenApi3Generator;
impl OpenApi3Generator {
pub fn generate(registrant: &DocumentationRegistrant) -> Value {
Self::generate_with_info(registrant, None, None)
}
pub fn generate_with_title(
registrant: &DocumentationRegistrant,
title: &str,
version: &str,
) -> Value {
Self::generate_with_info(registrant, Some(title), Some(version))
}
fn generate_with_info(
registrant: &DocumentationRegistrant,
title: Option<&str>,
version: Option<&str>,
) -> Value {
let mut registry = ComponentRegistry::default();
let info = Self::build_info(registrant, title, version);
let servers = Self::build_servers(registrant);
let paths = Self::build_paths(registrant, &mut registry);
let components = Self::resolve_components(registrant, registry);
let groups = Self::build_group_descriptions(registrant);
let mut spec = json!({
"openapi": "3.1.0",
"info": info,
"servers": servers,
"paths": paths,
});
if let Some(obj) = spec.as_object_mut() {
if !components.is_null() && components.as_object().map_or(false, |m| !m.is_empty()) {
obj.insert("components".to_string(), components.clone());
}
if Self::security_scheme_key(&components).is_some() {
obj.insert("security".to_string(), json!([]));
}
if !groups.is_empty() {
obj.insert("x-groups".to_string(), Value::Array(groups));
}
}
spec
}
fn build_info(
registrant: &DocumentationRegistrant,
title: Option<&str>,
version: Option<&str>,
) -> Value {
let (env_title, env_version) = if let Some(env) = crate::env::AppEnvironment::try_get() {
(env.name.clone(), env.version_code.clone())
} else {
("API Documentation".to_string(), "1.0.0".to_string())
};
let t = title.unwrap_or(&env_title);
let v = version.unwrap_or(&env_version);
let description = Self::build_global_description(registrant);
json!({ "title": t, "version": v, "description": description })
}
fn build_global_description(registrant: &DocumentationRegistrant) -> String {
let mut lines = vec![
"Live API specification for this service.".to_string(),
String::new(),
];
lines.push(format!(
"Default rate limit: {}",
registrant
.get_global_rate_limit()
.map_or("Unlimited".to_string(), |l| format!(
"{} requests per minute",
l
))
));
if !registrant.get_auth_settings().is_empty() {
lines.push(String::new());
lines.push("Authentication settings:".to_string());
for (k, v) in registrant.get_auth_settings() {
lines.push(format!("- `{}`: `{}`", k, v));
}
}
lines.join("\n")
}
fn build_servers(registrant: &DocumentationRegistrant) -> Value {
let mut servers: Vec<Value> = registrant
.server_paths()
.iter()
.map(|path| {
let normalized = if path == "/" {
"/".to_string()
} else {
format!("{}/", path.trim_end_matches('/'))
};
json!({ "url": normalized })
})
.collect();
servers.push(json!({ "url": "/"}));
Value::Array(servers)
}
fn build_paths(
registrant: &DocumentationRegistrant,
registry: &mut ComponentRegistry,
) -> Value {
let registers_auth = Self::route_registers_authentication(registrant);
let mut grouped: BTreeMap<String, Vec<&RegisteredRoute>> = BTreeMap::new();
let mut routes: Vec<&RegisteredRoute> = registrant.get_registered_routes().iter().collect();
routes.sort_by(|a, b| a.path.cmp(&b.path).then(a.method.cmp(&b.method)));
for route in routes {
if route.controller_class.contains("DocumentationController") {
continue;
}
if route.description.group.trim().is_empty() {
continue;
}
grouped
.entry(route.description.group.clone())
.or_default()
.push(route);
}
let mut paths: BTreeMap<String, Value> = BTreeMap::new();
for (_group, routes) in grouped {
for route in routes {
let desc = &route.description;
let openapi_path = Self::open_api_path(&route.path);
let entry = paths
.entry(openapi_path.clone())
.or_insert_with(|| json!({}));
if let Some(obj) = entry.as_object_mut() {
let op = Self::build_operation(
desc,
&route.path,
registry,
registrant,
registers_auth,
);
obj.insert(route.method.to_lowercase(), op);
}
}
}
Value::Object(paths.into_iter().collect())
}
fn build_operation(
desc: &RouteDescription,
route_path: &str,
registry: &mut ComponentRegistry,
registrant: &DocumentationRegistrant,
registers_auth: bool,
) -> Value {
let summary = if desc.name.is_empty() {
desc.summary.clone()
} else {
desc.name.clone()
};
let mut op = json!({
"summary": summary,
"operationId": Self::operation_id(&desc.name, route_path),
"tags": [desc.group.clone()],
});
if !desc.description.is_empty() {
op["description"] = Value::String(desc.description.clone());
}
if desc.is_deprecated {
op["deprecated"] = Value::Bool(true);
}
let params = Self::build_parameters(desc, registrant);
if !params.is_empty() {
op["parameters"] = Value::Array(params);
}
if let Some(rb) = Self::build_request_body(desc, route_path, registry) {
op["requestBody"] = rb;
}
op["responses"] = Self::build_responses(desc, registry);
op["x-authentication"] = json!({
"required": desc.authentication_required,
"comment": desc.authentication_comment.clone().unwrap_or_default()
});
if let Some(limit) = desc.effective_rate_limit(registrant.get_global_rate_limit()) {
if limit > 0 {
op["x-rate-limit"] = json!({ "limit": limit, "period": "minute" });
}
}
if desc.authentication_required && registers_auth {
op["security"] = json!([{ "authentication": [] }]);
}
op
}
fn build_parameters(
desc: &RouteDescription,
registrant: &DocumentationRegistrant,
) -> Vec<Value> {
let mut out = Vec::new();
for (name, descr) in &desc.path_parameters {
let mut schema = json!({ "type": "string" });
if let Some(def) = desc.path_parameter_defaults.get(name) {
schema["default"] = Value::String(def.clone());
}
out.push(json!({
"name": name,
"in": "path",
"required": true,
"description": descr,
"schema": schema
}));
}
for (name, descr) in &desc.query_parameters {
let mut schema = json!({ "type": "string" });
if let Some(def) = desc.query_parameter_defaults.get(name) {
schema["default"] = Value::String(def.clone());
}
out.push(json!({
"name": name,
"in": "query",
"required": false,
"description": descr,
"schema": schema
}));
}
let mut headers = registrant.get_unauthenticated_global_headers();
for (k, v) in &desc.headers {
headers.insert(k.clone(), v.clone());
}
for (name, val) in &headers {
if ["accept", "content-type", "authorization"].contains(&name.to_lowercase().as_str()) {
continue;
}
out.push(json!({
"name": name,
"in": "header",
"required": false,
"description": val,
"schema": { "type": "string", "default": "" }
}));
}
out
}
fn build_request_body(
desc: &RouteDescription,
route_path: &str,
registry: &mut ComponentRegistry,
) -> Option<Value> {
if !desc.file_parameters.is_empty() {
let mut properties = serde_json::Map::new();
properties.insert(
"body".to_string(),
json!({
"type": "string",
"description": "JSON-encoded application payload sent alongside the uploaded files, if any. Switch to the application/json to see the type information"
}),
);
for (name, param) in &desc.file_parameters {
let mut schema = json!({
"type": "string",
"format": "binary",
"description": param.description,
});
if let Some(max) = param.count.or(param.limit) {
schema["x-max-files"] = json!(max);
}
schema["x-allowed-extensions"] =
json!(param.file_type.allowed_extensions_example());
properties.insert(name.clone(), schema);
}
let mut content = serde_json::Map::new();
content.insert(
"multipart/form-data".to_string(),
json!({ "schema": { "type": "object", "properties": properties } }),
);
if let Some(dto) = desc.request_body.as_ref() {
if dto.name != "Void" && !dto.name.is_empty() {
let schema =
Self::schema_reference_for_name(dto.name, dto.schema.clone(), registry);
content.insert(
"application/json".to_string(),
json!({ "schema": schema, "example": dto.example.clone() }),
);
}
}
let name = format!(
"RequestBody<{}>",
Self::operation_id(&desc.name, route_path)
);
return Some(registry.component_ref(
"requestBodies",
&name,
json!({ "required": true, "content": content }),
));
}
let dto = desc.request_body.as_ref()?;
let dto_type = dto.name;
if dto_type == "String" {
return Some(json!({
"required": true,
"content": {
"text/plain": { "schema": { "type": "string" } }
}
}));
}
if dto_type == "Void" || dto_type.is_empty() {
return None;
}
let schema = Self::schema_reference_for_name(dto_type, dto.schema.clone(), registry);
let definition = json!({
"required": true,
"content": {
"application/json": { "schema": schema, "example": dto.example.clone() },
"application/x-www-form-urlencoded": { "schema": { "type": "object", "additionalProperties": true } }
}
});
let type_name = Self::component_name(dto_type);
Some(registry.component_ref(
"requestBodies",
&format!("RequestBody<{}>", type_name),
definition,
))
}
fn build_responses(desc: &RouteDescription, registry: &mut ComponentRegistry) -> Value {
if desc.response_examples.is_empty() {
return json!({
"200": registry.component_ref("responses", "Response<200,Empty>", json!({ "description": "Successful operation" }))
});
}
let mut out = serde_json::Map::new();
for (code, dto) in &desc.response_examples {
let status = if *code >= 400 { "error" } else { "success" };
let message = if *code >= 500 {
"Internal server error"
} else if *code >= 400 {
"Bad request"
} else {
"Successful operation"
};
let schema = Self::schema_reference_for_name(dto.name, dto.schema.clone(), registry);
let media = json!({
"schema": {
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["success", "error"] },
"message": { "type": "string" },
"data": schema
}
},
"example": {
"status": status,
"message": message,
"data": dto.example.clone()
}
});
let type_name = Self::component_name(dto.name);
let resp = json!({
"description": if *code >= 400 {
format!("Error response ({})", type_name)
} else {
format!("Successful operation ({})", type_name)
},
"content": { "application/json": media }
});
out.insert(
code.to_string(),
registry.component_ref(
"responses",
&format!("Response<{},{}>", code, type_name),
resp,
),
);
}
Value::Object(out)
}
fn schema_reference_for_name(
name: &str,
mut value: Value,
registry: &mut ComponentRegistry,
) -> Value {
let key = Self::component_name(name);
Self::register_schema_dependencies(&mut value, registry);
registry.schemas.entry(key.clone()).or_insert(value);
json!({ "$ref": format!("#/components/schemas/{}", key) })
}
fn register_schema_dependencies(schema: &mut Value, registry: &mut ComponentRegistry) {
let mut definitions = Vec::new();
if let Some(object) = schema.as_object_mut() {
if let Some(components) = object.remove("components") {
if let Some(schemas) = components.get("schemas").and_then(Value::as_object) {
definitions.extend(
schemas
.iter()
.map(|(name, schema)| (name.clone(), schema.clone())),
);
}
}
for key in ["$defs", "definitions"] {
if let Some(defs) = object
.remove(key)
.and_then(|value| value.as_object().cloned())
{
definitions.extend(defs);
}
}
}
for (name, mut definition) in definitions {
Self::register_schema_dependencies(&mut definition, registry);
Self::normalize_schema_refs(&mut definition);
registry.schemas.entry(name).or_insert(definition);
}
Self::normalize_schema_refs(schema);
}
fn normalize_schema_refs(value: &mut Value) {
match value {
Value::Object(object) => {
for child in object.values_mut() {
Self::normalize_schema_refs(child);
}
}
Value::Array(values) => {
for child in values {
Self::normalize_schema_refs(child);
}
}
Value::String(reference) => {
for prefix in ["#/$defs/", "#/definitions/"] {
if let Some(name) = reference.strip_prefix(prefix) {
*reference = format!("#/components/schemas/{}", name);
break;
}
}
}
_ => {}
}
}
fn route_registers_authentication(registrant: &DocumentationRegistrant) -> bool {
registrant
.get_registered_routes()
.iter()
.any(|r| r.description.authentication_required)
}
fn resolve_components(
registrant: &DocumentationRegistrant,
registry: ComponentRegistry,
) -> Value {
let mut comps = registry.to_json();
let auth_settings = registrant.get_auth_settings();
let auth_headers = registrant.get_authenticated_only_headers();
if auth_settings.is_empty()
&& auth_headers.is_empty()
&& !Self::route_registers_authentication(registrant)
{
return comps;
}
let mut schemes = serde_json::Map::new();
if auth_headers.is_empty() {
for (header_name, description) in auth_settings {
let key = Self::to_kebab_case(header_name);
let mut scheme = serde_json::Map::new();
scheme.insert("in".to_string(), json!("header"));
scheme.insert("name".to_string(), json!(header_name));
scheme.insert("description".to_string(), json!(description));
if header_name.eq_ignore_ascii_case("Authorization") {
scheme.insert("type".to_string(), json!("http"));
scheme.insert("scheme".to_string(), json!("bearer"));
} else {
scheme.insert("type".to_string(), json!("apiKey"));
}
schemes.insert(key, Value::Object(scheme));
}
} else {
for (header_name, description) in auth_headers {
let key = Self::to_kebab_case(header_name);
let mut scheme = serde_json::Map::new();
scheme.insert("in".to_string(), json!("header"));
scheme.insert("name".to_string(), json!(header_name));
scheme.insert("description".to_string(), json!(description));
if header_name.eq_ignore_ascii_case("Authorization") {
scheme.insert("type".to_string(), json!("http"));
scheme.insert("scheme".to_string(), json!("bearer"));
} else {
scheme.insert("type".to_string(), json!("apiKey"));
}
schemes.insert(key, Value::Object(scheme));
}
}
if let Some(obj) = comps.as_object_mut() {
obj.insert("securitySchemes".to_string(), Value::Object(schemes));
}
comps
}
fn security_scheme_key(components: &Value) -> Option<String> {
components
.get("securitySchemes")
.and_then(|s| s.as_object())
.and_then(|m| m.keys().next().cloned())
}
fn to_kebab_case(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for (i, c) in value.chars().enumerate() {
if c.is_ascii_uppercase() {
if i != 0 {
out.push('-');
}
out.push(c.to_ascii_lowercase());
} else if c == '_' || c == ' ' {
out.push('-');
} else {
out.push(c);
}
}
out
}
fn build_group_descriptions(registrant: &DocumentationRegistrant) -> Vec<Value> {
registrant
.get_group_descriptions()
.iter()
.filter_map(|(name, desc)| {
if desc.trim().is_empty() || desc == "N/A" {
None
} else {
Some(json!({ "name": name, "description": desc }))
}
})
.collect()
}
fn open_api_path(path: &str) -> String {
if path.is_empty() {
return "/".to_string();
}
let re = regex::Regex::new(r":([A-Za-z0-9_]+)").unwrap();
re.replace_all(path, "{$1}").to_string()
}
fn operation_id(name: &str, path: &str) -> String {
let base = Self::sanitize_identifier(name);
let id = if base.is_empty() {
Self::sanitize_identifier(path)
} else {
base
};
if id.is_empty() {
"operation".to_string()
} else {
id
}
}
fn sanitize_identifier(value: &str) -> String {
let value = value.rsplit("::").next().unwrap_or(value);
let re = regex::Regex::new(r"[^A-Za-z0-9:+_-]").unwrap();
let sanitized = re.replace_all(value.trim(), "_").to_string();
let re2 = regex::Regex::new(r"_{2,}").unwrap();
let sanitized = re2.replace_all(&sanitized, "_").to_string();
if sanitized.is_empty() {
return "".to_string();
}
if sanitized
.chars()
.next()
.map_or(false, |c| c.is_ascii_digit())
{
format!("_{}", sanitized)
} else {
sanitized
}
}
fn component_name(name: &str) -> String {
Self::sanitize_identifier(name)
}
#[allow(dead_code)]
fn scalar_schema(type_name: &str) -> Option<Value> {
match type_name {
"String" | "char" | "Character" => Some(json!({ "type": "string" })),
"Uuid" => Some(json!({ "type": "string", "format": "uuid" })),
"i32" | "i64" | "u32" | "u64" | "isize" | "usize" | "i8" | "u8" | "i16" | "u16"
| "i128" | "u128" => Some(json!({ "type": "integer" })),
"f32" | "f64" => Some(json!({ "type": "number" })),
"bool" => Some(json!({ "type": "boolean" })),
_ => None,
}
}
}
pub struct DocExampleHttpClient {
pub base_url: String,
}
impl DocExampleHttpClient {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
}
}
}
#[allow(dead_code)]
pub type LegacyDocHttpClient = DocExampleHttpClient;
#[cfg(test)]
mod tests {
use super::*;
use schemars::generate::SchemaSettings;
#[derive(JsonSchema, Debug, Clone, PartialEq, Serialize, Deserialize)]
enum InventoryType {
System,
Transactional,
}
#[derive(JsonSchema, Debug, Clone, PartialEq, Serialize, Deserialize)]
struct InventoryRequest {
inventory_type: Option<InventoryType>,
}
#[test]
fn flattens_openapi_schema_dependencies() {
let schema = SchemaSettings::openapi3()
.into_generator()
.into_root_schema_for::<InventoryRequest>()
.to_value();
let mut registry = ComponentRegistry::default();
OpenApi3Generator::schema_reference_for_name(
std::any::type_name::<InventoryRequest>(),
schema,
&mut registry,
);
assert!(registry.schemas.contains_key("InventoryType"));
let request = registry
.schemas
.get("doc::tests::InventoryRequest")
.or_else(|| {
registry.schemas.values().find(|schema| {
schema.get("title").and_then(Value::as_str) == Some("InventoryRequest")
})
})
.expect("root schema should be registered");
assert!(request.get("components").is_none());
}
#[test]
fn emits_security_scheme_from_authenticated_only_headers() {
let mut registrant = DocumentationRegistrant::new();
registrant.set_global_headers_scoped(
HashMap::from([(String::from("Authorization"), String::from("Bearer token"))]),
true,
);
let components =
OpenApi3Generator::resolve_components(®istrant, ComponentRegistry::default());
let schemes = components
.get("securitySchemes")
.and_then(Value::as_object)
.expect("authenticated headers should create security schemes");
assert!(schemes.contains_key("authorization"));
}
#[test]
fn component_names_use_entity_name_without_module_path() {
assert_eq!(
OpenApi3Generator::component_name("backend_server::modules::inventory::Request"),
"Request"
);
}
#[test]
fn emits_all_configured_mount_paths_as_servers() {
let mut registrant = DocumentationRegistrant::new();
registrant.bind_base_list(&["/api".into(), "/internal/".into(), "/api".into()]);
assert_eq!(
OpenApi3Generator::build_servers(®istrant),
json!([{ "url": "/api/" }, { "url": "/internal/" }])
);
}
}