Skip to main content

arcly_http/docs/
openapi.rs

1//! OpenAPI 3.0 spec generation + a tiny Swagger UI page.
2//!
3//! Walks every [`RouteDescriptor`] collected via `inventory`, converts the
4//! associated [`RouteSpec`](crate::core::engine::RouteSpec) into a `paths.<route>.<method>` entry, harvests
5//! schemars `definitions` into `components.schemas`, and produces a spec
6//! that's immediately browsable in Swagger UI.
7
8use std::borrow::Cow;
9use std::collections::BTreeMap;
10
11use serde_json::{json, Map, Value};
12
13use crate::core::engine::{HttpMethod, ParamLoc, RouteDescriptor};
14
15const OPENAPI_VERSION: &str = "3.0.3";
16
17/// HTTP authentication scheme exposed in `components.securitySchemes`.
18///
19/// Routes opt in to a scheme by name via `security("bearer")` on the route
20/// macro. The exact spelling must match the key used in
21/// [`OpenApiInfo::security_schemes`].
22#[derive(Clone)]
23pub enum SecurityScheme {
24    /// `Authorization: Bearer <token>`. Optional `bearer_format` (e.g. "JWT").
25    Bearer { bearer_format: Option<&'static str> },
26    /// API key carried in a header, query, or cookie.
27    ApiKey {
28        location: ApiKeyIn,
29        name: &'static str,
30    },
31    /// HTTP Basic auth.
32    Basic,
33}
34
35#[derive(Clone, Copy)]
36pub enum ApiKeyIn {
37    Header,
38    Query,
39    Cookie,
40}
41
42impl SecurityScheme {
43    fn to_value(&self) -> Value {
44        match self {
45            SecurityScheme::Bearer { bearer_format } => {
46                let mut v = json!({ "type": "http", "scheme": "bearer" });
47                if let Some(f) = bearer_format {
48                    v.as_object_mut()
49                        .unwrap()
50                        .insert("bearerFormat".into(), Value::String((*f).into()));
51                }
52                v
53            }
54            SecurityScheme::ApiKey { location, name } => json!({
55                "type": "apiKey",
56                "in": match location { ApiKeyIn::Header => "header", ApiKeyIn::Query => "query", ApiKeyIn::Cookie => "cookie" },
57                "name": *name,
58            }),
59            SecurityScheme::Basic => json!({ "type": "http", "scheme": "basic" }),
60        }
61    }
62}
63
64/// Top-level OpenAPI document configuration. Build at `main.rs` and pass to
65/// [`crate::App::launch_with_info`].
66#[derive(Clone, Default)]
67#[non_exhaustive]
68pub struct OpenApiInfo {
69    pub title: Cow<'static, str>,
70    pub version: Cow<'static, str>,
71    pub description: Option<Cow<'static, str>>,
72    pub terms_of_service: Option<Cow<'static, str>>,
73    pub contact_name: Option<Cow<'static, str>>,
74    pub contact_url: Option<Cow<'static, str>>,
75    pub contact_email: Option<Cow<'static, str>>,
76    pub license_name: Option<Cow<'static, str>>,
77    pub license_url: Option<Cow<'static, str>>,
78    /// Each `(url, description)` pair becomes a server entry. The first is
79    /// the default rendered by Swagger UI.
80    pub servers: Vec<(Cow<'static, str>, Cow<'static, str>)>,
81    /// Security schemes available to routes. Routes attach to a scheme by
82    /// name via `security("name")` on the route macro.
83    pub security_schemes: Vec<(Cow<'static, str>, SecurityScheme)>,
84    /// Tag descriptions surfaced in the UI as section blurbs.
85    pub tag_descriptions: Vec<(Cow<'static, str>, Cow<'static, str>)>,
86}
87
88impl OpenApiInfo {
89    /// Start from a title + version; chain the optional fields. Values may
90    /// be `&'static str` or runtime `String`s (config/env-driven services).
91    /// (`OpenApiInfo` is `#[non_exhaustive]` — new spec fields can land
92    /// without breaking downstream construction.)
93    pub fn new(title: impl Into<Cow<'static, str>>, version: impl Into<Cow<'static, str>>) -> Self {
94        Self {
95            title: title.into(),
96            version: version.into(),
97            ..Default::default()
98        }
99    }
100    pub fn description(mut self, v: impl Into<Cow<'static, str>>) -> Self {
101        self.description = Some(v.into());
102        self
103    }
104    pub fn terms_of_service(mut self, v: impl Into<Cow<'static, str>>) -> Self {
105        self.terms_of_service = Some(v.into());
106        self
107    }
108    pub fn contact_name(mut self, v: impl Into<Cow<'static, str>>) -> Self {
109        self.contact_name = Some(v.into());
110        self
111    }
112    pub fn contact_url(mut self, v: impl Into<Cow<'static, str>>) -> Self {
113        self.contact_url = Some(v.into());
114        self
115    }
116    pub fn contact_email(mut self, v: impl Into<Cow<'static, str>>) -> Self {
117        self.contact_email = Some(v.into());
118        self
119    }
120    pub fn license(
121        mut self,
122        name: impl Into<Cow<'static, str>>,
123        url: impl Into<Cow<'static, str>>,
124    ) -> Self {
125        self.license_name = Some(name.into());
126        self.license_url = Some(url.into());
127        self
128    }
129    /// Add one server entry (`url`, human description — may be empty).
130    pub fn server(
131        mut self,
132        url: impl Into<Cow<'static, str>>,
133        description: impl Into<Cow<'static, str>>,
134    ) -> Self {
135        self.servers.push((url.into(), description.into()));
136        self
137    }
138    /// Add one security scheme by name.
139    pub fn security_scheme(
140        mut self,
141        name: impl Into<Cow<'static, str>>,
142        scheme: SecurityScheme,
143    ) -> Self {
144        self.security_schemes.push((name.into(), scheme));
145        self
146    }
147    /// Add one tag description.
148    pub fn tag(
149        mut self,
150        name: impl Into<Cow<'static, str>>,
151        description: impl Into<Cow<'static, str>>,
152    ) -> Self {
153        self.tag_descriptions
154            .push((name.into(), description.into()));
155        self
156    }
157}
158
159/// Build a complete OpenAPI 3.0 document from all routes registered via
160/// `inventory`.
161pub fn build_spec(info: &OpenApiInfo) -> Value {
162    build_spec_filtered(info, None)
163}
164
165/// `allowed_controllers = None` means "include every inventory-registered route"
166/// (legacy / no module scoping). `Some(set)` filters to controllers reachable
167/// from the root module's import DAG.
168pub fn build_spec_filtered(
169    info: &OpenApiInfo,
170    allowed_controllers: Option<&std::collections::HashSet<&'static str>>,
171) -> Value {
172    let mut paths: Map<String, Value> = Map::new();
173    let mut components: Map<String, Value> = Map::new();
174
175    // Always emit ProblemDetails — it's the canonical 4xx/5xx body shape.
176    components.insert("ProblemDetails".into(), problem_details_schema());
177
178    for rt in inventory::iter::<&'static RouteDescriptor> {
179        if let Some(allowed) = allowed_controllers {
180            if !rt.controller.is_empty() && !allowed.contains(rt.controller) {
181                continue;
182            }
183        }
184        let oapi_path = axum_to_openapi_path(rt.path);
185        let entry = paths
186            .entry(oapi_path)
187            .or_insert_with(|| Value::Object(Map::new()));
188
189        // ── Parameters: path + header + flattened query DTO fields ──────
190        let mut parameters: Vec<Value> = rt.spec.params.iter().map(|p| {
191            json!({
192                "name": p.name,
193                "in": match p.loc { ParamLoc::Path => "path", ParamLoc::Query => "query", ParamLoc::Header => "header" },
194                "required": p.required,
195                "schema": (p.schema)(),
196            })
197        }).collect();
198
199        if let Some(qfn) = rt.spec.query_schema {
200            let mut schema = qfn();
201            harvest_definitions(&mut schema, &mut components);
202            rewrite_refs(&mut schema);
203            if let Some(props) = schema.get("properties").and_then(Value::as_object) {
204                let required: std::collections::HashSet<&str> = schema
205                    .get("required")
206                    .and_then(Value::as_array)
207                    .map(|a| a.iter().filter_map(Value::as_str).collect())
208                    .unwrap_or_default();
209                for (name, prop_schema) in props {
210                    parameters.push(json!({
211                        "name": name,
212                        "in": "query",
213                        "required": required.contains(name.as_str()),
214                        "schema": prop_schema,
215                    }));
216                }
217            }
218        }
219
220        // ── Hardening attributes → first-class spec surface ─────────────
221        if rt.spec.idempotent_ttl_secs > 0 {
222            parameters.push(json!({
223                "name": "Idempotency-Key",
224                "in": "header",
225                "required": false,
226                "description": format!(
227                    "Optional client-supplied key for safe retries. A repeat \
228                     within {}s replays the stored response \
229                     (`Idempotency-Replayed: true`); a concurrent duplicate \
230                     gets 409.", rt.spec.idempotent_ttl_secs),
231                "schema": { "type": "string", "maxLength": 255 },
232            }));
233        }
234
235        // ── Default success status code (POST → 201, else 200) ──────────
236        let status = rt
237            .spec
238            .status_code
239            .unwrap_or_else(|| default_success(rt.method));
240        let success_desc = if status == 204 {
241            "No Content"
242        } else {
243            "Successful response"
244        };
245        let success = match (rt.spec.response_schema, status == 204) {
246            (Some(f), false) => {
247                let mut s = f();
248                harvest_definitions(&mut s, &mut components);
249                rewrite_refs(&mut s);
250                json!({
251                    "description": success_desc,
252                    "content": { "application/json": { "schema": s } }
253                })
254            }
255            _ => json!({ "description": success_desc }),
256        };
257
258        // Success-response headers contributed by the hardening attributes.
259        let mut success = success;
260        {
261            let mut headers = Map::new();
262            if rt.spec.idempotent_ttl_secs > 0 {
263                headers.insert("Idempotency-Replayed".into(), json!({
264                    "description": "Present (true) when this response was replayed from the idempotency store.",
265                    "schema": { "type": "string", "enum": ["true"] },
266                }));
267            }
268            if !rt.spec.sunset.is_empty() {
269                headers.insert(
270                    "Deprecation".into(),
271                    json!({
272                        "description": "RFC 8594 — this endpoint is deprecated.",
273                        "schema": { "type": "string", "enum": ["true"] },
274                    }),
275                );
276                headers.insert(
277                    "Sunset".into(),
278                    json!({
279                        "description": "RFC 8594 — date after which this endpoint may be removed.",
280                        "schema": { "type": "string" },
281                    }),
282                );
283            }
284            if !headers.is_empty() {
285                success["headers"] = Value::Object(headers);
286            }
287        }
288
289        // ── Default error responses all point at ProblemDetails ─────────
290        let problem_ref = json!({
291            "description": "",
292            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } }
293        });
294        let mut responses = Map::new();
295        responses.insert(status.to_string(), success);
296        let forbidden_desc = if rt.spec.policies.is_empty() {
297            "Forbidden".to_string()
298        } else {
299            format!(
300                "Forbidden — requires ABAC policies: {} (default-deny; rules hot-reload at runtime)",
301                rt.spec.policies.join(", "),
302            )
303        };
304        let mut error_codes: Vec<(String, String)> = vec![
305            ("400".into(), "Bad request".into()),
306            ("401".into(), "Unauthorized".into()),
307            ("403".into(), forbidden_desc),
308            ("404".into(), "Not found".into()),
309            ("422".into(), "Validation failed".into()),
310            ("500".into(), "Internal error".into()),
311        ];
312        if rt.spec.idempotent_ttl_secs > 0 {
313            error_codes.push((
314                "409".into(),
315                "Conflict — a request with this Idempotency-Key is already in flight".into(),
316            ));
317        }
318        if rt.spec.timeout_ms > 0 {
319            error_codes.push((
320                "504".into(),
321                format!(
322                    "Gateway Timeout — handler exceeded its {}ms deadline (work cancelled{})",
323                    rt.spec.timeout_ms,
324                    if rt.spec.transactional {
325                        "; transaction rolled back"
326                    } else {
327                        ""
328                    },
329                ),
330            ));
331        }
332        for (code, desc) in error_codes {
333            let mut entry = problem_ref.clone();
334            entry["description"] = Value::String(desc);
335            responses.insert(code, entry);
336        }
337
338        let tags: Vec<&'static str> = if rt.spec.tags.is_empty() {
339            default_tags_from_path(rt.path)
340        } else {
341            rt.spec.tags.to_vec()
342        };
343
344        let security: Vec<Value> = rt
345            .spec
346            .security
347            .iter()
348            .map(|s| {
349                // {"<scheme>": []}  — empty scope list, OK for non-OAuth.
350                let mut m = Map::new();
351                m.insert((*s).into(), Value::Array(vec![]));
352                Value::Object(m)
353            })
354            .collect();
355
356        let mut op = json!({
357            "summary": rt.spec.summary,
358            "operationId": rt.spec.operation_id,
359            "tags": tags,
360            "parameters": parameters,
361            "responses": responses,
362            // A sunset date implies deprecation even without the route flag.
363            "deprecated": rt.spec.deprecated || !rt.spec.sunset.is_empty(),
364        });
365
366        // Vendor extensions: machine-readable mirror of the hardening stack,
367        // so gateways/linters/SDK generators can reason about behaviour.
368        if !rt.spec.api_version.is_empty() {
369            op["x-api-version"] = Value::String(rt.spec.api_version.into());
370        }
371        if !rt.spec.sunset.is_empty() {
372            op["x-sunset"] = Value::String(rt.spec.sunset.into());
373        }
374        if rt.spec.idempotent_ttl_secs > 0 {
375            op["x-arcly-idempotent-ttl-secs"] = json!(rt.spec.idempotent_ttl_secs);
376        }
377        if !rt.spec.policies.is_empty() {
378            op["x-arcly-policies"] = json!(rt.spec.policies);
379        }
380        if !rt.spec.audit_action.is_empty() {
381            op["x-arcly-audit"] = json!({
382                "action": rt.spec.audit_action,
383                "resource": rt.spec.audit_resource,
384            });
385        }
386        if rt.spec.timeout_ms > 0 {
387            op["x-arcly-timeout-ms"] = json!(rt.spec.timeout_ms);
388        }
389        if rt.spec.transactional {
390            op["x-arcly-transactional"] = Value::Bool(true);
391        }
392        if !rt.spec.mask_fields.is_empty() {
393            op["x-arcly-masked-fields"] = json!(rt.spec.mask_fields);
394        }
395        if !rt.spec.description.is_empty() {
396            op["description"] = Value::String(rt.spec.description.to_string());
397        }
398        if !security.is_empty() {
399            op["security"] = Value::Array(security);
400        }
401
402        if rt.spec.has_body {
403            let schema_val = match rt.spec.body_schema {
404                Some(f) => {
405                    let mut s = f();
406                    harvest_definitions(&mut s, &mut components);
407                    rewrite_refs(&mut s);
408                    s
409                }
410                None => json!({ "type": "object" }),
411            };
412            let mut content = serde_json::Map::new();
413            content.insert(
414                rt.spec.consumes.to_string(),
415                json!({ "schema": schema_val }),
416            );
417            op.as_object_mut().unwrap().insert(
418                "requestBody".into(),
419                json!({ "required": true, "content": content }),
420            );
421        }
422
423        let method_key = method_str(rt.method);
424        entry.as_object_mut().unwrap().insert(method_key.into(), op);
425    }
426
427    // ── /info block ──
428    let mut info_obj = Map::new();
429    info_obj.insert(
430        "title".into(),
431        Value::String(info.title.clone().into_owned()),
432    );
433    info_obj.insert(
434        "version".into(),
435        Value::String(info.version.clone().into_owned()),
436    );
437    if let Some(d) = &info.description {
438        info_obj.insert("description".into(), Value::String(d.clone().into_owned()));
439    }
440    if let Some(t) = &info.terms_of_service {
441        info_obj.insert(
442            "termsOfService".into(),
443            Value::String(t.clone().into_owned()),
444        );
445    }
446    let mut contact = Map::new();
447    if let Some(n) = &info.contact_name {
448        contact.insert("name".into(), Value::String(n.clone().into_owned()));
449    }
450    if let Some(u) = &info.contact_url {
451        contact.insert("url".into(), Value::String(u.clone().into_owned()));
452    }
453    if let Some(e) = &info.contact_email {
454        contact.insert("email".into(), Value::String(e.clone().into_owned()));
455    }
456    if !contact.is_empty() {
457        info_obj.insert("contact".into(), Value::Object(contact));
458    }
459    let mut license = Map::new();
460    if let Some(n) = &info.license_name {
461        license.insert("name".into(), Value::String(n.clone().into_owned()));
462    }
463    if let Some(u) = &info.license_url {
464        license.insert("url".into(), Value::String(u.clone().into_owned()));
465    }
466    if !license.is_empty() {
467        info_obj.insert("license".into(), Value::Object(license));
468    }
469
470    let servers: Vec<Value> = info
471        .servers
472        .iter()
473        .map(|(url, desc)| {
474            let mut m = Map::new();
475            m.insert("url".into(), Value::String(url.clone().into_owned()));
476            if !desc.is_empty() {
477                m.insert(
478                    "description".into(),
479                    Value::String(desc.clone().into_owned()),
480                );
481            }
482            Value::Object(m)
483        })
484        .collect();
485
486    let tags_section: Vec<Value> = info
487        .tag_descriptions
488        .iter()
489        .map(|(name, desc)| json!({ "name": name.as_ref(), "description": desc.as_ref() }))
490        .collect();
491
492    let mut components_obj = Map::new();
493    components_obj.insert("schemas".into(), Value::Object(components));
494    if !info.security_schemes.is_empty() {
495        let mut schemes: BTreeMap<String, Value> = BTreeMap::new();
496        for (name, sch) in &info.security_schemes {
497            schemes.insert(name.clone().into_owned(), sch.to_value());
498        }
499        components_obj.insert(
500            "securitySchemes".into(),
501            Value::Object(schemes.into_iter().collect()),
502        );
503    }
504
505    let mut doc = Map::new();
506    doc.insert("openapi".into(), Value::String(OPENAPI_VERSION.into()));
507    doc.insert("info".into(), Value::Object(info_obj));
508    if !servers.is_empty() {
509        doc.insert("servers".into(), Value::Array(servers));
510    }
511    if !tags_section.is_empty() {
512        doc.insert("tags".into(), Value::Array(tags_section));
513    }
514    doc.insert("paths".into(), Value::Object(paths));
515    doc.insert("components".into(), Value::Object(components_obj));
516    Value::Object(doc)
517}
518
519fn problem_details_schema() -> Value {
520    json!({
521        "type": "object",
522        "description": "RFC 7807 ProblemDetails",
523        "required": ["type", "title", "status", "detail"],
524        "properties": {
525            "type":   { "type": "string" },
526            "title":  { "type": "string" },
527            "status": { "type": "integer", "minimum": 100, "maximum": 599 },
528            "detail": { "type": "string" },
529            "errors": {
530                "type": "array",
531                "items": {
532                    "type": "object",
533                    "required": ["field", "code", "message"],
534                    "properties": {
535                        "field":   { "type": "string" },
536                        "code":    { "type": "string" },
537                        "message": { "type": "string" }
538                    }
539                }
540            }
541        }
542    })
543}
544
545fn default_success(m: HttpMethod) -> u16 {
546    match m {
547        HttpMethod::POST => 201,
548        HttpMethod::DELETE => 204,
549        _ => 200,
550    }
551}
552
553fn default_tags_from_path(p: &'static str) -> Vec<&'static str> {
554    // Route paths submitted via inventory are always 'static literals from the
555    // macro — so slicing them produces a valid 'static subslice.
556    let seg = p.trim_start_matches('/').split('/').next().unwrap_or("");
557    if seg.is_empty() || seg.starts_with(':') {
558        Vec::new()
559    } else {
560        vec![seg]
561    }
562}
563
564/// Move every entry from a schemars `definitions` map into the supplied
565/// `components/schemas` registry, then strip it from the input schema.
566fn harvest_definitions(schema: &mut Value, components: &mut Map<String, Value>) {
567    let Value::Object(map) = schema else { return };
568    if let Some(Value::Object(defs)) = map.remove("definitions") {
569        for (k, mut v) in defs {
570            rewrite_refs(&mut v);
571            components.entry(k).or_insert(v);
572        }
573    }
574}
575
576/// Rewrite every `$ref: "#/definitions/X"` to `"#/components/schemas/X"`.
577fn rewrite_refs(v: &mut Value) {
578    match v {
579        Value::Object(map) => {
580            if let Some(Value::String(s)) = map.get_mut("$ref") {
581                if let Some(name) = s.strip_prefix("#/definitions/") {
582                    *s = format!("#/components/schemas/{name}");
583                }
584            }
585            for (_, child) in map.iter_mut() {
586                rewrite_refs(child);
587            }
588        }
589        Value::Array(arr) => {
590            for child in arr {
591                rewrite_refs(child);
592            }
593        }
594        _ => {}
595    }
596}
597
598#[inline]
599fn method_str(m: HttpMethod) -> &'static str {
600    match m {
601        HttpMethod::GET => "get",
602        HttpMethod::POST => "post",
603        HttpMethod::PUT => "put",
604        HttpMethod::DELETE => "delete",
605        HttpMethod::PATCH => "patch",
606    }
607}
608
609fn axum_to_openapi_path(p: &str) -> String {
610    // Canonical form: no trailing slash. The router serves both
611    // `/products` and `/products/`, but generated clients should call the
612    // bare form — advertising only `/products/` made SDKs needlessly
613    // slash-sensitive.
614    let p = if p.len() > 1 {
615        p.trim_end_matches('/')
616    } else {
617        p
618    };
619    let mut out = String::with_capacity(p.len() + 4);
620    let mut chars = p.chars().peekable();
621    while let Some(c) = chars.next() {
622        if c == ':' {
623            out.push('{');
624            while let Some(&n) = chars.peek() {
625                if n == '/' {
626                    break;
627                }
628                out.push(n);
629                chars.next();
630            }
631            out.push('}');
632        } else {
633            out.push(c);
634        }
635    }
636    out
637}
638
639/// Minimal Swagger UI page (CDN-hosted assets). No build step, no extra crate.
640pub const SWAGGER_UI_HTML: &str = r##"<!doctype html>
641<html lang="en">
642<head>
643  <meta charset="utf-8" />
644  <title>arcly-http — API docs</title>
645  <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
646</head>
647<body>
648  <div id="swagger-ui"></div>
649  <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
650  <script>
651    window.onload = () => {
652      window.ui = SwaggerUIBundle({
653        url: '/openapi.json',
654        dom_id: '#swagger-ui',
655        deepLinking: true,
656        persistAuthorization: true,
657        layout: 'BaseLayout',
658      });
659    };
660  </script>
661</body>
662</html>
663"##;
664
665#[cfg(test)]
666mod path_tests {
667    use super::axum_to_openapi_path;
668
669    #[test]
670    fn spec_paths_are_canonical() {
671        assert_eq!(axum_to_openapi_path("/products/"), "/products");
672        assert_eq!(axum_to_openapi_path("/products"), "/products");
673        assert_eq!(axum_to_openapi_path("/users/:id"), "/users/{id}");
674        assert_eq!(axum_to_openapi_path("/"), "/");
675    }
676}