1#![deny(missing_docs)]
65
66use churust_core::{Call, IntoResponse, Response, RouteBuilder};
67use http::header::CONTENT_TYPE;
68use http::{HeaderValue, Method};
69use serde_json::{json, Map, Value};
70use std::collections::BTreeMap;
71
72#[derive(Debug, Clone, Default)]
78pub struct Operation {
79 summary: Option<String>,
80 description: Option<String>,
81 operation_id: Option<String>,
82 tags: Vec<String>,
83 deprecated: bool,
84 request_body: Option<(String, Option<Value>, bool)>,
85 responses: BTreeMap<u16, (String, Option<Value>)>,
86 query_params: Vec<(String, bool, Option<String>)>,
87}
88
89impl Operation {
90 pub fn new() -> Self {
92 Self::default()
93 }
94
95 pub fn summary(mut self, text: impl Into<String>) -> Self {
97 self.summary = Some(text.into());
98 self
99 }
100
101 pub fn description(mut self, text: impl Into<String>) -> Self {
103 self.description = Some(text.into());
104 self
105 }
106
107 pub fn operation_id(mut self, id: impl Into<String>) -> Self {
113 self.operation_id = Some(id.into());
114 self
115 }
116
117 pub fn tag(mut self, tag: impl Into<String>) -> Self {
119 self.tags.push(tag.into());
120 self
121 }
122
123 pub fn deprecated(mut self) -> Self {
125 self.deprecated = true;
126 self
127 }
128
129 pub fn query(mut self, name: impl Into<String>, required: bool) -> Self {
131 self.query_params.push((name.into(), required, None));
132 self
133 }
134
135 pub fn query_described(
137 mut self,
138 name: impl Into<String>,
139 required: bool,
140 description: impl Into<String>,
141 ) -> Self {
142 self.query_params
143 .push((name.into(), required, Some(description.into())));
144 self
145 }
146
147 pub fn accepts(mut self, media_type: impl Into<String>) -> Self {
149 self.request_body = Some((media_type.into(), None, true));
150 self
151 }
152
153 pub fn schema(mut self, media_type: impl Into<String>, schema: Value) -> Self {
158 self.request_body = Some((media_type.into(), Some(schema), true));
159 self
160 }
161
162 pub fn response(mut self, status: u16, description: impl Into<String>) -> Self {
164 self.responses.insert(status, (description.into(), None));
165 self
166 }
167
168 pub fn response_schema(
170 mut self,
171 status: u16,
172 description: impl Into<String>,
173 schema: Value,
174 ) -> Self {
175 self.responses
176 .insert(status, (description.into(), Some(schema)));
177 self
178 }
179}
180
181#[derive(Debug, Clone)]
183pub struct OpenApi {
184 title: String,
185 version: String,
186 description: Option<String>,
187 servers: Vec<(String, Option<String>)>,
188 routes: Vec<(Method, String)>,
190 operations: BTreeMap<(String, String), Operation>,
192}
193
194impl OpenApi {
195 pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
197 Self {
198 title: title.into(),
199 version: version.into(),
200 description: None,
201 servers: Vec::new(),
202 routes: Vec::new(),
203 operations: BTreeMap::new(),
204 }
205 }
206
207 pub fn description(mut self, text: impl Into<String>) -> Self {
209 self.description = Some(text.into());
210 self
211 }
212
213 pub fn server(mut self, url: impl Into<String>, description: Option<&str>) -> Self {
215 self.servers
216 .push((url.into(), description.map(str::to_string)));
217 self
218 }
219
220 pub fn from_routes(mut self, routes: &[(Method, String)]) -> Self {
226 for (method, path) in routes {
227 if method == Method::HEAD || method == Method::OPTIONS {
231 continue;
232 }
233 if !self
234 .routes
235 .iter()
236 .any(|r| r == &(method.clone(), path.clone()))
237 {
238 self.routes.push((method.clone(), path.clone()));
239 }
240 }
241 self
242 }
243
244 pub fn operation(mut self, method: Method, path: &str, operation: Operation) -> Self {
250 self.operations
251 .insert((path.to_string(), method.as_str().to_string()), operation);
252 self
253 }
254
255 pub fn undescribed(&self) -> Vec<(Method, String)> {
269 self.routes
270 .iter()
271 .filter(|(method, path)| {
272 !self
273 .operations
274 .contains_key(&(path.clone(), method.as_str().to_string()))
275 })
276 .cloned()
277 .collect()
278 }
279
280 pub fn stale(&self) -> Vec<(Method, String)> {
285 self.operations
286 .keys()
287 .filter(|(path, method)| {
288 !self
289 .routes
290 .iter()
291 .any(|(m, p)| m.as_str() == method && p == path)
292 })
293 .filter_map(|(path, method)| {
294 Method::from_bytes(method.as_bytes())
295 .ok()
296 .map(|m| (m, path.clone()))
297 })
298 .collect()
299 }
300
301 pub fn to_value(&self) -> Value {
303 let mut paths = Map::new();
304
305 for (method, path) in &self.routes {
306 let documented_path = openapi_path(path);
307 let entry = paths
308 .entry(documented_path.clone())
309 .or_insert_with(|| json!({}));
310 let Some(item) = entry.as_object_mut() else {
311 continue;
312 };
313
314 let described = self
315 .operations
316 .get(&(path.clone(), method.as_str().to_string()));
317 item.insert(
318 method.as_str().to_ascii_lowercase(),
319 self.operation_value(method, path, described),
320 );
321 }
322
323 let mut info = json!({
324 "title": self.title,
325 "version": self.version,
326 });
327 if let (Some(description), Some(map)) = (&self.description, info.as_object_mut()) {
328 map.insert("description".into(), json!(description));
329 }
330
331 let mut doc = json!({
332 "openapi": "3.1.0",
336 "info": info,
337 "paths": paths,
338 });
339
340 if !self.servers.is_empty() {
341 let servers: Vec<Value> = self
342 .servers
343 .iter()
344 .map(|(url, description)| match description {
345 Some(d) => json!({ "url": url, "description": d }),
346 None => json!({ "url": url }),
347 })
348 .collect();
349 if let Some(map) = doc.as_object_mut() {
350 map.insert("servers".into(), json!(servers));
351 }
352 }
353
354 doc
355 }
356
357 fn operation_value(&self, method: &Method, path: &str, described: Option<&Operation>) -> Value {
359 let mut out = Map::new();
360 let default = Operation::default();
361 let op = described.unwrap_or(&default);
362
363 out.insert(
364 "operationId".into(),
365 json!(op
366 .operation_id
367 .clone()
368 .unwrap_or_else(|| derive_operation_id(method, path))),
369 );
370 if let Some(summary) = &op.summary {
371 out.insert("summary".into(), json!(summary));
372 }
373 if let Some(description) = &op.description {
374 out.insert("description".into(), json!(description));
375 }
376 if !op.tags.is_empty() {
377 out.insert("tags".into(), json!(op.tags));
378 }
379 if op.deprecated {
380 out.insert("deprecated".into(), json!(true));
381 }
382
383 let mut parameters: Vec<Value> = path_parameters(path);
384 for (name, required, description) in &op.query_params {
385 let mut param = json!({
386 "name": name,
387 "in": "query",
388 "required": required,
389 "schema": { "type": "string" },
390 });
391 if let (Some(d), Some(map)) = (description, param.as_object_mut()) {
392 map.insert("description".into(), json!(d));
393 }
394 parameters.push(param);
395 }
396 if !parameters.is_empty() {
397 out.insert("parameters".into(), json!(parameters));
398 }
399
400 if let Some((media_type, schema, required)) = &op.request_body {
401 let content = match schema {
402 Some(schema) => json!({ media_type.clone(): { "schema": schema } }),
403 None => json!({ media_type.clone(): {} }),
404 };
405 out.insert(
406 "requestBody".into(),
407 json!({ "required": required, "content": content }),
408 );
409 }
410
411 let mut responses = Map::new();
412 if op.responses.is_empty() {
413 responses.insert(
417 "default".into(),
418 json!({ "description": "Undocumented response" }),
419 );
420 }
421 for (status, (description, schema)) in &op.responses {
422 let value = match schema {
423 Some(schema) => json!({
424 "description": description,
425 "content": { "application/json": { "schema": schema } },
426 }),
427 None => json!({ "description": description }),
428 };
429 responses.insert(status.to_string(), value);
430 }
431 out.insert("responses".into(), json!(responses));
432
433 Value::Object(out)
434 }
435
436 pub fn mount(&self, routes: &mut RouteBuilder, path: &str) {
455 let rendered = self.to_value().to_string();
456 routes.get(path, move |_c: Call| {
457 let body = rendered.clone();
458 async move { JsonDocument(body) }
459 });
460 }
461}
462
463struct JsonDocument(String);
469
470impl IntoResponse for JsonDocument {
471 fn into_response(self) -> Response {
472 let mut res = Response::text(self.0);
473 res.headers
474 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
475 res
476 }
477}
478
479fn openapi_path(pattern: &str) -> String {
486 pattern.replace("...}", "}")
487}
488
489fn path_parameters(pattern: &str) -> Vec<Value> {
491 let mut out = Vec::new();
492 for segment in pattern.split('/') {
493 let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) else {
494 continue;
495 };
496 let (name, spans_slashes) = match inner.strip_suffix("...") {
497 Some(name) => (name, true),
498 None => (inner, false),
499 };
500 let mut param = json!({
501 "name": name,
502 "in": "path",
503 "required": true,
506 "schema": { "type": "string" },
507 });
508 if spans_slashes {
509 if let Some(map) = param.as_object_mut() {
510 map.insert(
511 "description".into(),
512 json!("Matches the remaining path, including slashes."),
513 );
514 }
515 }
516 out.push(param);
517 }
518 out
519}
520
521fn derive_operation_id(method: &Method, path: &str) -> String {
527 let mut out = method.as_str().to_ascii_lowercase();
528 for segment in path.split('/').filter(|s| !s.is_empty()) {
529 match segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
530 Some(param) => {
531 let name = param.trim_end_matches("...");
532 out.push_str("By");
533 out.push_str(&capitalize(name));
534 }
535 None => out.push_str(&capitalize(segment)),
536 }
537 }
538 out
539}
540
541fn capitalize(s: &str) -> String {
543 let mut chars = s.chars();
544 match chars.next() {
545 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
546 None => String::new(),
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 fn routes() -> Vec<(Method, String)> {
555 vec![
556 (Method::GET, "/users/{id}".to_string()),
557 (Method::POST, "/users".to_string()),
558 (Method::GET, "/files/{path...}".to_string()),
559 ]
560 }
561
562 #[test]
563 fn every_route_becomes_an_operation() {
564 let doc = OpenApi::new("API", "1.0").from_routes(&routes()).to_value();
565 let paths = doc["paths"].as_object().unwrap();
566
567 assert!(paths.contains_key("/users/{id}"));
568 assert!(paths.contains_key("/users"));
569 assert!(paths["/users/{id}"].get("get").is_some());
570 assert!(paths["/users"].get("post").is_some());
571 }
572
573 #[test]
574 fn a_wildcard_becomes_an_ordinary_parameter() {
575 let doc = OpenApi::new("API", "1.0").from_routes(&routes()).to_value();
576 let paths = doc["paths"].as_object().unwrap();
577
578 assert!(
579 paths.contains_key("/files/{path}"),
580 "OpenAPI has no slash-spanning parameter: {:?}",
581 paths.keys().collect::<Vec<_>>()
582 );
583 let params = paths["/files/{path}"]["get"]["parameters"]
584 .as_array()
585 .unwrap();
586 assert_eq!(params[0]["name"], "path");
587 assert!(params[0]["description"]
588 .as_str()
589 .unwrap()
590 .contains("including slashes"));
591 }
592
593 #[test]
594 fn path_parameters_are_derived_and_required() {
595 let doc = OpenApi::new("API", "1.0").from_routes(&routes()).to_value();
596 let params = doc["paths"]["/users/{id}"]["get"]["parameters"]
597 .as_array()
598 .unwrap();
599
600 assert_eq!(params.len(), 1);
601 assert_eq!(params[0]["name"], "id");
602 assert_eq!(params[0]["in"], "path");
603 assert_eq!(params[0]["required"], true);
604 }
605
606 #[test]
607 fn an_undescribed_operation_still_carries_a_response() {
608 let doc = OpenApi::new("API", "1.0").from_routes(&routes()).to_value();
609 let responses = doc["paths"]["/users"]["post"]["responses"]
610 .as_object()
611 .unwrap();
612 assert!(
613 responses.contains_key("default"),
614 "an operation with no responses fails validation"
615 );
616 }
617
618 #[test]
619 fn a_description_reaches_the_document() {
620 let doc = OpenApi::new("API", "1.0")
621 .from_routes(&routes())
622 .operation(
623 Method::GET,
624 "/users/{id}",
625 Operation::new()
626 .summary("Fetch one user")
627 .tag("users")
628 .response(200, "The user")
629 .response(404, "No such user"),
630 )
631 .to_value();
632
633 let op = &doc["paths"]["/users/{id}"]["get"];
634 assert_eq!(op["summary"], "Fetch one user");
635 assert_eq!(op["tags"][0], "users");
636 assert_eq!(op["responses"]["200"]["description"], "The user");
637 assert_eq!(op["responses"]["404"]["description"], "No such user");
638 assert!(op["responses"].get("default").is_none());
639 }
640
641 #[test]
642 fn a_schema_is_carried_through_unchanged() {
643 let schema = json!({ "type": "object", "properties": { "name": { "type": "string" } } });
644 let doc = OpenApi::new("API", "1.0")
645 .from_routes(&routes())
646 .operation(
647 Method::POST,
648 "/users",
649 Operation::new().schema("application/json", schema.clone()),
650 )
651 .to_value();
652
653 assert_eq!(
654 doc["paths"]["/users"]["post"]["requestBody"]["content"]["application/json"]["schema"],
655 schema
656 );
657 }
658
659 #[test]
660 fn operation_ids_are_derived_and_stable() {
661 assert_eq!(
662 derive_operation_id(&Method::GET, "/users/{id}"),
663 "getUsersById"
664 );
665 assert_eq!(derive_operation_id(&Method::POST, "/users"), "postUsers");
666 assert_eq!(
667 derive_operation_id(&Method::GET, "/files/{path...}"),
668 "getFilesByPath"
669 );
670 }
671
672 #[test]
673 fn head_and_options_are_not_described() {
674 let mut routes = routes();
675 routes.push((Method::HEAD, "/users".to_string()));
676 routes.push((Method::OPTIONS, "/users".to_string()));
677
678 let doc = OpenApi::new("API", "1.0").from_routes(&routes).to_value();
679 assert!(doc["paths"]["/users"].get("head").is_none());
680 assert!(doc["paths"]["/users"].get("options").is_none());
681 }
682
683 #[test]
684 fn drift_is_reported_in_both_directions() {
685 let api = OpenApi::new("API", "1.0")
686 .from_routes(&routes())
687 .operation(Method::GET, "/users/{id}", Operation::new())
688 .operation(Method::DELETE, "/gone", Operation::new());
689
690 let undescribed = api.undescribed();
691 assert!(undescribed.contains(&(Method::POST, "/users".to_string())));
692 assert!(!undescribed.contains(&(Method::GET, "/users/{id}".to_string())));
693
694 assert_eq!(api.stale(), vec![(Method::DELETE, "/gone".to_string())]);
695 }
696
697 #[test]
698 fn servers_and_description_are_included_when_set() {
699 let doc = OpenApi::new("API", "1.0")
700 .description("Everything about users")
701 .server("https://api.example.com", Some("production"))
702 .from_routes(&routes())
703 .to_value();
704
705 assert_eq!(doc["info"]["description"], "Everything about users");
706 assert_eq!(doc["servers"][0]["url"], "https://api.example.com");
707 assert_eq!(doc["servers"][0]["description"], "production");
708 }
709
710 #[test]
711 fn the_document_declares_openapi_3_1() {
712 let doc = OpenApi::new("API", "1.0").to_value();
713 assert_eq!(doc["openapi"], "3.1.0");
714 assert_eq!(doc["info"]["title"], "API");
715 assert_eq!(doc["info"]["version"], "1.0");
716 }
717}