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