Skip to main content

shared_framework/doc/
mod.rs

1//! Documentation registry and OpenAPI 3.1 specification generator.
2//!
3//! Collects route metadata in [`DocumentationRegistrant`] (a process-wide
4//! registry) and renders it to an OpenAPI 3.1 JSON document with
5//! [`OpenApi3Generator`]. [`DocumentableDTO`] marks request/response types that
6//! can supply a schema and an example value.
7//!
8//! Key types: [`DocumentationRegistrant`] for collecting routes and global
9//! settings, [`OpenApi3Generator`] for rendering the spec, [`DocumentableDTO`]
10//! for describable payloads, [`DocHttpClient`] for the test-execution clients,
11//! and [`RegisteredRoute`] / [`RouteDoc`] as route records.
12//!
13//! Use this module when serving API docs: register each route on the global
14//! registrant, then call `OpenApi3Generator::generate(&registrant)` to get the
15//! JSON served by the documentation controller.
16//!
17//! ```ignore
18//! let spec = {
19//!     let registrant = crate::doc::DocumentationRegistrant::get_instance();
20//!     crate::doc::OpenApi3Generator::generate(&registrant)
21//! };
22//! assert_eq!(spec["openapi"], "3.1.0");
23//! ```
24
25pub 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
36/// A request or response type that can describe itself for documentation.
37///
38/// Requires [`JsonSchema`] so a schema can be derived, plus `Serialize`,
39/// `DeserializeOwned`, `Clone`, and `Debug`. Implementors provide
40/// [`get_example`](Self::get_example); [`make_example`](Self::make_example)
41/// serializes that example to JSON.
42///
43/// ```ignore
44/// # use schemars::JsonSchema;
45/// # use serde::{Deserialize, Serialize};
46/// # use crate::doc::DocumentableDTO;
47/// #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
48/// struct CreateUser { name: String }
49///
50/// impl DocumentableDTO for CreateUser {
51///     fn get_example() -> Self {
52///         Self { name: "ada".to_string() }
53///     }
54/// }
55/// ```
56pub trait DocumentableDTO:
57    Send + Sync + Debug + DeserializeOwned + Serialize + JsonSchema + Clone
58{
59    /// Returns a representative instance used as the documented example.
60    fn get_example() -> Self;
61
62    /// Serializes [`get_example`](Self::get_example) to a JSON value.
63    ///
64    /// Returns the `serde_json` error if the example cannot be serialized.
65    fn make_example() -> Result<Value, serde_json::Error> {
66        let example = Self::get_example();
67        serde_json::to_value(example)
68    }
69}
70
71// ── RouteDescription re-export from controller for single source ────────────
72
73/// Route metadata types shared with the controller module, used when registering documentation.
74pub use crate::controller::{FileParameter, FileParameterType, RouteDescription};
75
76/// One registered route: its path, HTTP method, owning controller name, and metadata.
77///
78/// `method` is stored as given (for example, `"GET"`); the gene,rator lowercases
79/// it when emitting the OpenAPI operations object.
80#[derive(Debug, Clone)]
81pub struct RegisteredRoute {
82    /// Route path as registered (for example, `"/users/:id"`).
83    pub path: String,
84    /// HTTP method as registered (for example, `"GET"`).
85    pub method: String,
86    /// Name of the controller that registered the route.
87    pub controller_class: String,
88    /// Descriptive metadata (group, parameters, bodies, responses).
89    pub description: RouteDescription,
90}
91
92/// Controls when the documentation endpoints are served.
93///
94/// `Conventional` (the default) hides the docs in production environments.
95/// `External` serves the docs in every environment, including production.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
97pub enum DocumentationMode {
98    /// Hide docs in production; serve them elsewhere.
99    #[default]
100    Conventional,
101    /// Serve docs in every environment, including production.
102    External,
103}
104
105/// HTTP response returned by a [`DocHttpClient`] test execution.
106#[derive(Debug, Clone)]
107pub struct DocHttpResponse {
108    /// HTTP status code of the executed request.
109    pub status_code: u16,
110    /// Response headers as name/value pairs.
111    pub headers: HashMap<String, String>,
112    /// Response body as text.
113    pub body: String,
114}
115
116/// A named HTTP client usable from the documentation test hook.
117///
118/// Each client describes itself with [`name`](Self::name),
119/// [`description`](Self::description), and [`usage_snippet`](Self::usage_snippet),
120/// and can [`execute`](Self::execute) an arbitrary request.
121#[async_trait::async_trait]
122pub trait DocHttpClient: Send + Sync + Debug {
123    /// Display the name of the client (for example, `"Classic HTTP Client"`).
124    fn name(&self) -> &str;
125
126    /// Short description of the client's behavior.
127    fn description(&self) -> &str;
128
129    /// Short snippet illustrating typical usage.
130    fn usage_snippet(&self) -> &str;
131
132    /// Executes one HTTP request and returns the response.
133    ///
134    /// `method` is the HTTP method, `url` the absolute URL, `headers` the
135    /// headers to send, `body` the optional request body, and `parameters`
136    /// extra query parameters to append. Returns an error if the request
137    /// cannot be built or sent.
138    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/// Default HTTP client with a 30-second request timeout.
216#[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/// HTTP client that waits 100ms before sending, with a 30-second timeout.
242#[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/// HTTP client with a short 5-second timeout and no artificial delay.
269#[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
294/// Process-wide registry of routes and documentation settings.
295///
296/// Holds the base path, global rate limit, auth settings, group descriptions,
297/// registered routes, global headers, test HTTP clients, and the
298/// [`DocumentationMode`]. Starts with base path `"/"`, no rate limit,
299/// `Conventional` mode, and the three built-in HTTP clients.
300///
301/// Access the shared instance via [`global`](Self::global),
302/// [`get_instance`](Self::get_instance), or
303/// [`get_instance_mut`](Self::get_instance_mut).
304pub 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    /// Returns the shared registry, creating it on first use.
340    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    /// Returns a read guard for the shared registry.
346    ///
347    /// Panics if the lock is poisoned.
348    // Singleton accessor.
349    pub fn get_instance() -> std::sync::RwLockReadGuard<'static, Self> {
350        Self::global().read().unwrap()
351    }
352
353    /// Returns a write guard for the shared registry.
354    ///
355    /// Panics if the lock is poisoned.
356    pub fn get_instance_mut() -> std::sync::RwLockWriteGuard<'static, Self> {
357        Self::global().write().unwrap()
358    }
359
360    /// Sets the base path used for the generated `servers` entries.
361    ///
362    /// An empty value resets the base path to `"/"`.
363    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    /// Sets the base path from a list of roots, using the first entry.
370    ///
371    /// Falls back to `"/"` when the list is empty.
372    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    /// Returns the currently configured base path.
392    pub fn base_path(&self) -> &str {
393        &self.base_path
394    }
395
396    /// Returns all configured mount/bind paths used as OpenAPI server paths.
397    pub fn server_paths(&self) -> &[String] {
398        &self.server_paths
399    }
400
401    /// Sets the global rate limit in requests per minute (`None` means unlimited).
402    pub fn set_global_rate_limit(&mut self, limit: Option<u32>) {
403        self.global_rate_limit = limit;
404    }
405
406    /// Returns the global rate limit in requests per minute, if set.
407    pub fn get_global_rate_limit(&self) -> Option<u32> {
408        self.global_rate_limit
409    }
410
411    /// Replaces the authentication settings map used in the spec description and security schemes.
412    pub fn set_auth_settings(&mut self, m: HashMap<String, String>) {
413        self.auth_settings = m;
414    }
415
416    /// Returns the authentication settings map.
417    pub fn get_auth_settings(&self) -> &HashMap<String, String> {
418        &self.auth_settings
419    }
420
421    /// Sets the legacy shared global headers bucket.
422    pub fn set_global_headers(&mut self, headers: HashMap<String, String>) {
423        self.legacy_global_headers = headers;
424    }
425
426    /// Sets scoped global headers: the authenticated bucket when
427    /// `authenticated_only` is true, otherwise the unauthenticated bucket.
428    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    /// Returns legacy, unauthenticated, and authenticated headers merged together.
441    ///
442    /// Later buckets overwrite earlier ones on key conflicts.
443    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    /// Returns legacy plus authenticated headers merged together.
451    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    /// Returns only headers configured for authenticated requests.
458    pub fn get_authenticated_only_headers(&self) -> &HashMap<String, String> {
459        &self.authenticated_global_headers
460    }
461
462    /// Returns legacy plus unauthenticated headers merged together.
463    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    /// Sets the [`DocumentationMode`] controlling docs visibility in production.
470    pub fn set_documentation_mode(&mut self, mode: DocumentationMode) {
471        self.documentation_mode = mode;
472    }
473
474    /// Returns the current [`DocumentationMode`].
475    pub fn get_documentation_mode(&self) -> DocumentationMode {
476        self.documentation_mode
477    }
478
479    /// Adds an extra [`DocHttpClient`] to the test-execution client list.
480    pub fn register_http_client(&mut self, client: Arc<dyn DocHttpClient>) {
481        self.http_clients.push(client);
482    }
483
484    /// Returns all registered test-execution HTTP clients.
485    pub fn get_http_clients(&self) -> Vec<Arc<dyn DocHttpClient>> {
486        self.http_clients.clone()
487    }
488
489    /// Sets the description for one documentation group (tag).
490    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    /// Replaces all group descriptions, keeping only entries with a non-empty
495    /// trimmed name and storing trimmed names and values.
496    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    /// Returns the description for a group, or `"N/A"` when missing or blank.
507    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    /// Returns all group descriptions keyed by group name.
518    pub fn get_group_descriptions(&self) -> &HashMap<String, String> {
519        &self.group_descriptions
520    }
521
522    /// Records one route with its path, method, controller name, and metadata.
523    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    /// Returns all registered routes in registration order.
539    pub fn get_registered_routes(&self) -> &[RegisteredRoute] {
540        &self.registered_routes
541    }
542
543    /// Removes all registered routes; other settings are left unchanged.
544    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/// Compact route record kept for backwards compatibility.
556///
557/// Prefer [`RegisteredRoute`] with [`RouteDescription`] for new code; this
558/// struct carries only the summary and tags without full parameter schemas.
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct RouteDoc {
561    /// Route path as registered.
562    pub path: String,
563    /// HTTP method as registered.
564    pub method: String,
565    /// Name of the controller that registered the route.
566    pub controller: String,
567    /// Short summary of the route.
568    pub summary: String,
569    /// Documentation groups (tags) the route belongs to.
570    pub tags: Vec<String>,
571}
572
573// ── ComponentRegistry ──────────────────────────────────────────────────────
574
575#[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
618/// Renders the registry contents as an OpenAPI 3.1 JSON document.
619///
620/// Only routes with a non-empty group are emitted, and routes owned by the
621/// documentation controller itself are skipped. The result is built fresh on
622/// every call; the documentation controller caches the rendered string.
623pub struct OpenApi3Generator;
624
625impl OpenApi3Generator {
626    /// Generates an OpenAPI 3.1 document from the registrant.
627    ///
628    /// Uses the application environment for the `info.title` and
629    /// `info.version` when available, falling back to `"API Documentation"` /
630    /// `"1.0.0"`.
631    ///
632    /// ```ignore
633    /// # let registrant = crate::doc::DocumentationRegistrant::get_instance();
634    /// let spec = crate::doc::OpenApi3Generator::generate(&registrant);
635    /// assert_eq!(spec["openapi"], "3.1.0");
636    /// ```
637    pub fn generate(registrant: &DocumentationRegistrant) -> Value {
638        Self::generate_with_info(registrant, None, None)
639    }
640
641    /// Generates an OpenAPI 3.1 document with an explicit title and version.
642    ///
643    /// Behaves as [`generate`](Self::generate) but overrides the `info.title`
644    /// and `info.version` taken from the environment.
645    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            // Top-level `security: []` when any scheme exists.
677            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        // Group routes by group, then emit sorted.
750        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        // Tags hold the single group.
803        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        // parameters (with defaults + global unauth headers merged in)
815        let params = Self::build_parameters(desc, registrant);
816        if !params.is_empty() {
817            op["parameters"] = Value::Array(params);
818        }
819        // requestBody
820        if let Some(rb) = Self::build_request_body(desc, route_path, registry) {
821            op["requestBody"] = rb;
822        }
823        // responses
824        op["responses"] = Self::build_responses(desc, registry);
825        // x-authentication
826        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        // Security entries only when some route in the set requires auth.
836        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        // Merge global unauthenticated headers into the route headers.
874        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        // Multipart request body when file parameters exist.
899        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                // Hint accepted extensions from the file parameter type.
918                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        // Single inline `example` (not `examples`), plus a form-urlencoded fallback.
964        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            // {status, message, data} envelope.
989            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    /// Moves schemas generated by Schemars into the document-level component
1046    /// registry. With `SchemaSettings::openapi3`, Schemars emits referenced
1047    /// schemas under the root schema's `components.schemas`; other schema
1048    /// dialects may use `$defs` or `definitions`. Keeping those definitions
1049    /// nested inside a component schema makes their absolute `$ref`s point at
1050    /// keys which do not exist in the final OpenAPI document.
1051    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        // securitySchemes built from the authenticated global headers.
1127        let mut schemes = serde_json::Map::new();
1128        if auth_headers.is_empty() {
1129            // Fall back to auth_settings keys so `security` is still meaningful
1130            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    // ── helpers ──────────────────────────────────────────────────────────────
1205
1206    fn open_api_path(path: &str) -> String {
1207        if path.is_empty() {
1208            return "/".to_string();
1209        }
1210        // Convert :param to {param}
1211        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    /// Component name derived from the entity/type name without its module path.
1250    fn component_name(name: &str) -> String {
1251        Self::sanitize_identifier(name)
1252    }
1253
1254    // ── scalar schema helpers (via schemars) ─────────────────────────────────
1255
1256    #[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
1270/// Minimal HTTP helper used in documentation examples.
1271///
1272/// Holds only the base URL to build requests against.
1273pub struct DocExampleHttpClient {
1274    /// Base URL that example requests are built against.
1275    pub base_url: String,
1276}
1277
1278impl DocExampleHttpClient {
1279    /// Creates a helper for the given base URL.
1280    pub fn new(base_url: impl Into<String>) -> Self {
1281        Self {
1282            base_url: base_url.into(),
1283        }
1284    }
1285}
1286
1287/// Backwards-compatible alias for [`DocExampleHttpClient`].
1288#[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(&registrant, 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(&registrant),
1366            json!([{ "url": "/api/" }, { "url": "/internal/" }])
1367        );
1368    }
1369}