Skip to main content

actus_server/
openapi.rs

1//! OpenAPI 3.1 doc generation. Behind the `openapi` feature.
2//!
3//! Walk a built [`Router`] and emit a `serde_json::Value`
4//! shaped like an OpenAPI 3.1 document. The generator pulls structural data
5//! directly from the route tree — every `(mount_path, RouteDef)` pair the
6//! `#[controller]` and `app_routes!` macros recorded — so the spec reflects
7//! the code, not a hand-maintained YAML file.
8//!
9//! ```ignore
10//! use actus::prelude::*;
11//! use actus::openapi;
12//!
13//! let router = init().await?;
14//! let spec = openapi::generate(
15//!     &router,
16//!     &openapi::Options::new("My API", "1.0.0").description("…"),
17//!     // Document only `/api/...` — hide internal mounts.
18//!     |mount| mount.starts_with("api/"),
19//! );
20//! println!("{}", openapi::to_string_pretty(&spec));
21//! ```
22//!
23//! ## Scope
24//!
25//! * **Mapping is structural, not semantic.** Verbs, path params, query
26//!   params (typed, with defaults, optional `Vec<String>`), JSON / Bytes
27//!   request bodies, and the handler's `///` doc as summary + description.
28//!   No response-body schema is inferred — handlers can return anything,
29//!   and the framework's `Reply` shape doesn't carry that information.
30//!   Operations get a `default` response with a generic description; if you
31//!   need richer responses, post-process the generated `Value`.
32//! * **Trailing rest parameters** (`{...name}`) don't have a clean OpenAPI
33//!   form — the spec's path templating is a single segment per `{name}`.
34//!   The generator strips the `...` and adds `x-actus-rest-param: true`
35//!   plus a `description` noting "captures the trailing path (slashes
36//!   included)" on the parameter, so clients and tooling can recognise it
37//!   if they want to.
38//! * **`DEFAULT_VERBS` routes** (no verb prefix in `routes!` — accepts
39//!   `GET` and `POST`) emit *two* operations on the path, one per verb.
40//! * **Route selection.** The `filter` predicate runs on the mount path
41//!   (the controller's prefix, no leading slash, no trailing slash). A
42//!   route is included iff its controller's mount passes the predicate.
43//!   The flexible form is a closure; the most common shape is
44//!   `|mount| mount.starts_with("api/")`.
45
46use actus_controller::{DEFAULT_VERBS, ParamDefault, ParamSource, ParamType, RouteDef, Verb};
47use serde_json::{Map, Value, json};
48
49use crate::router::Router;
50
51/// Top-level options for the generated spec. The OpenAPI `info` object plus
52/// an optional `servers` list.
53#[derive(Clone, Debug)]
54pub struct Options {
55    /// The API title (OpenAPI `info.title`).
56    pub title: String,
57    /// The API version (OpenAPI `info.version`).
58    pub version: String,
59    /// An optional API description (OpenAPI `info.description`).
60    pub description: Option<String>,
61    /// The base URLs the API is served at (OpenAPI `servers`).
62    pub servers: Vec<ServerInfo>,
63}
64
65/// One entry in the OpenAPI `servers` array — a base URL the API is
66/// reachable at, plus an optional human description.
67#[derive(Clone, Debug)]
68pub struct ServerInfo {
69    /// The server base URL (e.g. `https://api.example.com`).
70    pub url: String,
71    /// An optional human-readable description of this server entry.
72    pub description: Option<String>,
73}
74
75impl Options {
76    /// New `Options` with the given `info.title` and `info.version`. Both
77    /// are required by the OpenAPI 3.1 spec.
78    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
79        Self {
80            title: title.into(),
81            version: version.into(),
82            description: None,
83            servers: Vec::new(),
84        }
85    }
86
87    /// Set `info.description`.
88    pub fn description(mut self, description: impl Into<String>) -> Self {
89        self.description = Some(description.into());
90        self
91    }
92
93    /// Add an entry to the `servers` array.
94    pub fn server(
95        mut self,
96        url: impl Into<String>,
97        description: Option<impl Into<String>>,
98    ) -> Self {
99        self.servers.push(ServerInfo {
100            url: url.into(),
101            description: description.map(Into::into),
102        });
103        self
104    }
105}
106
107/// Walk `router` and emit an OpenAPI 3.1 `Value`. `filter` is consulted with
108/// the mount path of each controller (no leading or trailing slash); only
109/// routes from controllers whose mount passes are included.
110///
111/// See the [module docs](self) for the mapping conventions and the
112/// limitations on rest parameters / response schemas.
113pub fn generate<F>(router: &Router, options: &Options, filter: F) -> Value
114where
115    F: Fn(&str) -> bool,
116{
117    let mut paths: Map<String, Value> = Map::new();
118
119    for (mount, route) in router.routes() {
120        if !filter(mount.as_str()) {
121            continue;
122        }
123        let path = compose_path(&mount, route.pattern);
124        let methods = methods_for(&route);
125        let entry = paths.entry(path.clone()).or_insert_with(|| json!({}));
126        let entry_obj = entry
127            .as_object_mut()
128            .expect("path entry is always a JSON object");
129        for method in methods {
130            // Last-writer-wins for collisions on the same (path, method) —
131            // two routes with the same shape is a configuration error
132            // (the runtime router uses declaration order to pick one). The
133            // spec only knows the latest.
134            entry_obj.insert(method.to_string(), build_operation(&path, method, &route));
135        }
136    }
137
138    let mut info = Map::new();
139    info.insert("title".into(), Value::String(options.title.clone()));
140    info.insert("version".into(), Value::String(options.version.clone()));
141    if let Some(d) = &options.description {
142        info.insert("description".into(), Value::String(d.clone()));
143    }
144
145    let mut spec = Map::new();
146    spec.insert("openapi".into(), Value::String("3.1.0".into()));
147    spec.insert("info".into(), Value::Object(info));
148    if !options.servers.is_empty() {
149        let servers: Vec<Value> = options
150            .servers
151            .iter()
152            .map(|s| {
153                let mut obj = Map::new();
154                obj.insert("url".into(), Value::String(s.url.clone()));
155                if let Some(d) = &s.description {
156                    obj.insert("description".into(), Value::String(d.clone()));
157                }
158                Value::Object(obj)
159            })
160            .collect();
161        spec.insert("servers".into(), Value::Array(servers));
162    }
163    spec.insert("paths".into(), Value::Object(paths));
164    Value::Object(spec)
165}
166
167/// Pretty-printed JSON of a generated spec. Convenience for serving the
168/// document at e.g. `/openapi.json`.
169pub fn to_string_pretty(value: &Value) -> String {
170    serde_json::to_string_pretty(value).expect("serde_json::Value is always serializable")
171}
172
173// ---------- internal: mapping logic --------------------------------------
174
175/// Join a mount path and a route pattern into the OpenAPI path, replacing
176/// `{...name}` rest tokens with plain `{name}` (the rest-vs-segment
177/// distinction is communicated by `x-actus-rest-param` on the parameter,
178/// since OpenAPI path templating only knows about segment-sized variables).
179fn compose_path(mount: &str, pattern: &str) -> String {
180    let mount = mount.trim_matches('/');
181    let pattern = pattern.trim_matches('/').replace("{...", "{");
182    match (mount.is_empty(), pattern.is_empty()) {
183        (true, true) => "/".to_string(),
184        (true, false) => format!("/{pattern}"),
185        (false, true) => format!("/{mount}"),
186        (false, false) => format!("/{mount}/{pattern}"),
187    }
188}
189
190/// HTTP method names this route advertises as OpenAPI operations.
191fn methods_for(route: &RouteDef) -> Vec<&'static str> {
192    // A "no verb prefix" route accepts the framework's default verb set;
193    // the macro encodes that by reusing the `DEFAULT_VERBS` static slice.
194    // Identity comparison is enough since the macro never constructs a
195    // fresh equivalent slice for the default case.
196    if std::ptr::eq(route.verb, DEFAULT_VERBS) {
197        return DEFAULT_VERBS.iter().map(verb_method).collect();
198    }
199    route.verb.iter().map(verb_method).collect()
200}
201
202fn verb_method(v: &Verb) -> &'static str {
203    match v {
204        Verb::GET => "get",
205        Verb::POST => "post",
206        Verb::PUT => "put",
207        Verb::DELETE => "delete",
208        Verb::PATCH => "patch",
209        Verb::HEAD => "head",
210        Verb::OPTIONS => "options",
211    }
212}
213
214fn build_operation(path: &str, method: &str, route: &RouteDef) -> Value {
215    let mut op = Map::new();
216    op.insert(
217        "operationId".into(),
218        Value::String(operation_id(path, method, route.handler)),
219    );
220
221    if let Some(doc) = route.doc {
222        let trimmed = doc.trim();
223        if !trimmed.is_empty() {
224            // First non-empty line → `summary`; the full doc → `description`.
225            // Matches what most OpenAPI consumers (Swagger UI, redoc) render.
226            let summary = trimmed
227                .lines()
228                .find(|l| !l.trim().is_empty())
229                .map(str::trim)
230                .unwrap_or("");
231            if !summary.is_empty() {
232                op.insert("summary".into(), Value::String(summary.to_string()));
233            }
234            op.insert("description".into(), Value::String(trimmed.to_string()));
235        }
236    }
237
238    let (parameters, request_body) = split_params(route);
239    if !parameters.is_empty() {
240        op.insert("parameters".into(), Value::Array(parameters));
241    }
242    if let Some(body) = request_body {
243        op.insert("requestBody".into(), body);
244    }
245
246    // Every operation needs a `responses` object. Actus's `Reply` shape
247    // doesn't carry response-schema info, so we emit a generic `default`
248    // entry covering "any response not otherwise specified" (RFC 9110 /
249    // OpenAPI 3.1 §responses-object).
250    op.insert(
251        "responses".into(),
252        json!({
253            "default": { "description": "Response from the handler." }
254        }),
255    );
256
257    Value::Object(op)
258}
259
260/// `{sanitized_path}_{handler}_{method}` — guaranteed unique because the
261/// path is unique within the router and the handler/method tokens make the
262/// id readable.
263fn operation_id(path: &str, method: &str, handler: &str) -> String {
264    let sanitized: String = path
265        .chars()
266        .map(|c| match c {
267            '/' => '_',
268            '{' | '}' => '_',
269            other => other,
270        })
271        .collect();
272    let trimmed = sanitized.trim_matches('_');
273    if trimmed.is_empty() {
274        format!("{handler}_{method}")
275    } else {
276        // Collapse runs of `_` so e.g. `_api_users_{id}_` doesn't turn into
277        // `api_users__id__handler_method`.
278        let mut collapsed = String::with_capacity(trimmed.len());
279        let mut prev_us = false;
280        for c in trimmed.chars() {
281            if c == '_' {
282                if !prev_us {
283                    collapsed.push('_');
284                }
285                prev_us = true;
286            } else {
287                collapsed.push(c);
288                prev_us = false;
289            }
290        }
291        format!("{collapsed}_{handler}_{method}")
292    }
293}
294
295/// Split a route's `params` into `(parameters[], Option<requestBody>)`.
296fn split_params(route: &RouteDef) -> (Vec<Value>, Option<Value>) {
297    let mut params: Vec<Value> = Vec::new();
298    let mut body: Option<Value> = None;
299
300    let pattern_has_rest = route.pattern.contains("{...");
301
302    for p in route.params {
303        match p.source {
304            ParamSource::Path => {
305                let mut entry = Map::new();
306                entry.insert("name".into(), Value::String(p.name.to_string()));
307                entry.insert("in".into(), Value::String("path".into()));
308                entry.insert("required".into(), Value::Bool(true));
309                entry.insert("schema".into(), schema_for(p.ty, p.default.as_ref()));
310                // Mark `{...rest}` for clients that want to know.
311                if pattern_has_rest && matches!(p.ty, ParamType::String) {
312                    // Heuristic: the rest param is always typed `String` and
313                    // is the only Path-source `String` declared by a
314                    // rest-containing pattern. (The macro enforces typing.)
315                    if route
316                        .pattern
317                        .contains(&format!("{{...{name}}}", name = p.name))
318                    {
319                        entry.insert("x-actus-rest-param".into(), Value::Bool(true));
320                        entry.insert(
321                            "description".into(),
322                            Value::String(
323                                "Captures the trailing path (slashes included). Not natively \
324                                 representable in OpenAPI path templating; treated as a single \
325                                 segment here."
326                                    .into(),
327                            ),
328                        );
329                    }
330                }
331                params.push(Value::Object(entry));
332            }
333            ParamSource::Query => {
334                let mut entry = Map::new();
335                entry.insert("name".into(), Value::String(p.name.to_string()));
336                entry.insert("in".into(), Value::String("query".into()));
337                // A `Vec<String>` is inherently optional — absent → `[]`,
338                // never a 400 (see `Params::get_all`). Anything else is
339                // required iff no default.
340                let required = !matches!(p.ty, ParamType::StringArray) && p.default.is_none();
341                entry.insert("required".into(), Value::Bool(required));
342                entry.insert("schema".into(), schema_for(p.ty, p.default.as_ref()));
343                params.push(Value::Object(entry));
344            }
345            ParamSource::Body => {
346                // Json / Bytes — wrap into a requestBody. Two body params
347                // shouldn't happen (the macro emits one body param at most),
348                // but if it does we last-writer-wins.
349                let (content_type, schema): (&str, Value) = match p.ty {
350                    ParamType::Json => ("application/json", json!({})),
351                    ParamType::Bytes => (
352                        "application/octet-stream",
353                        json!({ "type": "string", "format": "binary" }),
354                    ),
355                    _ => continue, // shouldn't reach here for other ParamTypes
356                };
357                body = Some(json!({
358                    "required": true,
359                    "content": {
360                        content_type: { "schema": schema }
361                    }
362                }));
363            }
364        }
365    }
366
367    (params, body)
368}
369
370/// OpenAPI 3.1 schema fragment for a `ParamType`, including `default` if
371/// the macro recorded one.
372fn schema_for(ty: ParamType, default: Option<&ParamDefault>) -> Value {
373    let mut schema = base_schema(ty);
374    if let Some(d) = default {
375        let obj = schema
376            .as_object_mut()
377            .expect("base schema is always object");
378        obj.insert("default".into(), default_to_value(d));
379    }
380    schema
381}
382
383fn base_schema(ty: ParamType) -> Value {
384    match ty {
385        ParamType::String => json!({ "type": "string" }),
386        ParamType::Int => json!({ "type": "integer", "format": "int64" }),
387        ParamType::U64 => json!({ "type": "integer", "format": "int64", "minimum": 0 }),
388        ParamType::U32 => json!({ "type": "integer", "format": "int32", "minimum": 0 }),
389        ParamType::F64 => json!({ "type": "number" }),
390        ParamType::Bool => json!({ "type": "boolean" }),
391        ParamType::StringArray => json!({
392            "type": "array",
393            "items": { "type": "string" }
394        }),
395        ParamType::Json => json!({}), // any
396        ParamType::Bytes => json!({ "type": "string", "format": "binary" }),
397    }
398}
399
400fn default_to_value(d: &ParamDefault) -> Value {
401    match d {
402        ParamDefault::String(s) => Value::String((*s).to_string()),
403        ParamDefault::Int(i) => Value::from(*i),
404        ParamDefault::U64(u) => Value::from(*u),
405        ParamDefault::U32(u) => Value::from(*u),
406        ParamDefault::F64(f) => Value::from(*f),
407        ParamDefault::Bool(b) => Value::from(*b),
408    }
409}
410
411// =========================
412// Tests
413// =========================
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::router::RouterBuilder;
418    use actus_controller::{Controller, ParamDef, Params};
419    use actus_reply::{Reply, WebError};
420    use std::sync::Arc;
421
422    /// A `Controller` that exposes a fixed slice of `RouteDef`s via
423    /// `actus_describe_routes()` — sidesteps the `#[controller]` macro so
424    /// the test crate doesn't need an `::actus` self-dep.
425    struct Stub {
426        routes: &'static [RouteDef],
427    }
428
429    #[actus_controller::async_trait]
430    impl Controller for Stub {
431        async fn actus_dispatch(&self, _action: &str, _params: Params) -> Reply {
432            Err(WebError::NotFound)
433        }
434        fn __name(&self) -> &'static str {
435            "stub"
436        }
437        fn actus_describe_routes(&self) -> Vec<RouteDef> {
438            self.routes.to_vec()
439        }
440    }
441
442    fn build_router(mounts: &[(&str, &'static [RouteDef])]) -> Router {
443        let mut b = RouterBuilder::new();
444        for (mount, routes) in mounts {
445            b = b.add_route(mount, Arc::new(Stub { routes }));
446        }
447        b.build()
448    }
449
450    fn opts() -> Options {
451        Options::new("Test API", "1.0.0")
452    }
453
454    #[test]
455    fn shape_basics() {
456        static R: &[RouteDef] = &[RouteDef {
457            pattern: "",
458            handler_id: "handler_0",
459            handler: "list",
460            verb: &[Verb::GET],
461            params: &[],
462            doc: None,
463        }];
464        let router = build_router(&[("api/users", R)]);
465        let spec = generate(&router, &opts(), |_| true);
466
467        assert_eq!(spec["openapi"], "3.1.0");
468        assert_eq!(spec["info"]["title"], "Test API");
469        assert_eq!(spec["info"]["version"], "1.0.0");
470        assert!(spec["paths"]["/api/users"]["get"].is_object());
471        assert_eq!(
472            spec["paths"]["/api/users"]["get"]["operationId"],
473            "api_users_list_get"
474        );
475        // Every operation has a responses object.
476        assert!(spec["paths"]["/api/users"]["get"]["responses"]["default"].is_object());
477    }
478
479    #[test]
480    fn mount_filter_excludes_non_matching_controllers() {
481        static R: &[RouteDef] = &[RouteDef {
482            pattern: "",
483            handler_id: "handler_0",
484            handler: "h",
485            verb: &[Verb::GET],
486            params: &[],
487            doc: None,
488        }];
489        let router = build_router(&[("api/users", R), ("internal/debug", R)]);
490        let spec = generate(&router, &opts(), |mount| mount.starts_with("api/"));
491
492        assert!(spec["paths"]["/api/users"].is_object());
493        assert!(
494            spec["paths"]["/internal/debug"].is_null(),
495            "filter excluded"
496        );
497    }
498
499    #[test]
500    fn default_verbs_route_emits_both_get_and_post() {
501        static R: &[RouteDef] = &[RouteDef {
502            pattern: "",
503            handler_id: "handler_0",
504            handler: "either",
505            verb: DEFAULT_VERBS, // identity comparison detects this
506            params: &[],
507            doc: None,
508        }];
509        let router = build_router(&[("api/things", R)]);
510        let spec = generate(&router, &opts(), |_| true);
511
512        assert!(spec["paths"]["/api/things"]["get"].is_object());
513        assert!(spec["paths"]["/api/things"]["post"].is_object());
514    }
515
516    #[test]
517    fn path_param_marked_required_and_query_default_marked_optional() {
518        static R: &[RouteDef] = &[RouteDef {
519            pattern: "{id}",
520            handler_id: "handler_0",
521            handler: "get",
522            verb: &[Verb::GET],
523            params: &[
524                ParamDef {
525                    name: "id",
526                    ty: ParamType::U64,
527                    source: ParamSource::Path,
528                    default: None,
529                },
530                ParamDef {
531                    name: "expand",
532                    ty: ParamType::Bool,
533                    source: ParamSource::Query,
534                    default: Some(ParamDefault::Bool(false)),
535                },
536                ParamDef {
537                    name: "fields",
538                    ty: ParamType::StringArray,
539                    source: ParamSource::Query,
540                    default: None,
541                },
542            ],
543            doc: None,
544        }];
545        let router = build_router(&[("api/users", R)]);
546        let spec = generate(&router, &opts(), |_| true);
547
548        let params = spec["paths"]["/api/users/{id}"]["get"]["parameters"]
549            .as_array()
550            .expect("parameters array");
551        // id (path, required, u64 → integer/int64 min 0)
552        let id = &params[0];
553        assert_eq!(id["name"], "id");
554        assert_eq!(id["in"], "path");
555        assert_eq!(id["required"], true);
556        assert_eq!(id["schema"]["type"], "integer");
557        assert_eq!(id["schema"]["format"], "int64");
558        assert_eq!(id["schema"]["minimum"], 0);
559
560        // expand (query, optional because of default, bool with default)
561        let expand = &params[1];
562        assert_eq!(expand["name"], "expand");
563        assert_eq!(expand["in"], "query");
564        assert_eq!(expand["required"], false);
565        assert_eq!(expand["schema"]["type"], "boolean");
566        assert_eq!(expand["schema"]["default"], false);
567
568        // fields (query, StringArray → optional, array of string)
569        let fields = &params[2];
570        assert_eq!(fields["required"], false);
571        assert_eq!(fields["schema"]["type"], "array");
572        assert_eq!(fields["schema"]["items"]["type"], "string");
573    }
574
575    #[test]
576    fn rest_param_is_marked_with_extension() {
577        static R: &[RouteDef] = &[RouteDef {
578            pattern: "{drive}/{...path}",
579            handler_id: "handler_0",
580            handler: "read",
581            verb: &[Verb::GET],
582            params: &[
583                ParamDef {
584                    name: "drive",
585                    ty: ParamType::String,
586                    source: ParamSource::Path,
587                    default: None,
588                },
589                ParamDef {
590                    name: "path",
591                    ty: ParamType::String,
592                    source: ParamSource::Path,
593                    default: None,
594                },
595            ],
596            doc: None,
597        }];
598        let router = build_router(&[("files", R)]);
599        let spec = generate(&router, &opts(), |_| true);
600
601        // `{...path}` is reduced to `{path}` for OpenAPI path templating.
602        let op = &spec["paths"]["/files/{drive}/{path}"]["get"];
603        assert!(
604            op.is_object(),
605            "rest token stripped to /files/{{drive}}/{{path}}"
606        );
607
608        let params = op["parameters"].as_array().unwrap();
609        let drive = &params[0];
610        let path = &params[1];
611        // `drive` is a normal path param — no rest extension.
612        assert!(drive["x-actus-rest-param"].is_null());
613        // `path` is the rest param — marked.
614        assert_eq!(path["x-actus-rest-param"], true);
615        assert!(
616            path["description"]
617                .as_str()
618                .unwrap_or("")
619                .contains("trailing path"),
620        );
621    }
622
623    #[test]
624    fn body_params_become_request_body() {
625        static R: &[RouteDef] = &[RouteDef {
626            pattern: "",
627            handler_id: "handler_0",
628            handler: "create",
629            verb: &[Verb::POST],
630            params: &[ParamDef {
631                name: "data",
632                ty: ParamType::Json,
633                source: ParamSource::Body,
634                default: None,
635            }],
636            doc: None,
637        }];
638        let router = build_router(&[("api/users", R)]);
639        let spec = generate(&router, &opts(), |_| true);
640
641        let body = &spec["paths"]["/api/users"]["post"]["requestBody"];
642        assert!(body.is_object());
643        assert_eq!(body["required"], true);
644        assert!(body["content"]["application/json"]["schema"].is_object());
645
646        // Bytes body → application/octet-stream / string-binary.
647        static R2: &[RouteDef] = &[RouteDef {
648            pattern: "upload",
649            handler_id: "handler_0",
650            handler: "upload",
651            verb: &[Verb::POST],
652            params: &[ParamDef {
653                name: "body",
654                ty: ParamType::Bytes,
655                source: ParamSource::Body,
656                default: None,
657            }],
658            doc: None,
659        }];
660        let router = build_router(&[("api/files", R2)]);
661        let spec = generate(&router, &opts(), |_| true);
662        let body = &spec["paths"]["/api/files/upload"]["post"]["requestBody"];
663        assert!(body["content"]["application/octet-stream"]["schema"]["format"] == "binary");
664    }
665
666    #[test]
667    fn doc_becomes_summary_first_line_and_description_full() {
668        static R: &[RouteDef] = &[RouteDef {
669            pattern: "",
670            handler_id: "handler_0",
671            handler: "list",
672            verb: &[Verb::GET],
673            params: &[],
674            doc: Some(
675                " List items.\n\nThe long form: paginated, sorted by creation time.\nUse `?page=`.",
676            ),
677        }];
678        let router = build_router(&[("api/items", R)]);
679        let spec = generate(&router, &opts(), |_| true);
680        let op = &spec["paths"]["/api/items"]["get"];
681        assert_eq!(op["summary"], "List items.");
682        // Description carries the full trimmed doc (multi-line).
683        let desc = op["description"].as_str().unwrap();
684        assert!(desc.starts_with("List items."));
685        assert!(desc.contains("paginated"));
686    }
687
688    #[test]
689    fn options_servers_and_description_round_trip() {
690        static R: &[RouteDef] = &[RouteDef {
691            pattern: "",
692            handler_id: "handler_0",
693            handler: "h",
694            verb: &[Verb::GET],
695            params: &[],
696            doc: None,
697        }];
698        let router = build_router(&[("api", R)]);
699        let options = Options::new("My API", "2.1.0")
700            .description("Awesome")
701            .server("https://api.example.com", Some("prod"))
702            .server("https://staging.api.example.com", None::<&str>);
703        let spec = generate(&router, &options, |_| true);
704
705        assert_eq!(spec["info"]["description"], "Awesome");
706        let servers = spec["servers"].as_array().unwrap();
707        assert_eq!(servers.len(), 2);
708        assert_eq!(servers[0]["url"], "https://api.example.com");
709        assert_eq!(servers[0]["description"], "prod");
710        assert!(servers[1]["description"].is_null());
711    }
712
713    #[test]
714    fn to_string_pretty_is_deterministic_json() {
715        static R: &[RouteDef] = &[RouteDef {
716            pattern: "",
717            handler_id: "handler_0",
718            handler: "h",
719            verb: &[Verb::GET],
720            params: &[],
721            doc: None,
722        }];
723        let router = build_router(&[("api", R)]);
724        let spec = generate(&router, &opts(), |_| true);
725        let pretty = to_string_pretty(&spec);
726        assert!(pretty.starts_with("{\n"));
727        assert!(pretty.contains("\"openapi\": \"3.1.0\""));
728    }
729}