Skip to main content

arcly_http_core/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/// `App::launch_with_info` (in the `arcly-http` facade).
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/// Every `operationId` in the document that is used by more than one operation,
166/// with the operations that share it (`"METHOD /path"`), sorted for stable
167/// output. Empty when the document is spec-valid.
168///
169/// OpenAPI requires operationIds to be unique across the whole document; client
170/// generators key their emitted method names off them, so a duplicate makes the
171/// generated client fail to compile. `#[Controller]` routes get a
172/// controller-qualified id by default, which keeps this empty by construction —
173/// duplicates now only arise from hand-written `operation_id("…")` values.
174///
175/// Exposed so an app can assert on it in a test (or a CI guard) instead of
176/// discovering the collision downstream in its generated client.
177pub fn duplicate_operation_ids(spec: &Value) -> Vec<(String, Vec<String>)> {
178    let mut by_id: std::collections::BTreeMap<String, Vec<String>> = Default::default();
179    let Some(paths) = spec.get("paths").and_then(Value::as_object) else {
180        return Vec::new();
181    };
182    for (path, ops) in paths {
183        let Some(ops) = ops.as_object() else { continue };
184        for (method, op) in ops {
185            if let Some(id) = op.get("operationId").and_then(Value::as_str) {
186                by_id
187                    .entry(id.to_owned())
188                    .or_default()
189                    .push(format!("{} {path}", method.to_uppercase()));
190            }
191        }
192    }
193    by_id.retain(|_, ops| ops.len() > 1);
194    by_id
195        .into_iter()
196        .map(|(id, mut ops)| {
197            ops.sort();
198            (id, ops)
199        })
200        .collect()
201}
202
203fn warn_on_duplicate_operation_ids(paths: &Map<String, Value>) {
204    let spec = json!({ "paths": Value::Object(paths.clone()) });
205    for (id, ops) in duplicate_operation_ids(&spec) {
206        tracing::warn!(
207            operation_id = %id,
208            operations = %ops.join(", "),
209            "duplicate operationId — the OpenAPI document is invalid and client \
210             generators will emit colliding method names; set operation_id(\"…\") \
211             on all but one of these routes",
212        );
213    }
214}
215
216/// `allowed_controllers = None` means "include every inventory-registered route"
217/// (legacy / no module scoping). `Some(set)` filters to controllers reachable
218/// from the root module's import DAG.
219pub fn build_spec_filtered(
220    info: &OpenApiInfo,
221    allowed_controllers: Option<&std::collections::HashSet<&'static str>>,
222) -> Value {
223    let mut paths: Map<String, Value> = Map::new();
224    let mut components: Map<String, Value> = Map::new();
225
226    // Always emit ProblemDetails — it's the canonical 4xx/5xx body shape.
227    components.insert("ProblemDetails".into(), problem_details_schema());
228
229    for rt in inventory::iter::<&'static RouteDescriptor> {
230        if let Some(allowed) = allowed_controllers {
231            if !rt.controller.is_empty() && !allowed.contains(rt.controller) {
232                continue;
233            }
234        }
235        let oapi_path = axum_to_openapi_path(rt.path);
236        let entry = paths
237            .entry(oapi_path)
238            .or_insert_with(|| Value::Object(Map::new()));
239
240        // ── Parameters: path + header + flattened query DTO fields ──────
241        let mut parameters: Vec<Value> = rt.spec.params.iter().map(|p| {
242            json!({
243                "name": p.name,
244                "in": match p.loc { ParamLoc::Path => "path", ParamLoc::Query => "query", ParamLoc::Header => "header" },
245                "required": p.required,
246                "schema": (p.schema)(),
247            })
248        }).collect();
249
250        if let Some(qfn) = rt.spec.query_schema {
251            let mut schema = qfn();
252            harvest_definitions(&mut schema, &mut components);
253            rewrite_refs(&mut schema);
254            if let Some(props) = schema.get("properties").and_then(Value::as_object) {
255                let required: std::collections::HashSet<&str> = schema
256                    .get("required")
257                    .and_then(Value::as_array)
258                    .map(|a| a.iter().filter_map(Value::as_str).collect())
259                    .unwrap_or_default();
260                for (name, prop_schema) in props {
261                    parameters.push(json!({
262                        "name": name,
263                        "in": "query",
264                        "required": required.contains(name.as_str()),
265                        "schema": prop_schema,
266                    }));
267                }
268            }
269        }
270
271        // ── Hardening attributes → first-class spec surface ─────────────
272        if rt.spec.idempotent_ttl_secs > 0 {
273            parameters.push(json!({
274                "name": "Idempotency-Key",
275                "in": "header",
276                "required": false,
277                "description": format!(
278                    "Optional client-supplied key for safe retries. A repeat \
279                     within {}s replays the stored response \
280                     (`Idempotency-Replayed: true`); a concurrent duplicate \
281                     gets 409.", rt.spec.idempotent_ttl_secs),
282                "schema": { "type": "string", "maxLength": 255 },
283            }));
284        }
285
286        // ── Default success status code (POST → 201, else 200) ──────────
287        let status = rt
288            .spec
289            .status_code
290            .unwrap_or_else(|| default_success(rt.method));
291        let success_desc = if status == 204 {
292            "No Content"
293        } else {
294            "Successful response"
295        };
296        let success = match (rt.spec.response_schema, status == 204) {
297            (Some(f), false) => {
298                let mut s = f();
299                harvest_definitions(&mut s, &mut components);
300                rewrite_refs(&mut s);
301                json!({
302                    "description": success_desc,
303                    "content": { "application/json": { "schema": s } }
304                })
305            }
306            _ => json!({ "description": success_desc }),
307        };
308
309        // Success-response headers contributed by the hardening attributes.
310        let mut success = success;
311        {
312            let mut headers = Map::new();
313            if rt.spec.idempotent_ttl_secs > 0 {
314                headers.insert("Idempotency-Replayed".into(), json!({
315                    "description": "Present (true) when this response was replayed from the idempotency store.",
316                    "schema": { "type": "string", "enum": ["true"] },
317                }));
318            }
319            if !rt.spec.sunset.is_empty() {
320                headers.insert(
321                    "Deprecation".into(),
322                    json!({
323                        "description": "RFC 8594 — this endpoint is deprecated.",
324                        "schema": { "type": "string", "enum": ["true"] },
325                    }),
326                );
327                headers.insert(
328                    "Sunset".into(),
329                    json!({
330                        "description": "RFC 8594 — date after which this endpoint may be removed.",
331                        "schema": { "type": "string" },
332                    }),
333                );
334            }
335            if !headers.is_empty() {
336                success["headers"] = Value::Object(headers);
337            }
338        }
339
340        // ── Default error responses all point at ProblemDetails ─────────
341        let problem_ref = json!({
342            "description": "",
343            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } }
344        });
345        let mut responses = Map::new();
346        responses.insert(status.to_string(), success);
347        let forbidden_desc = if rt.spec.policies.is_empty() {
348            "Forbidden".to_string()
349        } else {
350            format!(
351                "Forbidden — requires ABAC policies: {} (default-deny; rules hot-reload at runtime)",
352                rt.spec.policies.join(", "),
353            )
354        };
355        let mut error_codes: Vec<(String, String)> = vec![
356            ("400".into(), "Bad request".into()),
357            ("401".into(), "Unauthorized".into()),
358            ("403".into(), forbidden_desc),
359            ("404".into(), "Not found".into()),
360            ("422".into(), "Validation failed".into()),
361            ("500".into(), "Internal error".into()),
362        ];
363        if rt.spec.idempotent_ttl_secs > 0 {
364            error_codes.push((
365                "409".into(),
366                "Conflict — a request with this Idempotency-Key is already in flight".into(),
367            ));
368        }
369        if rt.spec.timeout_ms > 0 {
370            error_codes.push((
371                "504".into(),
372                format!(
373                    "Gateway Timeout — handler exceeded its {}ms deadline (work cancelled{})",
374                    rt.spec.timeout_ms,
375                    if rt.spec.transactional {
376                        "; transaction rolled back"
377                    } else {
378                        ""
379                    },
380                ),
381            ));
382        }
383        for (code, desc) in error_codes {
384            let mut entry = problem_ref.clone();
385            entry["description"] = Value::String(desc);
386            responses.insert(code, entry);
387        }
388
389        let tags: Vec<&'static str> = if rt.spec.tags.is_empty() {
390            default_tags_from_path(rt.path)
391        } else {
392            rt.spec.tags.to_vec()
393        };
394
395        let security: Vec<Value> = rt
396            .spec
397            .security
398            .iter()
399            .map(|s| {
400                // {"<scheme>": []}  — empty scope list, OK for non-OAuth.
401                let mut m = Map::new();
402                m.insert((*s).into(), Value::Array(vec![]));
403                Value::Object(m)
404            })
405            .collect();
406
407        let mut op = json!({
408            "summary": rt.spec.summary,
409            "operationId": rt.spec.operation_id,
410            "tags": tags,
411            "parameters": parameters,
412            "responses": responses,
413            // A sunset date implies deprecation even without the route flag.
414            "deprecated": rt.spec.deprecated || !rt.spec.sunset.is_empty(),
415        });
416
417        // Vendor extensions: machine-readable mirror of the hardening stack,
418        // so gateways/linters/SDK generators can reason about behaviour.
419        if !rt.spec.api_version.is_empty() {
420            op["x-api-version"] = Value::String(rt.spec.api_version.into());
421        }
422        if !rt.spec.sunset.is_empty() {
423            op["x-sunset"] = Value::String(rt.spec.sunset.into());
424        }
425        if rt.spec.idempotent_ttl_secs > 0 {
426            op["x-arcly-idempotent-ttl-secs"] = json!(rt.spec.idempotent_ttl_secs);
427        }
428        if !rt.spec.policies.is_empty() {
429            op["x-arcly-policies"] = json!(rt.spec.policies);
430        }
431        if !rt.spec.audit_action.is_empty() {
432            op["x-arcly-audit"] = json!({
433                "action": rt.spec.audit_action,
434                "resource": rt.spec.audit_resource,
435            });
436        }
437        if rt.spec.timeout_ms > 0 {
438            op["x-arcly-timeout-ms"] = json!(rt.spec.timeout_ms);
439        }
440        if rt.spec.transactional {
441            op["x-arcly-transactional"] = Value::Bool(true);
442        }
443        if !rt.spec.mask_fields.is_empty() {
444            op["x-arcly-masked-fields"] = json!(rt.spec.mask_fields);
445        }
446        if !rt.spec.description.is_empty() {
447            op["description"] = Value::String(rt.spec.description.to_string());
448        }
449        if !security.is_empty() {
450            op["security"] = Value::Array(security);
451        }
452
453        if rt.spec.has_body {
454            let schema_val = match rt.spec.body_schema {
455                Some(f) => {
456                    let mut s = f();
457                    harvest_definitions(&mut s, &mut components);
458                    rewrite_refs(&mut s);
459                    s
460                }
461                None => json!({ "type": "object" }),
462            };
463            let mut content = serde_json::Map::new();
464            content.insert(
465                rt.spec.consumes.to_string(),
466                json!({ "schema": schema_val }),
467            );
468            op.as_object_mut().unwrap().insert(
469                "requestBody".into(),
470                json!({ "required": true, "content": content }),
471            );
472        }
473
474        let method_key = method_str(rt.method);
475        entry.as_object_mut().unwrap().insert(method_key.into(), op);
476    }
477
478    warn_on_duplicate_operation_ids(&paths);
479
480    // ── /info block ──
481    let mut info_obj = Map::new();
482    info_obj.insert(
483        "title".into(),
484        Value::String(info.title.clone().into_owned()),
485    );
486    info_obj.insert(
487        "version".into(),
488        Value::String(info.version.clone().into_owned()),
489    );
490    if let Some(d) = &info.description {
491        info_obj.insert("description".into(), Value::String(d.clone().into_owned()));
492    }
493    if let Some(t) = &info.terms_of_service {
494        info_obj.insert(
495            "termsOfService".into(),
496            Value::String(t.clone().into_owned()),
497        );
498    }
499    let mut contact = Map::new();
500    if let Some(n) = &info.contact_name {
501        contact.insert("name".into(), Value::String(n.clone().into_owned()));
502    }
503    if let Some(u) = &info.contact_url {
504        contact.insert("url".into(), Value::String(u.clone().into_owned()));
505    }
506    if let Some(e) = &info.contact_email {
507        contact.insert("email".into(), Value::String(e.clone().into_owned()));
508    }
509    if !contact.is_empty() {
510        info_obj.insert("contact".into(), Value::Object(contact));
511    }
512    let mut license = Map::new();
513    if let Some(n) = &info.license_name {
514        license.insert("name".into(), Value::String(n.clone().into_owned()));
515    }
516    if let Some(u) = &info.license_url {
517        license.insert("url".into(), Value::String(u.clone().into_owned()));
518    }
519    if !license.is_empty() {
520        info_obj.insert("license".into(), Value::Object(license));
521    }
522
523    let servers: Vec<Value> = info
524        .servers
525        .iter()
526        .map(|(url, desc)| {
527            let mut m = Map::new();
528            m.insert("url".into(), Value::String(url.clone().into_owned()));
529            if !desc.is_empty() {
530                m.insert(
531                    "description".into(),
532                    Value::String(desc.clone().into_owned()),
533                );
534            }
535            Value::Object(m)
536        })
537        .collect();
538
539    let tags_section: Vec<Value> = info
540        .tag_descriptions
541        .iter()
542        .map(|(name, desc)| json!({ "name": name.as_ref(), "description": desc.as_ref() }))
543        .collect();
544
545    let mut components_obj = Map::new();
546    components_obj.insert("schemas".into(), Value::Object(components));
547    if !info.security_schemes.is_empty() {
548        let mut schemes: BTreeMap<String, Value> = BTreeMap::new();
549        for (name, sch) in &info.security_schemes {
550            schemes.insert(name.clone().into_owned(), sch.to_value());
551        }
552        components_obj.insert(
553            "securitySchemes".into(),
554            Value::Object(schemes.into_iter().collect()),
555        );
556    }
557
558    let mut doc = Map::new();
559    doc.insert("openapi".into(), Value::String(OPENAPI_VERSION.into()));
560    doc.insert("info".into(), Value::Object(info_obj));
561    if !servers.is_empty() {
562        doc.insert("servers".into(), Value::Array(servers));
563    }
564    if !tags_section.is_empty() {
565        doc.insert("tags".into(), Value::Array(tags_section));
566    }
567    doc.insert("paths".into(), Value::Object(paths));
568    doc.insert("components".into(), Value::Object(components_obj));
569    Value::Object(doc)
570}
571
572fn problem_details_schema() -> Value {
573    json!({
574        "type": "object",
575        "description": "RFC 7807 ProblemDetails",
576        "required": ["type", "title", "status", "detail"],
577        "properties": {
578            "type":   { "type": "string" },
579            "title":  { "type": "string" },
580            "status": { "type": "integer", "minimum": 100, "maximum": 599 },
581            "detail": { "type": "string" },
582            "errors": {
583                "type": "array",
584                "items": {
585                    "type": "object",
586                    "required": ["field", "code", "message"],
587                    "properties": {
588                        "field":   { "type": "string" },
589                        "code":    { "type": "string" },
590                        "message": { "type": "string" }
591                    }
592                }
593            }
594        }
595    })
596}
597
598fn default_success(m: HttpMethod) -> u16 {
599    match m {
600        HttpMethod::POST => 201,
601        HttpMethod::DELETE => 204,
602        _ => 200,
603    }
604}
605
606fn default_tags_from_path(p: &'static str) -> Vec<&'static str> {
607    // Route paths submitted via inventory are always 'static literals from the
608    // macro — so slicing them produces a valid 'static subslice.
609    let seg = p.trim_start_matches('/').split('/').next().unwrap_or("");
610    if seg.is_empty() || seg.starts_with(':') {
611        Vec::new()
612    } else {
613        vec![seg]
614    }
615}
616
617/// Move every entry from a schemars `definitions` map into the supplied
618/// `components/schemas` registry, then strip it from the input schema.
619fn harvest_definitions(schema: &mut Value, components: &mut Map<String, Value>) {
620    let Value::Object(map) = schema else { return };
621    if let Some(Value::Object(defs)) = map.remove("definitions") {
622        for (k, mut v) in defs {
623            rewrite_refs(&mut v);
624            components.entry(k).or_insert(v);
625        }
626    }
627}
628
629/// Rewrite every `$ref: "#/definitions/X"` to `"#/components/schemas/X"`.
630fn rewrite_refs(v: &mut Value) {
631    match v {
632        Value::Object(map) => {
633            if let Some(Value::String(s)) = map.get_mut("$ref") {
634                if let Some(name) = s.strip_prefix("#/definitions/") {
635                    *s = format!("#/components/schemas/{name}");
636                }
637            }
638            for (_, child) in map.iter_mut() {
639                rewrite_refs(child);
640            }
641        }
642        Value::Array(arr) => {
643            for child in arr {
644                rewrite_refs(child);
645            }
646        }
647        _ => {}
648    }
649}
650
651#[inline]
652fn method_str(m: HttpMethod) -> &'static str {
653    match m {
654        HttpMethod::GET => "get",
655        HttpMethod::POST => "post",
656        HttpMethod::PUT => "put",
657        HttpMethod::DELETE => "delete",
658        HttpMethod::PATCH => "patch",
659    }
660}
661
662fn axum_to_openapi_path(p: &str) -> String {
663    // Canonical form: no trailing slash. The router serves both
664    // `/products` and `/products/`, but generated clients should call the
665    // bare form — advertising only `/products/` made SDKs needlessly
666    // slash-sensitive.
667    let p = if p.len() > 1 {
668        p.trim_end_matches('/')
669    } else {
670        p
671    };
672    let mut out = String::with_capacity(p.len() + 4);
673    let mut chars = p.chars().peekable();
674    while let Some(c) = chars.next() {
675        if c == ':' {
676            out.push('{');
677            while let Some(&n) = chars.peek() {
678                if n == '/' {
679                    break;
680                }
681                out.push(n);
682                chars.next();
683            }
684            out.push('}');
685        } else {
686            out.push(c);
687        }
688    }
689    out
690}
691
692/// Minimal Swagger UI page (CDN-hosted assets). No build step, no extra crate.
693pub const SWAGGER_UI_HTML: &str = r##"<!doctype html>
694<html lang="en">
695<head>
696  <meta charset="utf-8" />
697  <title>arcly-http — API docs</title>
698  <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
699</head>
700<body>
701  <div id="swagger-ui"></div>
702  <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
703  <script>
704    window.onload = () => {
705      window.ui = SwaggerUIBundle({
706        url: '/openapi.json',
707        dom_id: '#swagger-ui',
708        deepLinking: true,
709        persistAuthorization: true,
710        layout: 'BaseLayout',
711      });
712    };
713  </script>
714</body>
715</html>
716"##;
717
718#[cfg(test)]
719mod path_tests {
720    use super::axum_to_openapi_path;
721
722    #[test]
723    fn spec_paths_are_canonical() {
724        assert_eq!(axum_to_openapi_path("/products/"), "/products");
725        assert_eq!(axum_to_openapi_path("/products"), "/products");
726        assert_eq!(axum_to_openapi_path("/users/:id"), "/users/{id}");
727        assert_eq!(axum_to_openapi_path("/"), "/");
728    }
729}