1use serde_json::{json, Map, Value};
39use std::collections::BTreeMap;
40
41#[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 pub fn server(mut self, url: impl Into<String>) -> Self {
71 self.servers.push(url.into());
72 self
73 }
74
75 pub fn spec_path(mut self, path: impl Into<String>) -> Self {
77 self.spec_path = path.into();
78 self
79 }
80
81 pub fn swagger_ui(mut self, path: impl Into<String>) -> Self {
83 self.docs_path = Some(path.into());
84 self
85 }
86}
87
88#[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 pub fn secured(mut self) -> Self {
133 self.secured = true;
134 self
135 }
136
137 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 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 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 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 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 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
229pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub(crate) enum RouteKind {
269 Http,
270 #[cfg_attr(not(feature = "websocket"), allow(dead_code))]
271 WebSocket,
272 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
286fn 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 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 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
444pub(crate) fn swagger_ui_html(title: &str, spec_url: &str) -> String {
446 let title = title
447 .replace('&', "&")
448 .replace('<', "<")
449 .replace('>', ">");
450 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 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("<T&C>"));
648 assert!(!html.contains("</script><script>alert"));
649 assert!(html.contains(r#"integrity="sha384-"#));
650 }
651}