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