1pub mod assets;
26pub mod controller;
27
28use schemars::JsonSchema;
29use serde::de::DeserializeOwned;
30use serde::{Deserialize, Serialize};
31use serde_json::{Value, json};
32use std::collections::{BTreeMap, HashMap};
33use std::fmt::Debug;
34use std::sync::{Arc, OnceLock, RwLock};
35
36pub trait DocumentableDTO:
57 Send + Sync + Debug + DeserializeOwned + Serialize + JsonSchema + Clone
58{
59 fn get_example() -> Self;
61
62 fn make_example() -> Result<Value, serde_json::Error> {
66 let example = Self::get_example();
67 serde_json::to_value(example)
68 }
69}
70
71pub use crate::controller::{FileParameter, FileParameterType, RouteDescription};
75
76#[derive(Debug, Clone)]
81pub struct RegisteredRoute {
82 pub path: String,
84 pub method: String,
86 pub controller_class: String,
88 pub description: RouteDescription,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
97pub enum DocumentationMode {
98 #[default]
100 Conventional,
101 External,
103}
104
105#[derive(Debug, Clone)]
107pub struct DocHttpResponse {
108 pub status_code: u16,
110 pub headers: HashMap<String, String>,
112 pub body: String,
114}
115
116#[async_trait::async_trait]
122pub trait DocHttpClient: Send + Sync + Debug {
123 fn name(&self) -> &str;
125
126 fn description(&self) -> &str;
128
129 fn usage_snippet(&self) -> &str;
131
132 async fn execute(
139 &self,
140 method: &str,
141 url: &str,
142 headers: &HashMap<String, String>,
143 body: Option<&str>,
144 parameters: &HashMap<String, String>,
145 ) -> anyhow::Result<DocHttpResponse>;
146}
147
148fn append_query_params(url: &str, params: &HashMap<String, String>) -> String {
149 if params.is_empty() {
150 return url.to_string();
151 }
152 let mut out = url.to_string();
153 let sep = if url.contains('?') { '&' } else { '?' };
154 out.push(sep);
155 let mut first = true;
156 for (k, v) in params {
157 if !first {
158 out.push('&');
159 }
160 first = false;
161 out.push_str(&urlencoding_fallback(k));
162 out.push('=');
163 out.push_str(&urlencoding_fallback(v));
164 }
165 out
166}
167
168fn urlencoding_fallback(s: &str) -> String {
169 let mut out = String::with_capacity(s.len());
170 for b in s.bytes() {
171 if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) {
172 out.push(b as char);
173 } else {
174 out.push_str(&format!("%{:02X}", b));
175 }
176 }
177 out
178}
179
180async fn execute_with_reqwest(
181 method: &str,
182 url: &str,
183 headers: &HashMap<String, String>,
184 body: Option<&str>,
185 parameters: &HashMap<String, String>,
186 timeout_secs: u64,
187) -> anyhow::Result<DocHttpResponse> {
188 let client = reqwest::Client::builder()
189 .timeout(std::time::Duration::from_secs(timeout_secs))
190 .build()?;
191 let url = append_query_params(url, parameters);
192 let mut req = client.request(method.parse().unwrap_or(reqwest::Method::GET), &url);
193 for (k, v) in headers {
194 req = req.header(k.as_str(), v.as_str());
195 }
196 if let Some(b) = body {
197 if !b.is_empty() {
198 req = req.body(b.to_string());
199 }
200 }
201 let resp = req.send().await?;
202 let status_code = resp.status().as_u16();
203 let mut out_headers = HashMap::new();
204 for (k, v) in resp.headers() {
205 out_headers.insert(k.to_string(), v.to_str().unwrap_or("").to_string());
206 }
207 let body = resp.text().await.unwrap_or_default();
208 Ok(DocHttpResponse {
209 status_code,
210 headers: out_headers,
211 body,
212 })
213}
214
215#[derive(Debug, Default)]
217pub struct ClassicHttpClient;
218#[async_trait::async_trait]
219impl DocHttpClient for ClassicHttpClient {
220 fn name(&self) -> &str {
221 "Classic HTTP Client"
222 }
223 fn description(&self) -> &str {
224 "A simple HTTP client for scalability."
225 }
226 fn usage_snippet(&self) -> &str {
227 "reqwest::Client::new().get(url).send().await"
228 }
229 async fn execute(
230 &self,
231 method: &str,
232 url: &str,
233 headers: &HashMap<String, String>,
234 body: Option<&str>,
235 parameters: &HashMap<String, String>,
236 ) -> anyhow::Result<DocHttpResponse> {
237 execute_with_reqwest(method, url, headers, body, parameters, 30).await
238 }
239}
240
241#[derive(Debug, Default)]
243pub struct ThrottledHttpClient;
244#[async_trait::async_trait]
245impl DocHttpClient for ThrottledHttpClient {
246 fn name(&self) -> &str {
247 "Throttled HTTP Client"
248 }
249 fn description(&self) -> &str {
250 "Rate-limited HTTP client that spaces out requests."
251 }
252 fn usage_snippet(&self) -> &str {
253 "throttled: sleep 100ms between requests"
254 }
255 async fn execute(
256 &self,
257 method: &str,
258 url: &str,
259 headers: &HashMap<String, String>,
260 body: Option<&str>,
261 parameters: &HashMap<String, String>,
262 ) -> anyhow::Result<DocHttpResponse> {
263 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
264 execute_with_reqwest(method, url, headers, body, parameters, 30).await
265 }
266}
267
268#[derive(Debug, Default)]
270pub struct BurstHttpClient;
271#[async_trait::async_trait]
272impl DocHttpClient for BurstHttpClient {
273 fn name(&self) -> &str {
274 "Burst HTTP Client"
275 }
276 fn description(&self) -> &str {
277 "Short-timeout client for burst testing."
278 }
279 fn usage_snippet(&self) -> &str {
280 "burst: 5s timeout, no delay"
281 }
282 async fn execute(
283 &self,
284 method: &str,
285 url: &str,
286 headers: &HashMap<String, String>,
287 body: Option<&str>,
288 parameters: &HashMap<String, String>,
289 ) -> anyhow::Result<DocHttpResponse> {
290 execute_with_reqwest(method, url, headers, body, parameters, 5).await
291 }
292}
293
294pub struct DocumentationRegistrant {
305 base_path: String,
306 server_paths: Vec<String>,
307 global_rate_limit: Option<u32>,
308 auth_settings: HashMap<String, String>,
309 group_descriptions: HashMap<String, String>,
310 registered_routes: Vec<RegisteredRoute>,
311 legacy_global_headers: HashMap<String, String>,
312 unauthenticated_global_headers: HashMap<String, String>,
313 authenticated_global_headers: HashMap<String, String>,
314 http_clients: Vec<Arc<dyn DocHttpClient>>,
315 documentation_mode: DocumentationMode,
316}
317
318impl DocumentationRegistrant {
319 fn new() -> Self {
320 let mut s = Self {
321 base_path: "/".to_string(),
322 server_paths: vec!["/".to_string()],
323 global_rate_limit: None,
324 auth_settings: HashMap::new(),
325 group_descriptions: HashMap::new(),
326 registered_routes: Vec::new(),
327 legacy_global_headers: HashMap::new(),
328 unauthenticated_global_headers: HashMap::new(),
329 authenticated_global_headers: HashMap::new(),
330 http_clients: Vec::new(),
331 documentation_mode: DocumentationMode::Conventional,
332 };
333 s.http_clients.push(Arc::new(ClassicHttpClient));
334 s.http_clients.push(Arc::new(ThrottledHttpClient));
335 s.http_clients.push(Arc::new(BurstHttpClient));
336 s
337 }
338
339 pub fn global() -> &'static RwLock<Self> {
341 static INSTANCE: OnceLock<RwLock<DocumentationRegistrant>> = OnceLock::new();
342 INSTANCE.get_or_init(|| RwLock::new(Self::new()))
343 }
344
345 pub fn get_instance() -> std::sync::RwLockReadGuard<'static, Self> {
350 Self::global().read().unwrap()
351 }
352
353 pub fn get_instance_mut() -> std::sync::RwLockWriteGuard<'static, Self> {
357 Self::global().write().unwrap()
358 }
359
360 pub fn bind_base(&mut self, base: impl Into<String>) {
364 let b = base.into();
365 self.base_path = if b.is_empty() { "/".to_string() } else { b };
366 self.server_paths = vec![self.base_path.clone()];
367 }
368
369 pub fn bind_base_list(&mut self, roots: &[String]) {
373 let mut paths = Vec::new();
374 for root in roots {
375 let path = if root.trim().is_empty() {
376 "/".to_string()
377 } else {
378 root.clone()
379 };
380 if !paths.contains(&path) {
381 paths.push(path);
382 }
383 }
384 if paths.is_empty() {
385 paths.push("/".to_string());
386 }
387 self.base_path = paths[0].clone();
388 self.server_paths = paths;
389 }
390
391 pub fn base_path(&self) -> &str {
393 &self.base_path
394 }
395
396 pub fn server_paths(&self) -> &[String] {
398 &self.server_paths
399 }
400
401 pub fn set_global_rate_limit(&mut self, limit: Option<u32>) {
403 self.global_rate_limit = limit;
404 }
405
406 pub fn get_global_rate_limit(&self) -> Option<u32> {
408 self.global_rate_limit
409 }
410
411 pub fn set_auth_settings(&mut self, m: HashMap<String, String>) {
413 self.auth_settings = m;
414 }
415
416 pub fn get_auth_settings(&self) -> &HashMap<String, String> {
418 &self.auth_settings
419 }
420
421 pub fn set_global_headers(&mut self, headers: HashMap<String, String>) {
423 self.legacy_global_headers = headers;
424 }
425
426 pub fn set_global_headers_scoped(
429 &mut self,
430 headers: HashMap<String, String>,
431 authenticated_only: bool,
432 ) {
433 if authenticated_only {
434 self.authenticated_global_headers = headers;
435 } else {
436 self.unauthenticated_global_headers = headers;
437 }
438 }
439
440 pub fn get_global_headers(&self) -> HashMap<String, String> {
444 let mut combined = self.legacy_global_headers.clone();
445 combined.extend(self.unauthenticated_global_headers.clone());
446 combined.extend(self.authenticated_global_headers.clone());
447 combined
448 }
449
450 pub fn get_authenticated_global_headers(&self) -> HashMap<String, String> {
452 let mut combined = self.legacy_global_headers.clone();
453 combined.extend(self.authenticated_global_headers.clone());
454 combined
455 }
456
457 pub fn get_authenticated_only_headers(&self) -> &HashMap<String, String> {
459 &self.authenticated_global_headers
460 }
461
462 pub fn get_unauthenticated_global_headers(&self) -> HashMap<String, String> {
464 let mut combined = self.legacy_global_headers.clone();
465 combined.extend(self.unauthenticated_global_headers.clone());
466 combined
467 }
468
469 pub fn set_documentation_mode(&mut self, mode: DocumentationMode) {
471 self.documentation_mode = mode;
472 }
473
474 pub fn get_documentation_mode(&self) -> DocumentationMode {
476 self.documentation_mode
477 }
478
479 pub fn register_http_client(&mut self, client: Arc<dyn DocHttpClient>) {
481 self.http_clients.push(client);
482 }
483
484 pub fn get_http_clients(&self) -> Vec<Arc<dyn DocHttpClient>> {
486 self.http_clients.clone()
487 }
488
489 pub fn set_group_description(&mut self, group: impl Into<String>, desc: impl Into<String>) {
491 self.group_descriptions.insert(group.into(), desc.into());
492 }
493
494 pub fn set_group_descriptions(&mut self, descriptions: HashMap<String, String>) {
497 self.group_descriptions.clear();
498 for (k, v) in descriptions {
499 if !k.trim().is_empty() {
500 self.group_descriptions
501 .insert(k.trim().to_string(), v.trim().to_string());
502 }
503 }
504 }
505
506 pub fn get_group_description(&self, group: &str) -> String {
508 if group.trim().is_empty() {
509 return "N/A".to_string();
510 }
511 match self.group_descriptions.get(group.trim()) {
512 Some(d) if !d.trim().is_empty() => d.clone(),
513 _ => "N/A".to_string(),
514 }
515 }
516
517 pub fn get_group_descriptions(&self) -> &HashMap<String, String> {
519 &self.group_descriptions
520 }
521
522 pub fn register_route(
524 &mut self,
525 path: &str,
526 method: &str,
527 controller_class: &str,
528 description: RouteDescription,
529 ) {
530 self.registered_routes.push(RegisteredRoute {
531 path: path.to_string(),
532 method: method.to_string(),
533 controller_class: controller_class.to_string(),
534 description,
535 });
536 }
537
538 pub fn get_registered_routes(&self) -> &[RegisteredRoute] {
540 &self.registered_routes
541 }
542
543 pub fn clear(&mut self) {
545 self.registered_routes.clear();
546 }
547}
548
549impl Default for DocumentationRegistrant {
550 fn default() -> Self {
551 Self::new()
552 }
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct RouteDoc {
561 pub path: String,
563 pub method: String,
565 pub controller: String,
567 pub summary: String,
569 pub tags: Vec<String>,
571}
572
573#[derive(Debug, Default)]
576struct ComponentRegistry {
577 schemas: serde_json::Map<String, Value>,
578 request_bodies: serde_json::Map<String, Value>,
579 responses: serde_json::Map<String, Value>,
580}
581
582impl ComponentRegistry {
583 fn section_mut(&mut self, name: &str) -> &mut serde_json::Map<String, Value> {
584 match name {
585 "schemas" => &mut self.schemas,
586 "requestBodies" => &mut self.request_bodies,
587 "responses" => &mut self.responses,
588 _ => panic!("unsupported component section: {}", name),
589 }
590 }
591
592 fn to_json(self) -> Value {
593 let mut out = serde_json::Map::new();
594 if !self.schemas.is_empty() {
595 out.insert("schemas".to_string(), Value::Object(self.schemas));
596 }
597 if !self.request_bodies.is_empty() {
598 out.insert(
599 "requestBodies".to_string(),
600 Value::Object(self.request_bodies),
601 );
602 }
603 if !self.responses.is_empty() {
604 out.insert("responses".to_string(), Value::Object(self.responses));
605 }
606 Value::Object(out)
607 }
608
609 fn component_ref(&mut self, section: &str, name: &str, definition: Value) -> Value {
610 let entry = self.section_mut(section);
611 if !entry.contains_key(name) {
612 entry.insert(name.to_string(), definition);
613 }
614 json!({ "$ref": format!("#/components/{}/{}", section, name) })
615 }
616}
617
618pub struct OpenApi3Generator;
624
625impl OpenApi3Generator {
626 pub fn generate(registrant: &DocumentationRegistrant) -> Value {
638 Self::generate_with_info(registrant, None, None)
639 }
640
641 pub fn generate_with_title(
646 registrant: &DocumentationRegistrant,
647 title: &str,
648 version: &str,
649 ) -> Value {
650 Self::generate_with_info(registrant, Some(title), Some(version))
651 }
652
653 fn generate_with_info(
654 registrant: &DocumentationRegistrant,
655 title: Option<&str>,
656 version: Option<&str>,
657 ) -> Value {
658 let mut registry = ComponentRegistry::default();
659
660 let info = Self::build_info(registrant, title, version);
661 let servers = Self::build_servers(registrant);
662 let paths = Self::build_paths(registrant, &mut registry);
663 let components = Self::resolve_components(registrant, registry);
664 let groups = Self::build_group_descriptions(registrant);
665
666 let mut spec = json!({
667 "openapi": "3.1.0",
668 "info": info,
669 "servers": servers,
670 "paths": paths,
671 });
672 if let Some(obj) = spec.as_object_mut() {
673 if !components.is_null() && components.as_object().map_or(false, |m| !m.is_empty()) {
674 obj.insert("components".to_string(), components.clone());
675 }
676 if Self::security_scheme_key(&components).is_some() {
678 obj.insert("security".to_string(), json!([]));
679 }
680 if !groups.is_empty() {
681 obj.insert("x-groups".to_string(), Value::Array(groups));
682 }
683 }
684 spec
685 }
686
687 fn build_info(
688 registrant: &DocumentationRegistrant,
689 title: Option<&str>,
690 version: Option<&str>,
691 ) -> Value {
692 let (env_title, env_version) = if let Some(env) = crate::env::AppEnvironment::try_get() {
693 (env.name.clone(), env.version_code.clone())
694 } else {
695 ("API Documentation".to_string(), "1.0.0".to_string())
696 };
697 let t = title.unwrap_or(&env_title);
698 let v = version.unwrap_or(&env_version);
699 let description = Self::build_global_description(registrant);
700 json!({ "title": t, "version": v, "description": description })
701 }
702
703 fn build_global_description(registrant: &DocumentationRegistrant) -> String {
704 let mut lines = vec![
705 "Live API specification for this service.".to_string(),
706 String::new(),
707 ];
708 lines.push(format!(
709 "Default rate limit: {}",
710 registrant
711 .get_global_rate_limit()
712 .map_or("Unlimited".to_string(), |l| format!(
713 "{} requests per minute",
714 l
715 ))
716 ));
717 if !registrant.get_auth_settings().is_empty() {
718 lines.push(String::new());
719 lines.push("Authentication settings:".to_string());
720 for (k, v) in registrant.get_auth_settings() {
721 lines.push(format!("- `{}`: `{}`", k, v));
722 }
723 }
724 lines.join("\n")
725 }
726
727 fn build_servers(registrant: &DocumentationRegistrant) -> Value {
728 let mut servers: Vec<Value> = registrant
729 .server_paths()
730 .iter()
731 .map(|path| {
732 let normalized = if path == "/" {
733 "/".to_string()
734 } else {
735 format!("{}/", path.trim_end_matches('/'))
736 };
737 json!({ "url": normalized })
738 })
739 .collect();
740 servers.push(json!({ "url": "/"}));
741 Value::Array(servers)
742 }
743
744 fn build_paths(
745 registrant: &DocumentationRegistrant,
746 registry: &mut ComponentRegistry,
747 ) -> Value {
748 let registers_auth = Self::route_registers_authentication(registrant);
749 let mut grouped: BTreeMap<String, Vec<&RegisteredRoute>> = BTreeMap::new();
751 let mut routes: Vec<&RegisteredRoute> = registrant.get_registered_routes().iter().collect();
752 routes.sort_by(|a, b| a.path.cmp(&b.path).then(a.method.cmp(&b.method)));
753
754 for route in routes {
755 if route.controller_class.contains("DocumentationController") {
756 continue;
757 }
758 if route.description.group.trim().is_empty() {
759 continue;
760 }
761 grouped
762 .entry(route.description.group.clone())
763 .or_default()
764 .push(route);
765 }
766
767 let mut paths: BTreeMap<String, Value> = BTreeMap::new();
768 for (_group, routes) in grouped {
769 for route in routes {
770 let desc = &route.description;
771 let openapi_path = Self::open_api_path(&route.path);
772 let entry = paths
773 .entry(openapi_path.clone())
774 .or_insert_with(|| json!({}));
775 if let Some(obj) = entry.as_object_mut() {
776 let op = Self::build_operation(
777 desc,
778 &route.path,
779 registry,
780 registrant,
781 registers_auth,
782 );
783 obj.insert(route.method.to_lowercase(), op);
784 }
785 }
786 }
787 Value::Object(paths.into_iter().collect())
788 }
789
790 fn build_operation(
791 desc: &RouteDescription,
792 route_path: &str,
793 registry: &mut ComponentRegistry,
794 registrant: &DocumentationRegistrant,
795 registers_auth: bool,
796 ) -> Value {
797 let summary = if desc.name.is_empty() {
798 desc.summary.clone()
799 } else {
800 desc.name.clone()
801 };
802 let mut op = json!({
804 "summary": summary,
805 "operationId": Self::operation_id(&desc.name, route_path),
806 "tags": [desc.group.clone()],
807 });
808 if !desc.description.is_empty() {
809 op["description"] = Value::String(desc.description.clone());
810 }
811 if desc.is_deprecated {
812 op["deprecated"] = Value::Bool(true);
813 }
814 let params = Self::build_parameters(desc, registrant);
816 if !params.is_empty() {
817 op["parameters"] = Value::Array(params);
818 }
819 if let Some(rb) = Self::build_request_body(desc, route_path, registry) {
821 op["requestBody"] = rb;
822 }
823 op["responses"] = Self::build_responses(desc, registry);
825 op["x-authentication"] = json!({
827 "required": desc.authentication_required,
828 "comment": desc.authentication_comment.clone().unwrap_or_default()
829 });
830 if let Some(limit) = desc.effective_rate_limit(registrant.get_global_rate_limit()) {
831 if limit > 0 {
832 op["x-rate-limit"] = json!({ "limit": limit, "period": "minute" });
833 }
834 }
835 if desc.authentication_required && registers_auth {
837 op["security"] = json!([{ "authentication": [] }]);
838 }
839 op
840 }
841
842 fn build_parameters(
843 desc: &RouteDescription,
844 registrant: &DocumentationRegistrant,
845 ) -> Vec<Value> {
846 let mut out = Vec::new();
847 for (name, descr) in &desc.path_parameters {
848 let mut schema = json!({ "type": "string" });
849 if let Some(def) = desc.path_parameter_defaults.get(name) {
850 schema["default"] = Value::String(def.clone());
851 }
852 out.push(json!({
853 "name": name,
854 "in": "path",
855 "required": true,
856 "description": descr,
857 "schema": schema
858 }));
859 }
860 for (name, descr) in &desc.query_parameters {
861 let mut schema = json!({ "type": "string" });
862 if let Some(def) = desc.query_parameter_defaults.get(name) {
863 schema["default"] = Value::String(def.clone());
864 }
865 out.push(json!({
866 "name": name,
867 "in": "query",
868 "required": false,
869 "description": descr,
870 "schema": schema
871 }));
872 }
873 let mut headers = registrant.get_unauthenticated_global_headers();
875 for (k, v) in &desc.headers {
876 headers.insert(k.clone(), v.clone());
877 }
878 for (name, val) in &headers {
879 if ["accept", "content-type", "authorization"].contains(&name.to_lowercase().as_str()) {
880 continue;
881 }
882 out.push(json!({
883 "name": name,
884 "in": "header",
885 "required": false,
886 "description": val,
887 "schema": { "type": "string", "default": "" }
888 }));
889 }
890 out
891 }
892
893 fn build_request_body(
894 desc: &RouteDescription,
895 route_path: &str,
896 registry: &mut ComponentRegistry,
897 ) -> Option<Value> {
898 if !desc.file_parameters.is_empty() {
900 let mut properties = serde_json::Map::new();
901 properties.insert(
902 "body".to_string(),
903 json!({
904 "type": "string",
905 "description": "JSON-encoded application payload sent alongside the uploaded files, if any. Switch to the application/json to see the type information"
906 }),
907 );
908 for (name, param) in &desc.file_parameters {
909 let mut schema = json!({
910 "type": "string",
911 "format": "binary",
912 "description": param.description,
913 });
914 if let Some(max) = param.count.or(param.limit) {
915 schema["x-max-files"] = json!(max);
916 }
917 schema["x-allowed-extensions"] =
919 json!(param.file_type.allowed_extensions_example());
920 properties.insert(name.clone(), schema);
921 }
922 let mut content = serde_json::Map::new();
923 content.insert(
924 "multipart/form-data".to_string(),
925 json!({ "schema": { "type": "object", "properties": properties } }),
926 );
927 if let Some(dto) = desc.request_body.as_ref() {
928 if dto.name != "Void" && !dto.name.is_empty() {
929 let schema =
930 Self::schema_reference_for_name(dto.name, dto.schema.clone(), registry);
931 content.insert(
932 "application/json".to_string(),
933 json!({ "schema": schema, "example": dto.example.clone() }),
934 );
935 }
936 }
937 let name = format!(
938 "RequestBody<{}>",
939 Self::operation_id(&desc.name, route_path)
940 );
941 return Some(registry.component_ref(
942 "requestBodies",
943 &name,
944 json!({ "required": true, "content": content }),
945 ));
946 }
947
948 let dto = desc.request_body.as_ref()?;
949 let dto_type = dto.name;
950
951 if dto_type == "String" {
952 return Some(json!({
953 "required": true,
954 "content": {
955 "text/plain": { "schema": { "type": "string" } }
956 }
957 }));
958 }
959 if dto_type == "Void" || dto_type.is_empty() {
960 return None;
961 }
962
963 let schema = Self::schema_reference_for_name(dto_type, dto.schema.clone(), registry);
965 let definition = json!({
966 "required": true,
967 "content": {
968 "application/json": { "schema": schema, "example": dto.example.clone() },
969 "application/x-www-form-urlencoded": { "schema": { "type": "object", "additionalProperties": true } }
970 }
971 });
972 let type_name = Self::component_name(dto_type);
973 Some(registry.component_ref(
974 "requestBodies",
975 &format!("RequestBody<{}>", type_name),
976 definition,
977 ))
978 }
979
980 fn build_responses(desc: &RouteDescription, registry: &mut ComponentRegistry) -> Value {
981 if desc.response_examples.is_empty() {
982 return json!({
983 "200": registry.component_ref("responses", "Response<200,Empty>", json!({ "description": "Successful operation" }))
984 });
985 }
986 let mut out = serde_json::Map::new();
987 for (code, dto) in &desc.response_examples {
988 let status = if *code >= 400 { "error" } else { "success" };
990 let message = if *code >= 500 {
991 "Internal server error"
992 } else if *code >= 400 {
993 "Bad request"
994 } else {
995 "Successful operation"
996 };
997 let schema = Self::schema_reference_for_name(dto.name, dto.schema.clone(), registry);
998 let media = json!({
999 "schema": {
1000 "type": "object",
1001 "properties": {
1002 "status": { "type": "string", "enum": ["success", "error"] },
1003 "message": { "type": "string" },
1004 "data": schema
1005 }
1006 },
1007 "example": {
1008 "status": status,
1009 "message": message,
1010 "data": dto.example.clone()
1011 }
1012 });
1013 let type_name = Self::component_name(dto.name);
1014 let resp = json!({
1015 "description": if *code >= 400 {
1016 format!("Error response ({})", type_name)
1017 } else {
1018 format!("Successful operation ({})", type_name)
1019 },
1020 "content": { "application/json": media }
1021 });
1022 out.insert(
1023 code.to_string(),
1024 registry.component_ref(
1025 "responses",
1026 &format!("Response<{},{}>", code, type_name),
1027 resp,
1028 ),
1029 );
1030 }
1031 Value::Object(out)
1032 }
1033
1034 fn schema_reference_for_name(
1035 name: &str,
1036 mut value: Value,
1037 registry: &mut ComponentRegistry,
1038 ) -> Value {
1039 let key = Self::component_name(name);
1040 Self::register_schema_dependencies(&mut value, registry);
1041 registry.schemas.entry(key.clone()).or_insert(value);
1042 json!({ "$ref": format!("#/components/schemas/{}", key) })
1043 }
1044
1045 fn register_schema_dependencies(schema: &mut Value, registry: &mut ComponentRegistry) {
1052 let mut definitions = Vec::new();
1053 if let Some(object) = schema.as_object_mut() {
1054 if let Some(components) = object.remove("components") {
1055 if let Some(schemas) = components.get("schemas").and_then(Value::as_object) {
1056 definitions.extend(
1057 schemas
1058 .iter()
1059 .map(|(name, schema)| (name.clone(), schema.clone())),
1060 );
1061 }
1062 }
1063 for key in ["$defs", "definitions"] {
1064 if let Some(defs) = object
1065 .remove(key)
1066 .and_then(|value| value.as_object().cloned())
1067 {
1068 definitions.extend(defs);
1069 }
1070 }
1071 }
1072
1073 for (name, mut definition) in definitions {
1074 Self::register_schema_dependencies(&mut definition, registry);
1075 Self::normalize_schema_refs(&mut definition);
1076 registry.schemas.entry(name).or_insert(definition);
1077 }
1078 Self::normalize_schema_refs(schema);
1079 }
1080
1081 fn normalize_schema_refs(value: &mut Value) {
1082 match value {
1083 Value::Object(object) => {
1084 for child in object.values_mut() {
1085 Self::normalize_schema_refs(child);
1086 }
1087 }
1088 Value::Array(values) => {
1089 for child in values {
1090 Self::normalize_schema_refs(child);
1091 }
1092 }
1093 Value::String(reference) => {
1094 for prefix in ["#/$defs/", "#/definitions/"] {
1095 if let Some(name) = reference.strip_prefix(prefix) {
1096 *reference = format!("#/components/schemas/{}", name);
1097 break;
1098 }
1099 }
1100 }
1101 _ => {}
1102 }
1103 }
1104
1105 fn route_registers_authentication(registrant: &DocumentationRegistrant) -> bool {
1106 registrant
1107 .get_registered_routes()
1108 .iter()
1109 .any(|r| r.description.authentication_required)
1110 }
1111
1112 fn resolve_components(
1113 registrant: &DocumentationRegistrant,
1114 registry: ComponentRegistry,
1115 ) -> Value {
1116 let mut comps = registry.to_json();
1117 let auth_settings = registrant.get_auth_settings();
1118 let auth_headers = registrant.get_authenticated_only_headers();
1119 if auth_settings.is_empty()
1120 && auth_headers.is_empty()
1121 && !Self::route_registers_authentication(registrant)
1122 {
1123 return comps;
1124 }
1125
1126 let mut schemes = serde_json::Map::new();
1128 if auth_headers.is_empty() {
1129 for (header_name, description) in auth_settings {
1131 let key = Self::to_kebab_case(header_name);
1132 let mut scheme = serde_json::Map::new();
1133 scheme.insert("in".to_string(), json!("header"));
1134 scheme.insert("name".to_string(), json!(header_name));
1135 scheme.insert("description".to_string(), json!(description));
1136 if header_name.eq_ignore_ascii_case("Authorization") {
1137 scheme.insert("type".to_string(), json!("http"));
1138 scheme.insert("scheme".to_string(), json!("bearer"));
1139 } else {
1140 scheme.insert("type".to_string(), json!("apiKey"));
1141 }
1142 schemes.insert(key, Value::Object(scheme));
1143 }
1144 } else {
1145 for (header_name, description) in auth_headers {
1146 let key = Self::to_kebab_case(header_name);
1147 let mut scheme = serde_json::Map::new();
1148 scheme.insert("in".to_string(), json!("header"));
1149 scheme.insert("name".to_string(), json!(header_name));
1150 scheme.insert("description".to_string(), json!(description));
1151 if header_name.eq_ignore_ascii_case("Authorization") {
1152 scheme.insert("type".to_string(), json!("http"));
1153 scheme.insert("scheme".to_string(), json!("bearer"));
1154 } else {
1155 scheme.insert("type".to_string(), json!("apiKey"));
1156 }
1157 schemes.insert(key, Value::Object(scheme));
1158 }
1159 }
1160 if let Some(obj) = comps.as_object_mut() {
1161 obj.insert("securitySchemes".to_string(), Value::Object(schemes));
1162 }
1163 comps
1164 }
1165
1166 fn security_scheme_key(components: &Value) -> Option<String> {
1167 components
1168 .get("securitySchemes")
1169 .and_then(|s| s.as_object())
1170 .and_then(|m| m.keys().next().cloned())
1171 }
1172
1173 fn to_kebab_case(value: &str) -> String {
1174 let mut out = String::with_capacity(value.len());
1175 for (i, c) in value.chars().enumerate() {
1176 if c.is_ascii_uppercase() {
1177 if i != 0 {
1178 out.push('-');
1179 }
1180 out.push(c.to_ascii_lowercase());
1181 } else if c == '_' || c == ' ' {
1182 out.push('-');
1183 } else {
1184 out.push(c);
1185 }
1186 }
1187 out
1188 }
1189
1190 fn build_group_descriptions(registrant: &DocumentationRegistrant) -> Vec<Value> {
1191 registrant
1192 .get_group_descriptions()
1193 .iter()
1194 .filter_map(|(name, desc)| {
1195 if desc.trim().is_empty() || desc == "N/A" {
1196 None
1197 } else {
1198 Some(json!({ "name": name, "description": desc }))
1199 }
1200 })
1201 .collect()
1202 }
1203
1204 fn open_api_path(path: &str) -> String {
1207 if path.is_empty() {
1208 return "/".to_string();
1209 }
1210 let re = regex::Regex::new(r":([A-Za-z0-9_]+)").unwrap();
1212 re.replace_all(path, "{$1}").to_string()
1213 }
1214
1215 fn operation_id(name: &str, path: &str) -> String {
1216 let base = Self::sanitize_identifier(name);
1217 let id = if base.is_empty() {
1218 Self::sanitize_identifier(path)
1219 } else {
1220 base
1221 };
1222 if id.is_empty() {
1223 "operation".to_string()
1224 } else {
1225 id
1226 }
1227 }
1228
1229 fn sanitize_identifier(value: &str) -> String {
1230 let value = value.rsplit("::").next().unwrap_or(value);
1231 let re = regex::Regex::new(r"[^A-Za-z0-9:+_-]").unwrap();
1232 let sanitized = re.replace_all(value.trim(), "_").to_string();
1233 let re2 = regex::Regex::new(r"_{2,}").unwrap();
1234 let sanitized = re2.replace_all(&sanitized, "_").to_string();
1235 if sanitized.is_empty() {
1236 return "".to_string();
1237 }
1238 if sanitized
1239 .chars()
1240 .next()
1241 .map_or(false, |c| c.is_ascii_digit())
1242 {
1243 format!("_{}", sanitized)
1244 } else {
1245 sanitized
1246 }
1247 }
1248
1249 fn component_name(name: &str) -> String {
1251 Self::sanitize_identifier(name)
1252 }
1253
1254 #[allow(dead_code)]
1257 fn scalar_schema(type_name: &str) -> Option<Value> {
1258 match type_name {
1259 "String" | "char" | "Character" => Some(json!({ "type": "string" })),
1260 "Uuid" => Some(json!({ "type": "string", "format": "uuid" })),
1261 "i32" | "i64" | "u32" | "u64" | "isize" | "usize" | "i8" | "u8" | "i16" | "u16"
1262 | "i128" | "u128" => Some(json!({ "type": "integer" })),
1263 "f32" | "f64" => Some(json!({ "type": "number" })),
1264 "bool" => Some(json!({ "type": "boolean" })),
1265 _ => None,
1266 }
1267 }
1268}
1269
1270pub struct DocExampleHttpClient {
1274 pub base_url: String,
1276}
1277
1278impl DocExampleHttpClient {
1279 pub fn new(base_url: impl Into<String>) -> Self {
1281 Self {
1282 base_url: base_url.into(),
1283 }
1284 }
1285}
1286
1287#[allow(dead_code)]
1289pub type LegacyDocHttpClient = DocExampleHttpClient;
1290
1291#[cfg(test)]
1292mod tests {
1293 use super::*;
1294 use schemars::generate::SchemaSettings;
1295
1296 #[derive(JsonSchema, Debug, Clone, PartialEq, Serialize, Deserialize)]
1297 enum InventoryType {
1298 System,
1299 Transactional,
1300 }
1301
1302 #[derive(JsonSchema, Debug, Clone, PartialEq, Serialize, Deserialize)]
1303 struct InventoryRequest {
1304 inventory_type: Option<InventoryType>,
1305 }
1306
1307 #[test]
1308 fn flattens_openapi_schema_dependencies() {
1309 let schema = SchemaSettings::openapi3()
1310 .into_generator()
1311 .into_root_schema_for::<InventoryRequest>()
1312 .to_value();
1313 let mut registry = ComponentRegistry::default();
1314
1315 OpenApi3Generator::schema_reference_for_name(
1316 std::any::type_name::<InventoryRequest>(),
1317 schema,
1318 &mut registry,
1319 );
1320
1321 assert!(registry.schemas.contains_key("InventoryType"));
1322 let request = registry
1323 .schemas
1324 .get("doc::tests::InventoryRequest")
1325 .or_else(|| {
1326 registry.schemas.values().find(|schema| {
1327 schema.get("title").and_then(Value::as_str) == Some("InventoryRequest")
1328 })
1329 })
1330 .expect("root schema should be registered");
1331 assert!(request.get("components").is_none());
1332 }
1333
1334 #[test]
1335 fn emits_security_scheme_from_authenticated_only_headers() {
1336 let mut registrant = DocumentationRegistrant::new();
1337 registrant.set_global_headers_scoped(
1338 HashMap::from([(String::from("Authorization"), String::from("Bearer token"))]),
1339 true,
1340 );
1341
1342 let components =
1343 OpenApi3Generator::resolve_components(®istrant, ComponentRegistry::default());
1344 let schemes = components
1345 .get("securitySchemes")
1346 .and_then(Value::as_object)
1347 .expect("authenticated headers should create security schemes");
1348 assert!(schemes.contains_key("authorization"));
1349 }
1350
1351 #[test]
1352 fn component_names_use_entity_name_without_module_path() {
1353 assert_eq!(
1354 OpenApi3Generator::component_name("backend_server::modules::inventory::Request"),
1355 "Request"
1356 );
1357 }
1358
1359 #[test]
1360 fn emits_all_configured_mount_paths_as_servers() {
1361 let mut registrant = DocumentationRegistrant::new();
1362 registrant.bind_base_list(&["/api".into(), "/internal/".into(), "/api".into()]);
1363
1364 assert_eq!(
1365 OpenApi3Generator::build_servers(®istrant),
1366 json!([{ "url": "/api/" }, { "url": "/internal/" }])
1367 );
1368 }
1369}