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 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 pub fn secured(mut self) -> Self {
132 self.secured = true;
133 self
134 }
135
136 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 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 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 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 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
219pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub(crate) enum RouteKind {
259 Http,
260 #[cfg_attr(not(feature = "websocket"), allow(dead_code))]
261 WebSocket,
262 Hidden,
264}
265
266pub(crate) struct RouteInfo {
267 pub method: String,
268 pub path: String,
269 pub kind: RouteKind,
270}
271
272fn 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 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
406pub(crate) fn swagger_ui_html(title: &str, spec_url: &str) -> String {
408 let title = title
409 .replace('&', "&")
410 .replace('<', "<")
411 .replace('>', ">");
412 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 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("<T&C>"));
575 assert!(!html.contains("</script><script>alert"));
576 assert!(html.contains(r#"integrity="sha384-"#));
577 }
578}