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