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