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 && !rb.is_null()
822 {
823 op["requestBody"] = rb;
824 }
825 op["responses"] = Self::build_responses(desc, registry);
827 op["x-authentication"] = json!({
829 "required": desc.authentication_required,
830 "comment": desc.authentication_comment.clone().unwrap_or_default()
831 });
832 if let Some(limit) = desc.effective_rate_limit(registrant.get_global_rate_limit()) {
833 if limit > 0 {
834 op["x-rate-limit"] = json!({ "limit": limit, "period": "minute" });
835 }
836 }
837 if desc.authentication_required && registers_auth {
839 op["security"] = json!([{ "authentication": [] }]);
840 }
841 op
842 }
843
844 fn build_parameters(
845 desc: &RouteDescription,
846 registrant: &DocumentationRegistrant,
847 ) -> Vec<Value> {
848 let mut out = Vec::new();
849 for (name, descr) in &desc.path_parameters {
850 let mut schema = json!({ "type": "string" });
851 if let Some(def) = desc.path_parameter_defaults.get(name) {
852 schema["default"] = Value::String(def.clone());
853 }
854 out.push(json!({
855 "name": name,
856 "in": "path",
857 "required": true,
858 "description": descr,
859 "schema": schema
860 }));
861 }
862 for (name, descr) in &desc.query_parameters {
863 let mut schema = json!({ "type": "string" });
864 if let Some(def) = desc.query_parameter_defaults.get(name) {
865 schema["default"] = Value::String(def.clone());
866 }
867 out.push(json!({
868 "name": name,
869 "in": "query",
870 "required": false,
871 "description": descr,
872 "schema": schema
873 }));
874 }
875 let mut headers = registrant.get_unauthenticated_global_headers();
877 for (k, v) in &desc.headers {
878 headers.insert(k.clone(), v.clone());
879 }
880 for (name, val) in &headers {
881 if ["accept", "content-type", "authorization"].contains(&name.to_lowercase().as_str()) {
882 continue;
883 }
884 out.push(json!({
885 "name": name,
886 "in": "header",
887 "required": false,
888 "description": val,
889 "schema": { "type": "string", "default": "" }
890 }));
891 }
892 out
893 }
894
895 fn build_request_body(
896 desc: &RouteDescription,
897 route_path: &str,
898 registry: &mut ComponentRegistry,
899 ) -> Option<Value> {
900 if !desc.file_parameters.is_empty() {
902 let mut properties = serde_json::Map::new();
903 properties.insert(
904 "body".to_string(),
905 json!({
906 "type": "string",
907 "description": "JSON-encoded application payload sent alongside the uploaded files, if any. Switch to the application/json to see the type information"
908 }),
909 );
910 for (name, param) in &desc.file_parameters {
911 let mut schema = json!({
912 "type": "string",
913 "format": "binary",
914 "description": param.description,
915 });
916 if let Some(max) = param.count.or(param.limit) {
917 schema["x-max-files"] = json!(max);
918 }
919 schema["x-allowed-extensions"] =
921 json!(param.file_type.allowed_extensions_example());
922 properties.insert(name.clone(), schema);
923 }
924 let mut content = serde_json::Map::new();
925 content.insert(
926 "multipart/form-data".to_string(),
927 json!({ "schema": { "type": "object", "properties": properties } }),
928 );
929 if let Some(dto) = desc.request_body.as_ref() {
930 if dto.name != "Void" && !dto.name.is_empty() {
931 let schema =
932 Self::schema_reference_for_name(dto.name, dto.schema.clone(), registry);
933 content.insert(
934 "application/json".to_string(),
935 json!({ "schema": schema, "example": dto.example.clone() }),
936 );
937 }
938 }
939 let name = format!(
940 "RequestBody<{}>",
941 Self::operation_id(&desc.name, route_path)
942 );
943 return Some(registry.component_ref(
944 "requestBodies",
945 &name,
946 json!({ "required": true, "content": content }),
947 ));
948 }
949
950 let dto = desc.request_body.as_ref()?;
951 let dto_type = dto.name;
952
953 if dto_type == "String" {
954 return Some(json!({
955 "required": true,
956 "content": {
957 "text/plain": { "schema": { "type": "string" } }
958 }
959 }));
960 }
961 if dto_type == "Void" || dto_type.is_empty() {
962 return None;
963 }
964
965 let schema = Self::schema_reference_for_name(dto_type, dto.schema.clone(), registry);
967 let definition = json!({
968 "required": true,
969 "content": {
970 "application/json": { "schema": schema, "example": dto.example.clone() },
971 "application/x-www-form-urlencoded": { "schema": { "type": "object", "additionalProperties": true } }
972 }
973 });
974 let type_name = Self::component_name(dto_type);
975 Some(registry.component_ref(
976 "requestBodies",
977 &format!("RequestBody<{}>", type_name),
978 definition,
979 ))
980 }
981
982 fn build_responses(desc: &RouteDescription, registry: &mut ComponentRegistry) -> Value {
983 if desc.response_examples.is_empty() {
984 return json!({
985 "200": registry.component_ref("responses", "Response<200,Empty>", json!({ "description": "Successful operation" }))
986 });
987 }
988 let mut out = serde_json::Map::new();
989 for (code, dto) in &desc.response_examples {
990 let status = if *code >= 400 { "error" } else { "success" };
992 let message = if *code >= 500 {
993 "Internal server error"
994 } else if *code >= 400 {
995 "Bad request"
996 } else {
997 "Successful operation"
998 };
999 let schema = Self::schema_reference_for_name(dto.name, dto.schema.clone(), registry);
1000 let media = json!({
1001 "schema": {
1002 "type": "object",
1003 "properties": {
1004 "status": { "type": "string", "enum": ["success", "error"] },
1005 "message": { "type": "string" },
1006 "data": schema
1007 }
1008 },
1009 "example": {
1010 "status": status,
1011 "message": message,
1012 "data": dto.example.clone()
1013 }
1014 });
1015 let type_name = Self::component_name(dto.name);
1016 let resp = json!({
1017 "description": if *code >= 400 {
1018 format!("Error response ({})", type_name)
1019 } else {
1020 format!("Successful operation ({})", type_name)
1021 },
1022 "content": { "application/json": media }
1023 });
1024 out.insert(
1025 code.to_string(),
1026 registry.component_ref(
1027 "responses",
1028 &format!("Response<{},{}>", code, type_name),
1029 resp,
1030 ),
1031 );
1032 }
1033 Value::Object(out)
1034 }
1035
1036 fn schema_reference_for_name(
1037 name: &str,
1038 mut value: Value,
1039 registry: &mut ComponentRegistry,
1040 ) -> Value {
1041 let key = Self::component_name(name);
1042 Self::register_schema_dependencies(&mut value, registry);
1043 registry.schemas.entry(key.clone()).or_insert(value);
1044 json!({ "$ref": format!("#/components/schemas/{}", key) })
1045 }
1046
1047 fn register_schema_dependencies(schema: &mut Value, registry: &mut ComponentRegistry) {
1054 let mut definitions = Vec::new();
1055 if let Some(object) = schema.as_object_mut() {
1056 if let Some(components) = object.remove("components") {
1057 if let Some(schemas) = components.get("schemas").and_then(Value::as_object) {
1058 definitions.extend(
1059 schemas
1060 .iter()
1061 .map(|(name, schema)| (name.clone(), schema.clone())),
1062 );
1063 }
1064 }
1065 for key in ["$defs", "definitions"] {
1066 if let Some(defs) = object
1067 .remove(key)
1068 .and_then(|value| value.as_object().cloned())
1069 {
1070 definitions.extend(defs);
1071 }
1072 }
1073 }
1074
1075 for (name, mut definition) in definitions {
1076 Self::register_schema_dependencies(&mut definition, registry);
1077 Self::normalize_schema_refs(&mut definition);
1078 registry.schemas.entry(name).or_insert(definition);
1079 }
1080 Self::normalize_schema_refs(schema);
1081 }
1082
1083 fn normalize_schema_refs(value: &mut Value) {
1084 match value {
1085 Value::Object(object) => {
1086 for child in object.values_mut() {
1087 Self::normalize_schema_refs(child);
1088 }
1089 }
1090 Value::Array(values) => {
1091 for child in values {
1092 Self::normalize_schema_refs(child);
1093 }
1094 }
1095 Value::String(reference) => {
1096 for prefix in ["#/$defs/", "#/definitions/"] {
1097 if let Some(name) = reference.strip_prefix(prefix) {
1098 *reference = format!("#/components/schemas/{}", name);
1099 break;
1100 }
1101 }
1102 }
1103 _ => {}
1104 }
1105 }
1106
1107 fn route_registers_authentication(registrant: &DocumentationRegistrant) -> bool {
1108 registrant
1109 .get_registered_routes()
1110 .iter()
1111 .any(|r| r.description.authentication_required)
1112 }
1113
1114 fn resolve_components(
1115 registrant: &DocumentationRegistrant,
1116 registry: ComponentRegistry,
1117 ) -> Value {
1118 let mut comps = registry.to_json();
1119 let auth_settings = registrant.get_auth_settings();
1120 let auth_headers = registrant.get_authenticated_only_headers();
1121 if auth_settings.is_empty()
1122 && auth_headers.is_empty()
1123 && !Self::route_registers_authentication(registrant)
1124 {
1125 return comps;
1126 }
1127
1128 let mut schemes = serde_json::Map::new();
1130 if auth_headers.is_empty() {
1131 for (header_name, description) in auth_settings {
1133 let key = Self::to_kebab_case(header_name);
1134 let mut scheme = serde_json::Map::new();
1135 scheme.insert("in".to_string(), json!("header"));
1136 scheme.insert("name".to_string(), json!(header_name));
1137 scheme.insert("description".to_string(), json!(description));
1138 if header_name.eq_ignore_ascii_case("Authorization") {
1139 scheme.insert("type".to_string(), json!("http"));
1140 scheme.insert("scheme".to_string(), json!("bearer"));
1141 } else {
1142 scheme.insert("type".to_string(), json!("apiKey"));
1143 }
1144 schemes.insert(key, Value::Object(scheme));
1145 }
1146 } else {
1147 for (header_name, description) in auth_headers {
1148 let key = Self::to_kebab_case(header_name);
1149 let mut scheme = serde_json::Map::new();
1150 scheme.insert("in".to_string(), json!("header"));
1151 scheme.insert("name".to_string(), json!(header_name));
1152 scheme.insert("description".to_string(), json!(description));
1153 if header_name.eq_ignore_ascii_case("Authorization") {
1154 scheme.insert("type".to_string(), json!("http"));
1155 scheme.insert("scheme".to_string(), json!("bearer"));
1156 } else {
1157 scheme.insert("type".to_string(), json!("apiKey"));
1158 }
1159 schemes.insert(key, Value::Object(scheme));
1160 }
1161 }
1162 if let Some(obj) = comps.as_object_mut() {
1163 obj.insert("securitySchemes".to_string(), Value::Object(schemes));
1164 }
1165 comps
1166 }
1167
1168 fn security_scheme_key(components: &Value) -> Option<String> {
1169 components
1170 .get("securitySchemes")
1171 .and_then(|s| s.as_object())
1172 .and_then(|m| m.keys().next().cloned())
1173 }
1174
1175 fn to_kebab_case(value: &str) -> String {
1176 let mut out = String::with_capacity(value.len());
1177 for (i, c) in value.chars().enumerate() {
1178 if c.is_ascii_uppercase() {
1179 if i != 0 {
1180 out.push('-');
1181 }
1182 out.push(c.to_ascii_lowercase());
1183 } else if c == '_' || c == ' ' {
1184 out.push('-');
1185 } else {
1186 out.push(c);
1187 }
1188 }
1189 out
1190 }
1191
1192 fn build_group_descriptions(registrant: &DocumentationRegistrant) -> Vec<Value> {
1193 registrant
1194 .get_group_descriptions()
1195 .iter()
1196 .filter_map(|(name, desc)| {
1197 if desc.trim().is_empty() || desc == "N/A" {
1198 None
1199 } else {
1200 Some(json!({ "name": name, "description": desc }))
1201 }
1202 })
1203 .collect()
1204 }
1205
1206 fn open_api_path(path: &str) -> String {
1209 if path.is_empty() {
1210 return "/".to_string();
1211 }
1212 let re = regex::Regex::new(r":([A-Za-z0-9_]+)").unwrap();
1214 re.replace_all(path, "{$1}").to_string()
1215 }
1216
1217 fn operation_id(name: &str, path: &str) -> String {
1218 let base = Self::sanitize_identifier(name);
1219 let id = if base.is_empty() {
1220 Self::sanitize_identifier(path)
1221 } else {
1222 base
1223 };
1224 if id.is_empty() {
1225 "operation".to_string()
1226 } else {
1227 id
1228 }
1229 }
1230
1231 fn sanitize_identifier(value: &str) -> String {
1232 let value = value.rsplit("::").next().unwrap_or(value);
1233 let re = regex::Regex::new(r"[^A-Za-z0-9:+_-]").unwrap();
1234 let sanitized = re.replace_all(value.trim(), "_").to_string();
1235 let re2 = regex::Regex::new(r"_{2,}").unwrap();
1236 let sanitized = re2.replace_all(&sanitized, "_").to_string();
1237 if sanitized.is_empty() {
1238 return "".to_string();
1239 }
1240 if sanitized
1241 .chars()
1242 .next()
1243 .map_or(false, |c| c.is_ascii_digit())
1244 {
1245 format!("_{}", sanitized)
1246 } else {
1247 sanitized
1248 }
1249 }
1250
1251 fn component_name(name: &str) -> String {
1253 Self::sanitize_identifier(name)
1254 }
1255
1256 #[allow(dead_code)]
1259 fn scalar_schema(type_name: &str) -> Option<Value> {
1260 match type_name {
1261 "String" | "char" | "Character" => Some(json!({ "type": "string" })),
1262 "Uuid" => Some(json!({ "type": "string", "format": "uuid" })),
1263 "i32" | "i64" | "u32" | "u64" | "isize" | "usize" | "i8" | "u8" | "i16" | "u16"
1264 | "i128" | "u128" => Some(json!({ "type": "integer" })),
1265 "f32" | "f64" => Some(json!({ "type": "number" })),
1266 "bool" => Some(json!({ "type": "boolean" })),
1267 _ => None,
1268 }
1269 }
1270}
1271
1272pub struct DocExampleHttpClient {
1276 pub base_url: String,
1278}
1279
1280impl DocExampleHttpClient {
1281 pub fn new(base_url: impl Into<String>) -> Self {
1283 Self {
1284 base_url: base_url.into(),
1285 }
1286 }
1287}
1288
1289#[allow(dead_code)]
1291pub type LegacyDocHttpClient = DocExampleHttpClient;
1292
1293#[cfg(test)]
1294mod tests {
1295 use super::*;
1296 use schemars::generate::SchemaSettings;
1297
1298 #[derive(JsonSchema, Debug, Clone, PartialEq, Serialize, Deserialize)]
1299 enum InventoryType {
1300 System,
1301 Transactional,
1302 }
1303
1304 #[derive(JsonSchema, Debug, Clone, PartialEq, Serialize, Deserialize)]
1305 struct InventoryRequest {
1306 inventory_type: Option<InventoryType>,
1307 }
1308
1309 #[test]
1310 fn flattens_openapi_schema_dependencies() {
1311 let schema = SchemaSettings::openapi3()
1312 .into_generator()
1313 .into_root_schema_for::<InventoryRequest>()
1314 .to_value();
1315 let mut registry = ComponentRegistry::default();
1316
1317 OpenApi3Generator::schema_reference_for_name(
1318 std::any::type_name::<InventoryRequest>(),
1319 schema,
1320 &mut registry,
1321 );
1322
1323 assert!(registry.schemas.contains_key("InventoryType"));
1324 let request = registry
1325 .schemas
1326 .get("doc::tests::InventoryRequest")
1327 .or_else(|| {
1328 registry.schemas.values().find(|schema| {
1329 schema.get("title").and_then(Value::as_str) == Some("InventoryRequest")
1330 })
1331 })
1332 .expect("root schema should be registered");
1333 assert!(request.get("components").is_none());
1334 }
1335
1336 #[test]
1337 fn emits_security_scheme_from_authenticated_only_headers() {
1338 let mut registrant = DocumentationRegistrant::new();
1339 registrant.set_global_headers_scoped(
1340 HashMap::from([(String::from("Authorization"), String::from("Bearer token"))]),
1341 true,
1342 );
1343
1344 let components =
1345 OpenApi3Generator::resolve_components(®istrant, ComponentRegistry::default());
1346 let schemes = components
1347 .get("securitySchemes")
1348 .and_then(Value::as_object)
1349 .expect("authenticated headers should create security schemes");
1350 assert!(schemes.contains_key("authorization"));
1351 }
1352
1353 #[test]
1354 fn component_names_use_entity_name_without_module_path() {
1355 assert_eq!(
1356 OpenApi3Generator::component_name("backend_server::modules::inventory::Request"),
1357 "Request"
1358 );
1359 }
1360
1361 #[test]
1362 fn emits_all_configured_mount_paths_as_servers() {
1363 let mut registrant = DocumentationRegistrant::new();
1364 registrant.bind_base_list(&["/api".into(), "/internal/".into(), "/api".into()]);
1365
1366 assert_eq!(
1367 OpenApi3Generator::build_servers(®istrant),
1368 json!([{ "url": "/api/" }, { "url": "/internal/" }])
1369 );
1370 }
1371}