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            && !rb.is_null()
822        {
823            op["requestBody"] = rb;
824        }
825        // responses
826        op["responses"] = Self::build_responses(desc, registry);
827        // x-authentication
828        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        // Security entries only when some route in the set requires auth.
838        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        // Merge global unauthenticated headers into the route headers.
876        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        // Multipart request body when file parameters exist.
901        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                // Hint accepted extensions from the file parameter type.
920                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        // Single inline `example` (not `examples`), plus a form-urlencoded fallback.
966        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            // {status, message, data} envelope.
991            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    /// Moves schemas generated by Schemars into the document-level component
1048    /// registry. With `SchemaSettings::openapi3`, Schemars emits referenced
1049    /// schemas under the root schema's `components.schemas`; other schema
1050    /// dialects may use `$defs` or `definitions`. Keeping those definitions
1051    /// nested inside a component schema makes their absolute `$ref`s point at
1052    /// keys which do not exist in the final OpenAPI document.
1053    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        // securitySchemes built from the authenticated global headers.
1129        let mut schemes = serde_json::Map::new();
1130        if auth_headers.is_empty() {
1131            // Fall back to auth_settings keys so `security` is still meaningful
1132            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    // ── helpers ──────────────────────────────────────────────────────────────
1207
1208    fn open_api_path(path: &str) -> String {
1209        if path.is_empty() {
1210            return "/".to_string();
1211        }
1212        // Convert :param to {param}
1213        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    /// Component name derived from the entity/type name without its module path.
1252    fn component_name(name: &str) -> String {
1253        Self::sanitize_identifier(name)
1254    }
1255
1256    // ── scalar schema helpers (via schemars) ─────────────────────────────────
1257
1258    #[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
1272/// Minimal HTTP helper used in documentation examples.
1273///
1274/// Holds only the base URL to build requests against.
1275pub struct DocExampleHttpClient {
1276    /// Base URL that example requests are built against.
1277    pub base_url: String,
1278}
1279
1280impl DocExampleHttpClient {
1281    /// Creates a helper for the given base URL.
1282    pub fn new(base_url: impl Into<String>) -> Self {
1283        Self {
1284            base_url: base_url.into(),
1285        }
1286    }
1287}
1288
1289/// Backwards-compatible alias for [`DocExampleHttpClient`].
1290#[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(&registrant, 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(&registrant),
1368            json!([{ "url": "/api/" }, { "url": "/internal/" }])
1369        );
1370    }
1371}