Skip to main content

ullun/
openapi.rs

1//! OpenAPI 3.1 document generated from the registered routes
2//!
3//! Every route you register shows up in the document with its method, path and
4//! path parameters. Attach details with [`Operation`] through `App::doc` (or
5//! `RouteGroup::doc`), then publish the document with `App::openapi`:
6//!
7//! ```no_run
8//! use ullun::openapi::{schema, OpenApiInfo, Operation};
9//! use ullun::prelude::*;
10//!
11//! # async fn run() {
12//! App::new()
13//!     .get_with_params("/users/{id}", |p: Params| async move {
14//!         Ok(Response::json(serde_json::json!({ "id": p.get("id")? })))
15//!     })
16//!     .doc(
17//!         "GET",
18//!         "/users/{id}",
19//!         Operation::new("Fetch a user")
20//!             .tag("users")
21//!             .path_param("id", schema::integer(), "User id")
22//!             .response_json(200, "The user", schema::object([("id", schema::integer())], ["id"]))
23//!             .error_response(404, "No such user"),
24//!     )
25//!     .openapi(OpenApiInfo::new("Users API", "1.0.0").swagger_ui("/docs"))
26//!     .run("127.0.0.1:3000")
27//!     .await
28//!     .unwrap();
29//! # }
30//! ```
31//!
32//! With the `openapi-schemars` feature the schemas come from your types: see
33//! `Operation::request_type`, `response_type` and `query_type`.
34//!
35//! The document is built when the server starts, so routes added after the
36//! `openapi` call are included too. Static file routes are left out.
37//! `swagger_ui` serves a page that loads Swagger UI from unpkg (pinned version
38//! with subresource integrity hashes); leave it off if you do not want a
39//! third-party script on your server.
40
41use serde_json::{json, Map, Value};
42use std::collections::BTreeMap;
43
44/// Document metadata and where to publish it
45#[derive(Debug, Clone)]
46pub struct OpenApiInfo {
47    pub(crate) title: String,
48    pub(crate) version: String,
49    pub(crate) description: Option<String>,
50    pub(crate) servers: Vec<String>,
51    pub(crate) spec_path: String,
52    pub(crate) docs_path: Option<String>,
53}
54
55impl OpenApiInfo {
56    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
57        Self {
58            title: title.into(),
59            version: version.into(),
60            description: None,
61            servers: Vec::new(),
62            spec_path: "/openapi.json".to_string(),
63            docs_path: None,
64        }
65    }
66
67    pub fn description(mut self, description: impl Into<String>) -> Self {
68        self.description = Some(description.into());
69        self
70    }
71
72    /// Add a server URL, e.g. `https://api.example.com`
73    pub fn server(mut self, url: impl Into<String>) -> Self {
74        self.servers.push(url.into());
75        self
76    }
77
78    /// Where the JSON document is served (default `/openapi.json`)
79    pub fn spec_path(mut self, path: impl Into<String>) -> Self {
80        self.spec_path = path.into();
81        self
82    }
83
84    /// Also serve a Swagger UI page at `path`
85    pub fn swagger_ui(mut self, path: impl Into<String>) -> Self {
86        self.docs_path = Some(path.into());
87        self
88    }
89}
90
91/// Description of one operation (method + path)
92#[derive(Debug, Clone, Default)]
93pub struct Operation {
94    summary: String,
95    description: Option<String>,
96    tags: Vec<String>,
97    operation_id: Option<String>,
98    deprecated: bool,
99    secured: bool,
100    api_key_headers: Vec<String>,
101    parameters: Vec<Value>,
102    request_body: Option<Value>,
103    responses: BTreeMap<u16, Value>,
104    /// Named schemas the operation refers to (`#/components/schemas/<name>`)
105    components: BTreeMap<String, Value>,
106}
107
108impl Operation {
109    pub fn new(summary: impl Into<String>) -> Self {
110        Self {
111            summary: summary.into(),
112            ..Default::default()
113        }
114    }
115
116    pub fn description(mut self, description: impl Into<String>) -> Self {
117        self.description = Some(description.into());
118        self
119    }
120
121    pub fn tag(mut self, tag: impl Into<String>) -> Self {
122        self.tags.push(tag.into());
123        self
124    }
125
126    pub fn operation_id(mut self, id: impl Into<String>) -> Self {
127        self.operation_id = Some(id.into());
128        self
129    }
130
131    pub fn deprecated(mut self) -> Self {
132        self.deprecated = true;
133        self
134    }
135
136    /// Requires a bearer token (`Authorization: Bearer ...`)
137    pub fn secured(mut self) -> Self {
138        self.secured = true;
139        self
140    }
141
142    /// Requires an API key in the given header (e.g. `x-api-key`)
143    pub fn secured_by_api_key(mut self, header: impl Into<String>) -> Self {
144        let header = header.into();
145        if !self.api_key_headers.contains(&header) {
146            self.api_key_headers.push(header);
147        }
148        self
149    }
150
151    /// Describe a path parameter (undeclared ones default to `string`)
152    pub fn path_param(self, name: &str, schema: Value, description: &str) -> Self {
153        self.param("path", name, schema, description, true)
154    }
155
156    pub fn query_param(self, name: &str, schema: Value, description: &str, required: bool) -> Self {
157        self.param("query", name, schema, description, required)
158    }
159
160    pub fn header_param(
161        self,
162        name: &str,
163        schema: Value,
164        description: &str,
165        required: bool,
166    ) -> Self {
167        self.param("header", name, schema, description, required)
168    }
169
170    fn param(
171        mut self,
172        location: &str,
173        name: &str,
174        schema: Value,
175        description: &str,
176        required: bool,
177    ) -> Self {
178        self.parameters
179            .retain(|p| !(p["in"] == location && p["name"] == name));
180        let mut p = json!({ "name": name, "in": location, "required": required, "schema": schema });
181        if !description.is_empty() {
182            p["description"] = json!(description);
183        }
184        self.parameters.push(p);
185        self
186    }
187
188    /// JSON request body with the given schema
189    pub fn request_json(mut self, schema: Value) -> Self {
190        self.request_body = Some(json!({
191            "required": true,
192            "content": { "application/json": { "schema": schema } }
193        }));
194        self
195    }
196
197    /// `application/x-www-form-urlencoded` request body
198    pub fn request_form(mut self, schema: Value) -> Self {
199        self.request_body = Some(json!({
200            "required": true,
201            "content": { "application/x-www-form-urlencoded": { "schema": schema } }
202        }));
203        self
204    }
205
206    /// Response without a body description
207    pub fn response(mut self, status: u16, description: &str) -> Self {
208        self.responses
209            .insert(status, json!({ "description": description }));
210        self
211    }
212
213    pub fn response_json(mut self, status: u16, description: &str, schema: Value) -> Self {
214        self.responses.insert(
215            status,
216            json!({
217                "description": description,
218                "content": { "application/json": { "schema": schema } }
219            }),
220        );
221        self
222    }
223
224    /// Response using the framework's error body `{ "error": "...", "status": 404 }`
225    pub fn error_response(self, status: u16, description: &str) -> Self {
226        self.response_json(
227            status,
228            description,
229            json!({ "$ref": "#/components/schemas/Error" }),
230        )
231    }
232}
233
234#[cfg(feature = "openapi-schemars")]
235pub use schemars;
236
237/// Schemas derived from Rust types (feature `openapi-schemars`)
238///
239/// Derive `JsonSchema` (add `schemars` to your own `Cargo.toml` for the derive
240/// macro) and hand the type to the operation instead of writing the schema by
241/// hand. Structs and enums become named entries under `components/schemas`
242/// that operations refer to with `$ref`; the type names must be unique.
243///
244/// ```no_run
245/// use schemars::JsonSchema;
246/// use serde::{Deserialize, Serialize};
247/// use ullun::openapi::Operation;
248///
249/// #[derive(Deserialize, JsonSchema)]
250/// struct NewUser { name: String, age: Option<u8> }
251///
252/// #[derive(Serialize, JsonSchema)]
253/// struct User { id: u64, name: String }
254///
255/// #[derive(Deserialize, JsonSchema)]
256/// struct Paging { limit: Option<u32>, after: Option<u64> }
257///
258/// let op = Operation::new("Create a user")
259///     .request_type::<NewUser>()
260///     .response_type::<User>(201, "Created")
261///     .query_type::<Paging>();
262/// ```
263#[cfg(feature = "openapi-schemars")]
264mod typed {
265    use super::*;
266    use schemars::generate::SchemaSettings;
267    use schemars::JsonSchema;
268
269    /// Schema of `T` with every named type moved to `components`
270    fn generate<T: JsonSchema>() -> (Value, BTreeMap<String, Value>) {
271        let mut settings = SchemaSettings::draft2020_12();
272        settings.meta_schema = None;
273        settings.definitions_path = "/components/schemas".into();
274        let mut generator = settings.into_generator();
275        let schema = serde_json::to_value(generator.subschema_for::<T>()).unwrap_or(Value::Null);
276        let defs = generator.take_definitions(true).into_iter().collect();
277        (schema, defs)
278    }
279
280    /// Follow a `$ref` into `defs`
281    fn resolve<'a>(schema: &'a Value, defs: &'a BTreeMap<String, Value>) -> &'a Value {
282        schema
283            .get("$ref")
284            .and_then(Value::as_str)
285            .and_then(|r| r.strip_prefix("#/components/schemas/"))
286            .and_then(|name| defs.get(name))
287            .unwrap_or(schema)
288    }
289
290    impl Operation {
291        /// JSON request body described by the schema of `T`
292        pub fn request_type<T: JsonSchema>(mut self) -> Self {
293            let (schema, defs) = generate::<T>();
294            self.components.extend(defs);
295            self.request_json(schema)
296        }
297
298        /// `application/x-www-form-urlencoded` request body described by the schema of `T`
299        pub fn request_form_type<T: JsonSchema>(mut self) -> Self {
300            let (schema, defs) = generate::<T>();
301            self.components.extend(defs);
302            self.request_form(schema)
303        }
304
305        /// JSON response described by the schema of `T`
306        pub fn response_type<T: JsonSchema>(mut self, status: u16, description: &str) -> Self {
307            let (schema, defs) = generate::<T>();
308            self.components.extend(defs);
309            self.response_json(status, description, schema)
310        }
311
312        /// One query parameter per field of the struct `T`; fields that are not
313        /// `Option` are required, doc comments become descriptions
314        pub fn query_type<T: JsonSchema>(mut self) -> Self {
315            let (schema, defs) = generate::<T>();
316            let object = resolve(&schema, &defs).clone();
317            let required: Vec<&str> = object
318                .get("required")
319                .and_then(Value::as_array)
320                .map(|r| r.iter().filter_map(Value::as_str).collect())
321                .unwrap_or_default();
322            if let Some(props) = object.get("properties").and_then(Value::as_object) {
323                for (name, prop) in props {
324                    let description = prop
325                        .get("description")
326                        .and_then(Value::as_str)
327                        .unwrap_or("")
328                        .to_string();
329                    self = self.query_param(
330                        name,
331                        prop.clone(),
332                        &description,
333                        required.contains(&name.as_str()),
334                    );
335                }
336            }
337            // types the properties refer to
338            self.components.extend(defs);
339            self
340        }
341    }
342}
343
344/// Small helpers for JSON Schema values
345pub mod schema {
346    use serde_json::{json, Map, Value};
347
348    pub fn string() -> Value {
349        json!({ "type": "string" })
350    }
351    pub fn integer() -> Value {
352        json!({ "type": "integer" })
353    }
354    pub fn number() -> Value {
355        json!({ "type": "number" })
356    }
357    pub fn boolean() -> Value {
358        json!({ "type": "boolean" })
359    }
360    pub fn array(items: Value) -> Value {
361        json!({ "type": "array", "items": items })
362    }
363    /// Object with named properties; `required` lists the mandatory ones
364    pub fn object<'a>(
365        properties: impl IntoIterator<Item = (&'a str, Value)>,
366        required: impl IntoIterator<Item = &'a str>,
367    ) -> Value {
368        let props: Map<String, Value> = properties
369            .into_iter()
370            .map(|(k, v)| (k.to_string(), v))
371            .collect();
372        let required: Vec<&str> = required.into_iter().collect();
373        let mut o = json!({ "type": "object", "properties": props });
374        if !required.is_empty() {
375            o["required"] = json!(required);
376        }
377        o
378    }
379}
380
381/// How a route was registered
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383pub(crate) enum RouteKind {
384    Http,
385    #[cfg_attr(not(feature = "websocket"), allow(dead_code))]
386    WebSocket,
387    /// Registered, but not part of the documentation (static files)
388    Hidden,
389}
390
391pub(crate) struct RouteInfo {
392    pub method: String,
393    pub path: String,
394    pub kind: RouteKind,
395}
396
397fn api_key_scheme(header: &str) -> String {
398    format!("apiKey_{}", header.replace('-', "_"))
399}
400
401/// `{*rest}` catch-alls become plain `{rest}`; returns the parameter names
402fn openapi_path(path: &str) -> (String, Vec<String>) {
403    let mut out = String::new();
404    let mut names = Vec::new();
405    let mut rest = path;
406    while let Some(start) = rest.find('{') {
407        out.push_str(&rest[..start]);
408        let Some(end) = rest[start..].find('}') else {
409            break;
410        };
411        let name = rest[start + 1..start + end].trim_start_matches('*');
412        out.push('{');
413        out.push_str(name);
414        out.push('}');
415        names.push(name.to_string());
416        rest = &rest[start + end + 1..];
417    }
418    out.push_str(rest);
419    (out, names)
420}
421
422pub(crate) fn build(
423    info: &OpenApiInfo,
424    routes: &[RouteInfo],
425    docs: &std::collections::HashMap<(String, String), Operation>,
426) -> Value {
427    let mut paths: BTreeMap<String, Map<String, Value>> = BTreeMap::new();
428    let mut uses_bearer = false;
429    let mut api_key_headers: Vec<String> = Vec::new();
430
431    for route in routes.iter().filter(|r| r.kind != RouteKind::Hidden) {
432        let (doc_path, names) = openapi_path(&route.path);
433        let op = docs.get(&(route.method.clone(), route.path.clone()));
434
435        let mut operation = Map::new();
436        if let Some(op) = op {
437            if !op.summary.is_empty() {
438                operation.insert("summary".into(), json!(op.summary));
439            }
440            if let Some(d) = &op.description {
441                operation.insert("description".into(), json!(d));
442            }
443            if !op.tags.is_empty() {
444                operation.insert("tags".into(), json!(op.tags));
445            }
446            if let Some(id) = &op.operation_id {
447                operation.insert("operationId".into(), json!(id));
448            }
449            if op.deprecated {
450                operation.insert("deprecated".into(), json!(true));
451            }
452            // alternatives: any one of the listed schemes is enough
453            let mut security: Vec<Value> = Vec::new();
454            if op.secured {
455                uses_bearer = true;
456                security.push(json!({ "bearerAuth": [] }));
457            }
458            for header in &op.api_key_headers {
459                if !api_key_headers.contains(header) {
460                    api_key_headers.push(header.clone());
461                }
462                security.push(json!({ api_key_scheme(header): [] }));
463            }
464            if !security.is_empty() {
465                operation.insert("security".into(), Value::Array(security));
466            }
467        }
468
469        // every {param} in the path is declared; documented ones win
470        let mut parameters: Vec<Value> = names
471            .iter()
472            .filter(|n| {
473                !op.is_some_and(|o| o.parameters.iter().any(|p| p["in"] == "path" && p["name"] == n.as_str()))
474            })
475            .map(|n| json!({ "name": n, "in": "path", "required": true, "schema": { "type": "string" } }))
476            .collect();
477        if let Some(op) = op {
478            parameters.extend(op.parameters.iter().cloned());
479        }
480        if !parameters.is_empty() {
481            operation.insert("parameters".into(), json!(parameters));
482        }
483        if let Some(body) = op.and_then(|o| o.request_body.clone()) {
484            operation.insert("requestBody".into(), body);
485        }
486
487        let mut responses = Map::new();
488        match (op, route.kind) {
489            (Some(op), _) if !op.responses.is_empty() => {
490                for (status, r) in &op.responses {
491                    responses.insert(status.to_string(), r.clone());
492                }
493            }
494            (_, RouteKind::WebSocket) => {
495                responses.insert(
496                    "101".into(),
497                    json!({ "description": "Switching Protocols (WebSocket)" }),
498                );
499            }
500            _ => {
501                responses.insert("200".into(), json!({ "description": "OK" }));
502            }
503        }
504        operation.insert("responses".into(), Value::Object(responses));
505
506        paths
507            .entry(doc_path)
508            .or_default()
509            .insert(route.method.to_ascii_lowercase(), Value::Object(operation));
510    }
511
512    let mut info_obj = json!({ "title": info.title, "version": info.version });
513    if let Some(d) = &info.description {
514        info_obj["description"] = json!(d);
515    }
516
517    let mut components = json!({
518        "schemas": {
519            "Error": {
520                "type": "object",
521                "properties": { "error": { "type": "string" }, "status": { "type": "integer" } },
522                "required": ["error", "status"]
523            }
524        }
525    });
526    for op in docs.values() {
527        for (name, schema) in &op.components {
528            // the built-in `Error` schema stays what the framework answers with
529            if name != "Error" {
530                components["schemas"][name] = schema.clone();
531            }
532        }
533    }
534    let mut schemes = Map::new();
535    if uses_bearer {
536        schemes.insert(
537            "bearerAuth".into(),
538            json!({ "type": "http", "scheme": "bearer", "bearerFormat": "JWT" }),
539        );
540    }
541    for header in &api_key_headers {
542        schemes.insert(
543            api_key_scheme(header),
544            json!({ "type": "apiKey", "in": "header", "name": header }),
545        );
546    }
547    if !schemes.is_empty() {
548        components["securitySchemes"] = Value::Object(schemes);
549    }
550
551    let mut doc = json!({
552        "openapi": "3.1.0",
553        "info": info_obj,
554        "paths": paths,
555        "components": components,
556    });
557    if !info.servers.is_empty() {
558        doc["servers"] = json!(info
559            .servers
560            .iter()
561            .map(|u| json!({ "url": u }))
562            .collect::<Vec<_>>());
563    }
564    doc
565}
566
567/// Swagger UI page for the document at `spec_url`
568pub(crate) fn swagger_ui_html(title: &str, spec_url: &str) -> String {
569    let title = title
570        .replace('&', "&amp;")
571        .replace('<', "&lt;")
572        .replace('>', "&gt;");
573    // JSON string literal is safe inside the script block once '<' is escaped
574    let spec = serde_json::to_string(spec_url)
575        .unwrap_or_else(|_| "\"\"".into())
576        .replace('<', "\\u003c");
577    format!(
578        r##"<!doctype html>
579<html lang="en">
580<head>
581<meta charset="utf-8">
582<meta name="viewport" content="width=device-width, initial-scale=1">
583<title>{title}</title>
584<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.34.0/swagger-ui.css"
585  integrity="sha384-4c3vsE5f+trhFyHrFrLM1fFGUn531NIaC7dcqEWIJdoRQJ5CkJbSuSCwXLpaS1KF" crossorigin="anonymous">
586</head>
587<body>
588<div id="ui"></div>
589<script src="https://unpkg.com/swagger-ui-dist@5.34.0/swagger-ui-bundle.js"
590  integrity="sha384-9Td7MKiIr2S83E1kZyyVi8KdJw/S8CpAvu0llLyMeg0xq/hDGiZJfVn0fV4llCmK" crossorigin="anonymous"></script>
591<script>window.ui = SwaggerUIBundle({{ url: {spec}, dom_id: "#ui" }});</script>
592</body>
593</html>
594"##
595    )
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use std::collections::HashMap;
602
603    fn route(method: &str, path: &str, kind: RouteKind) -> RouteInfo {
604        RouteInfo {
605            method: method.into(),
606            path: path.into(),
607            kind,
608        }
609    }
610
611    #[test]
612    fn converts_paths_and_catch_alls() {
613        assert_eq!(
614            openapi_path("/users/{id}/posts/{post}"),
615            (
616                "/users/{id}/posts/{post}".into(),
617                vec!["id".into(), "post".into()]
618            )
619        );
620        assert_eq!(
621            openapi_path("/files/{*path}"),
622            ("/files/{path}".into(), vec!["path".into()])
623        );
624        assert_eq!(openapi_path("/plain"), ("/plain".into(), vec![]));
625    }
626
627    #[test]
628    fn undocumented_routes_get_defaults_and_hidden_ones_are_skipped() {
629        let routes = vec![
630            route("GET", "/users/{id}", RouteKind::Http),
631            route("POST", "/users", RouteKind::Http),
632            route("GET", "/chat", RouteKind::WebSocket),
633            route("GET", "/static/{*filepath}", RouteKind::Hidden),
634        ];
635        let doc = build(&OpenApiInfo::new("T", "1"), &routes, &HashMap::new());
636        assert_eq!(doc["openapi"], "3.1.0");
637        assert_eq!(doc["info"], json!({ "title": "T", "version": "1" }));
638        assert!(doc["paths"].get("/static/{filepath}").is_none());
639
640        let get = &doc["paths"]["/users/{id}"]["get"];
641        assert_eq!(
642            get["parameters"][0],
643            json!({ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } })
644        );
645        assert_eq!(get["responses"]["200"]["description"], "OK");
646        assert!(doc["paths"]["/users"]["post"].is_object());
647        assert_eq!(
648            doc["paths"]["/chat"]["get"]["responses"]["101"]["description"],
649            "Switching Protocols (WebSocket)"
650        );
651        assert!(doc["components"].get("securitySchemes").is_none());
652    }
653
654    #[test]
655    fn documented_operation_is_rendered() {
656        let routes = vec![route("GET", "/users/{id}", RouteKind::Http)];
657        let mut docs = HashMap::new();
658        docs.insert(
659            ("GET".to_string(), "/users/{id}".to_string()),
660            Operation::new("Fetch")
661                .description("Longer text")
662                .tag("users")
663                .operation_id("getUser")
664                .deprecated()
665                .secured()
666                .path_param("id", schema::integer(), "User id")
667                .query_param("verbose", schema::boolean(), "", false)
668                .response_json(
669                    200,
670                    "User",
671                    schema::object([("id", schema::integer())], ["id"]),
672                )
673                .error_response(404, "Missing"),
674        );
675        let info = OpenApiInfo::new("T", "1")
676            .description("d")
677            .server("https://api.example.com");
678        let doc = build(&info, &routes, &docs);
679
680        let op = &doc["paths"]["/users/{id}"]["get"];
681        assert_eq!(op["summary"], "Fetch");
682        assert_eq!(op["tags"], json!(["users"]));
683        assert_eq!(op["operationId"], "getUser");
684        assert_eq!(op["deprecated"], true);
685        assert_eq!(op["security"], json!([{ "bearerAuth": [] }]));
686        // documented path param replaces the default one instead of duplicating it
687        let params = op["parameters"].as_array().unwrap();
688        assert_eq!(params.len(), 2);
689        assert_eq!(params[0]["schema"]["type"], "integer");
690        assert_eq!(params[1]["name"], "verbose");
691        assert_eq!(
692            op["responses"]["200"]["content"]["application/json"]["schema"]["required"],
693            json!(["id"])
694        );
695        assert_eq!(
696            op["responses"]["404"]["content"]["application/json"]["schema"]["$ref"],
697            "#/components/schemas/Error"
698        );
699        assert!(op["responses"].get("default").is_none());
700        assert_eq!(
701            doc["components"]["securitySchemes"]["bearerAuth"]["scheme"],
702            "bearer"
703        );
704        assert_eq!(doc["servers"][0]["url"], "https://api.example.com");
705        assert_eq!(doc["info"]["description"], "d");
706    }
707
708    #[test]
709    fn api_key_and_bearer_schemes_are_listed_as_alternatives() {
710        let routes = vec![
711            route("GET", "/a", RouteKind::Http),
712            route("GET", "/b", RouteKind::Http),
713        ];
714        let mut docs = HashMap::new();
715        docs.insert(
716            ("GET".into(), "/a".into()),
717            Operation::new("a").secured_by_api_key("x-api-key"),
718        );
719        docs.insert(
720            ("GET".into(), "/b".into()),
721            Operation::new("b")
722                .secured()
723                .secured_by_api_key("x-api-key")
724                .secured_by_api_key("x-api-key"),
725        );
726        let doc = build(&OpenApiInfo::new("T", "1"), &routes, &docs);
727        assert_eq!(
728            doc["paths"]["/a"]["get"]["security"],
729            json!([{ "apiKey_x_api_key": [] }])
730        );
731        assert_eq!(
732            doc["paths"]["/b"]["get"]["security"],
733            json!([{ "bearerAuth": [] }, { "apiKey_x_api_key": [] }])
734        );
735        let schemes = &doc["components"]["securitySchemes"];
736        assert_eq!(
737            schemes["apiKey_x_api_key"],
738            json!({ "type": "apiKey", "in": "header", "name": "x-api-key" })
739        );
740        assert_eq!(schemes["bearerAuth"]["scheme"], "bearer");
741    }
742
743    #[test]
744    fn request_bodies() {
745        let routes = vec![
746            route("POST", "/a", RouteKind::Http),
747            route("POST", "/b", RouteKind::Http),
748        ];
749        let mut docs = HashMap::new();
750        docs.insert(
751            ("POST".into(), "/a".into()),
752            Operation::new("a").request_json(schema::object([("n", schema::string())], [])),
753        );
754        docs.insert(
755            ("POST".into(), "/b".into()),
756            Operation::new("b").request_form(schema::object([("n", schema::string())], ["n"])),
757        );
758        let doc = build(&OpenApiInfo::new("T", "1"), &routes, &docs);
759        assert!(
760            doc["paths"]["/a"]["post"]["requestBody"]["content"]["application/json"].is_object()
761        );
762        assert!(doc["paths"]["/b"]["post"]["requestBody"]["content"]
763            ["application/x-www-form-urlencoded"]
764            .is_object());
765    }
766
767    #[test]
768    fn swagger_page_escapes_inputs() {
769        let html = swagger_ui_html("<T&C>", "/x</script><script>alert(1)");
770        assert!(html.contains("&lt;T&amp;C&gt;"));
771        assert!(!html.contains("</script><script>alert"));
772        assert!(html.contains(r#"integrity="sha384-"#));
773    }
774}