1use serde_json::{json, Map, Value};
42use std::collections::BTreeMap;
43
44#[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 pub fn server(mut self, url: impl Into<String>) -> Self {
74 self.servers.push(url.into());
75 self
76 }
77
78 pub fn spec_path(mut self, path: impl Into<String>) -> Self {
80 self.spec_path = path.into();
81 self
82 }
83
84 pub fn swagger_ui(mut self, path: impl Into<String>) -> Self {
86 self.docs_path = Some(path.into());
87 self
88 }
89}
90
91#[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 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 pub fn secured(mut self) -> Self {
138 self.secured = true;
139 self
140 }
141
142 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 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 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 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 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 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#[cfg(feature = "openapi-schemars")]
264mod typed {
265 use super::*;
266 use schemars::generate::SchemaSettings;
267 use schemars::JsonSchema;
268
269 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 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 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 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 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 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 self.components.extend(defs);
339 self
340 }
341 }
342}
343
344pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383pub(crate) enum RouteKind {
384 Http,
385 #[cfg_attr(not(feature = "websocket"), allow(dead_code))]
386 WebSocket,
387 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
401fn 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 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 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 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
567pub(crate) fn swagger_ui_html(title: &str, spec_url: &str) -> String {
569 let title = title
570 .replace('&', "&")
571 .replace('<', "<")
572 .replace('>', ">");
573 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 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("<T&C>"));
771 assert!(!html.contains("</script><script>alert"));
772 assert!(html.contains(r#"integrity="sha384-"#));
773 }
774}