1use std::collections::BTreeSet;
23
24use serde_json::Value;
25
26const HTTP_METHODS: [&str; 8] = [
33 "get", "put", "post", "delete", "patch", "head", "options", "trace",
34];
35
36#[derive(Debug, Clone, Copy, Default)]
42pub struct ReducerConfig<'a> {
43 pub reserved_flags: &'a [&'a str],
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Location {
54 Path,
56 Query,
58 Header,
60}
61
62#[derive(Debug, Clone)]
64pub struct Param {
65 pub wire: String,
67 pub flag: String,
69 pub location: Location,
71 pub required: bool,
73 pub description: Option<String>,
75}
76
77#[derive(Debug, Clone)]
79pub struct Method {
80 pub name: String,
82 pub operation_id: Option<String>,
89 pub summary: Option<String>,
91 pub http_method: String,
93 pub path: String,
96 pub params: Vec<Param>,
98 pub body: Option<bool>,
101}
102
103impl Method {
104 #[must_use]
106 pub fn path_params(&self) -> Vec<&Param> {
107 self.params
108 .iter()
109 .filter(|param| param.location == Location::Path)
110 .collect()
111 }
112}
113
114#[derive(Debug, Clone)]
116pub struct Cassette {
117 pub name: String,
119 pub description: Option<String>,
121 pub methods: Vec<Method>,
123}
124
125#[derive(Debug, Clone, Default)]
127pub struct Surface {
128 pub cassettes: Vec<Cassette>,
130}
131
132impl Surface {
133 #[must_use]
135 pub fn is_empty(&self) -> bool {
136 self.cassettes.is_empty()
137 }
138
139 #[must_use]
141 pub fn cassette(&self, name: &str) -> Option<&Cassette> {
142 self.cassettes.iter().find(|c| c.name == name)
143 }
144}
145
146#[must_use]
152pub fn reduce(
153 entry_name: &str,
154 description: Option<String>,
155 document: &Value,
156 reducer: &ReducerConfig<'_>,
157) -> Cassette {
158 Cassette {
159 name: entry_name.to_owned(),
160 description,
161 methods: reduce_methods(document, reducer),
162 }
163}
164
165#[must_use]
172pub fn reduce_methods(document: &Value, reducer: &ReducerConfig<'_>) -> Vec<Method> {
173 let mut methods: Vec<Method> = Vec::new();
174 let mut taken: BTreeSet<String> = BTreeSet::new();
175
176 if let Some(paths) = document.get("paths").and_then(Value::as_object) {
177 for (path, item) in paths {
180 methods.extend(methods_of(path, item, document, &mut taken, reducer));
181 }
182 }
183
184 methods.sort_by(|a, b| a.name.cmp(&b.name));
185 methods
186}
187
188fn methods_of(
193 path: &str,
194 item: &Value,
195 document: &Value,
196 taken: &mut BTreeSet<String>,
197 reducer: &ReducerConfig<'_>,
198) -> Vec<Method> {
199 let Some(item) = item.as_object() else {
200 return Vec::new();
201 };
202 let shared = parameters_of(item.get("parameters"), document);
203
204 HTTP_METHODS
205 .iter()
206 .filter_map(|verb| {
207 let operation = item.get(*verb)?.as_object()?;
208
209 let mut params = shared.clone();
210 params.extend(parameters_of(operation.get("parameters"), document));
211
212 let operation_id = operation
213 .get("operationId")
214 .and_then(Value::as_str)
215 .map(str::trim)
216 .filter(|id| !id.is_empty())
217 .map(ToOwned::to_owned);
218 let raw_name = operation_id
219 .as_deref()
220 .map_or_else(|| synthesize_id(verb, path), kebab_case);
221
222 Some(Method {
223 name: unique(raw_name, verb, taken),
224 operation_id,
225 summary: text_of(operation.get("summary"))
226 .or_else(|| text_of(operation.get("description"))),
227 http_method: verb.to_ascii_uppercase(),
228 path: path.to_owned(),
229 params: finish_params(path, params, reducer),
230 body: operation.get("requestBody").map(body_required),
231 })
232 })
233 .collect()
234}
235
236fn body_required(body: &Value) -> bool {
239 body.get("required")
240 .and_then(Value::as_bool)
241 .unwrap_or(false)
242}
243
244fn parameters_of(value: Option<&Value>, document: &Value) -> Vec<Param> {
252 let Some(list) = value.and_then(Value::as_array) else {
253 return Vec::new();
254 };
255
256 list.iter()
257 .filter_map(|entry| {
258 let resolved = match entry.get("$ref").and_then(Value::as_str) {
259 Some(reference) => resolve(reference, document)?,
260 None => entry,
261 };
262 parameter(resolved)
263 })
264 .collect()
265}
266
267fn resolve<'a>(reference: &str, document: &'a Value) -> Option<&'a Value> {
269 let pointer = reference.strip_prefix('#')?;
270 document.pointer(pointer)
271}
272
273fn parameter(value: &Value) -> Option<Param> {
275 let wire = value.get("name").and_then(Value::as_str)?.trim();
276 if wire.is_empty() {
277 return None;
278 }
279 let location = match value.get("in").and_then(Value::as_str) {
280 Some("path") => Location::Path,
281 Some("query") => Location::Query,
282 Some("header") => Location::Header,
283 _ => return None,
286 };
287
288 Some(Param {
289 wire: wire.to_owned(),
290 flag: kebab_case(wire),
291 location,
292 required: location == Location::Path
295 || value
296 .get("required")
297 .and_then(Value::as_bool)
298 .unwrap_or(false),
299 description: text_of(value.get("description")),
300 })
301}
302
303fn finish_params(path: &str, params: Vec<Param>, reducer: &ReducerConfig<'_>) -> Vec<Param> {
307 let templated = template_params(path);
308
309 let mut ordered: Vec<Param> = Vec::new();
310 for name in &templated {
313 if let Some(found) = params
314 .iter()
315 .find(|p| p.location == Location::Path && &p.wire == name)
316 {
317 ordered.push(found.clone());
318 } else {
319 ordered.push(Param {
323 wire: name.clone(),
324 flag: kebab_case(name),
325 location: Location::Path,
326 required: true,
327 description: None,
328 });
329 }
330 }
331 for param in params {
332 if param.location != Location::Path {
335 ordered.push(param);
336 }
337 }
338
339 let mut seen: BTreeSet<String> = BTreeSet::new();
340 for param in &mut ordered {
341 let mut base = param.flag.clone();
347 while reducer.reserved_flags.contains(&base.as_str()) {
348 base = format!("param-{base}");
349 }
350 let mut candidate = base.clone();
355 let mut suffix = 2;
356 while reducer.reserved_flags.contains(&candidate.as_str())
357 || !seen.insert(candidate.clone())
358 {
359 candidate = format!("{base}-{suffix}");
360 suffix += 1;
361 }
362 param.flag = candidate;
363 }
364
365 ordered
366}
367
368fn template_params(path: &str) -> Vec<String> {
370 let mut found = Vec::new();
371 let mut rest = path;
372 while let Some(open) = rest.find('{') {
373 let Some(close) = rest[open..].find('}') else {
374 break;
375 };
376 let name = &rest[open + 1..open + close];
377 if !name.is_empty() {
378 found.push(name.to_owned());
379 }
380 rest = &rest[open + close + 1..];
381 }
382 found
383}
384
385fn synthesize_id(verb: &str, path: &str) -> String {
393 let mut parts = vec![verb.to_ascii_lowercase()];
394 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
395 let tail = if segments.len() > 3 && segments[0] == "v1" && segments[1] == "cassettes" {
397 &segments[3..]
398 } else {
399 &segments[..]
400 };
401 for segment in tail {
402 let cleaned: String = segment
403 .chars()
404 .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
405 .collect();
406 if !cleaned.is_empty() {
407 parts.push(kebab_case(&cleaned));
408 }
409 }
410 if parts.len() == 1 {
411 return parts.remove(0);
413 }
414 parts.join("-")
415}
416
417fn unique(name: String, verb: &str, taken: &mut BTreeSet<String>) -> String {
424 if taken.insert(name.clone()) {
425 return name;
426 }
427 let with_verb = format!("{name}-{verb}");
428 if taken.insert(with_verb.clone()) {
429 return with_verb;
430 }
431 let mut suffix = 2;
432 loop {
433 let candidate = format!("{name}-{suffix}");
434 if taken.insert(candidate.clone()) {
435 return candidate;
436 }
437 suffix += 1;
438 }
439}
440
441fn text_of(value: Option<&Value>) -> Option<String> {
443 value
444 .and_then(Value::as_str)
445 .map(str::trim)
446 .filter(|s| !s.is_empty())
447 .map(ToOwned::to_owned)
448}
449
450fn kebab_case(raw: &str) -> String {
456 let chars: Vec<char> = raw.trim().chars().collect();
457 let mut out = String::with_capacity(chars.len() + 4);
458
459 for (index, ¤t) in chars.iter().enumerate() {
460 if current == '_' || current == ' ' || current == '.' {
461 if !out.ends_with('-') && !out.is_empty() {
462 out.push('-');
463 }
464 continue;
465 }
466 if current == '-' {
467 if !out.ends_with('-') && !out.is_empty() {
468 out.push('-');
469 }
470 continue;
471 }
472 if current.is_ascii_uppercase() && index > 0 {
473 let previous = chars[index - 1];
474 let starts_word = previous.is_ascii_lowercase()
475 || previous.is_ascii_digit()
476 || (previous.is_ascii_uppercase()
477 && chars.get(index + 1).is_some_and(char::is_ascii_lowercase));
478 if starts_word && !out.ends_with('-') && !out.is_empty() {
479 out.push('-');
480 }
481 }
482 out.extend(current.to_lowercase());
483 }
484
485 out.trim_matches('-').to_owned()
486}
487
488#[cfg(test)]
489#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
490mod tests {
491 use super::*;
492 use serde_json::json;
493
494 const RESERVED: ReducerConfig<'static> = ReducerConfig {
497 reserved_flags: &["tapes-url", "body", "help", "verbose"],
498 };
499
500 fn reduce(entry_name: &str, description: Option<String>, document: &Value) -> Cassette {
503 super::reduce(entry_name, description, document, &RESERVED)
504 }
505
506 fn hello_world() -> Value {
507 json!({
509 "openapi": "3.1.0",
510 "paths": {
511 "/v1/cassettes/hello-world/hello": {
512 "get": {
513 "operationId": "getHello",
514 "summary": "Greet, and read back every stored row"
515 },
516 "post": {
517 "operationId": "createHello",
518 "summary": "Write one row to the hello table",
519 "requestBody": {"required": false}
520 }
521 }
522 }
523 })
524 }
525
526 #[test]
527 fn an_operation_id_becomes_a_kebab_case_method() {
528 let cassette = reduce("hello-world", None, &hello_world());
529 let names: Vec<&str> = cassette.methods.iter().map(|m| m.name.as_str()).collect();
530 assert_eq!(names, vec!["create-hello", "get-hello"]);
531 }
532
533 #[test]
534 fn the_republished_path_is_used_verbatim() {
535 let cassette = reduce("hello-world", None, &hello_world());
538 let method = cassette
539 .methods
540 .iter()
541 .find(|m| m.name == "get-hello")
542 .unwrap();
543 assert_eq!(method.path, "/v1/cassettes/hello-world/hello");
544 assert_eq!(method.http_method, "GET");
545 }
546
547 #[test]
548 fn an_optional_request_body_is_distinguished_from_a_required_one_and_from_none() {
549 let document = json!({"paths": {"/v1/cassettes/c/thing": {
550 "post": {"operationId": "a", "requestBody": {"required": true}},
551 "put": {"operationId": "b", "requestBody": {}},
552 "get": {"operationId": "c"}
553 }}});
554 let cassette = reduce("c", None, &document);
555 let body = |name: &str| {
556 cassette
557 .methods
558 .iter()
559 .find(|m| m.name == name)
560 .unwrap()
561 .body
562 };
563 assert_eq!(body("a"), Some(true));
564 assert_eq!(body("b"), Some(false));
565 assert_eq!(body("c"), None);
566 }
567
568 #[test]
569 fn path_parameters_are_ordered_by_the_template_not_by_the_declaration() {
570 let document = json!({"paths": {"/v1/cassettes/c/{owner}/reports/{id}": {
573 "parameters": [
574 {"name": "id", "in": "path", "required": true},
575 {"name": "owner", "in": "path", "required": true}
576 ],
577 "get": {"operationId": "getReport"}
578 }}});
579 let cassette = reduce("c", None, &document);
580 let method = &cassette.methods[0];
581 let names: Vec<&str> = method
582 .path_params()
583 .iter()
584 .map(|p| p.wire.as_str())
585 .collect();
586 assert_eq!(names, vec!["owner", "id"]);
587 }
588
589 #[test]
590 fn a_templated_segment_with_no_declaration_still_becomes_an_argument() {
591 let document = json!({"paths": {"/v1/cassettes/c/reports/{id}": {
594 "get": {"operationId": "getReport"}
595 }}});
596 let cassette = reduce("c", None, &document);
597 assert_eq!(cassette.methods[0].path_params()[0].wire, "id");
598 assert!(cassette.methods[0].path_params()[0].required);
599 }
600
601 #[test]
602 fn shared_path_item_parameters_reach_every_operation() {
603 let document = json!({"paths": {"/v1/cassettes/c/reports": {
604 "parameters": [{"name": "since", "in": "query"}],
605 "get": {"operationId": "listReports"},
606 "post": {"operationId": "createReport"}
607 }}});
608 let cassette = reduce("c", None, &document);
609 for method in &cassette.methods {
610 assert!(
611 method.params.iter().any(|p| p.wire == "since"),
612 "{} lost the shared parameter",
613 method.name,
614 );
615 }
616 }
617
618 #[test]
619 fn a_shared_parameter_list_is_not_mistaken_for_an_operation() {
620 let document = json!({"paths": {"/v1/cassettes/c/reports": {
623 "parameters": [{"name": "since", "in": "query"}],
624 "summary": "not an operation",
625 "get": {"operationId": "listReports"}
626 }}});
627 let cassette = reduce("c", None, &document);
628 assert_eq!(cassette.methods.len(), 1);
629 assert_eq!(cassette.methods[0].name, "list-reports");
630 }
631
632 #[test]
633 fn a_referenced_parameter_is_resolved_from_components() {
634 let document = json!({
635 "components": {"parameters": {"Since": {"name": "since", "in": "query", "required": true}}},
636 "paths": {"/v1/cassettes/c/reports": {
637 "get": {"operationId": "listReports", "parameters": [{"$ref": "#/components/parameters/Since"}]}
638 }}
639 });
640 let cassette = reduce("c", None, &document);
641 let param = &cassette.methods[0].params[0];
642 assert_eq!(param.wire, "since");
643 assert!(param.required);
644 assert_eq!(param.location, Location::Query);
645 }
646
647 #[test]
648 fn a_reference_that_does_not_resolve_is_dropped_rather_than_guessed_at() {
649 let document = json!({"paths": {"/v1/cassettes/c/reports": {
650 "get": {"operationId": "listReports", "parameters": [{"$ref": "#/components/parameters/Absent"}]}
651 }}});
652 let cassette = reduce("c", None, &document);
653 assert!(cassette.methods[0].params.is_empty());
654 }
655
656 #[test]
657 fn a_cookie_parameter_is_ignored_because_a_cli_cannot_offer_one() {
658 let document = json!({"paths": {"/v1/cassettes/c/reports": {
659 "get": {"operationId": "listReports", "parameters": [{"name": "sid", "in": "cookie"}]}
660 }}});
661 let cassette = reduce("c", None, &document);
662 assert!(cassette.methods[0].params.is_empty());
663 }
664
665 #[test]
666 fn a_parameter_cannot_take_a_flag_the_subcommand_defines_itself() {
667 let document = json!({"paths": {"/v1/cassettes/c/reports": {
671 "get": {"operationId": "listReports", "parameters": [
672 {"name": "tapes_url", "in": "query"},
673 {"name": "body", "in": "query"}
674 ]}
675 }}});
676 let cassette = reduce("c", None, &document);
677 let flags: Vec<&str> = cassette.methods[0]
678 .params
679 .iter()
680 .map(|p| p.flag.as_str())
681 .collect();
682 assert_eq!(flags, vec!["param-tapes-url", "param-body"]);
683 assert_eq!(cassette.methods[0].params[0].wire, "tapes_url");
685 }
686
687 #[test]
688 fn a_reserved_rewrite_that_is_itself_reserved_is_rewritten_again() {
689 let adversarial = ReducerConfig {
695 reserved_flags: &["body", "param-body", "help"],
696 };
697 let document = json!({"paths": {"/v1/cassettes/c/reports": {
698 "get": {"operationId": "listReports", "parameters": [
699 {"name": "body", "in": "query"}
700 ]}
701 }}});
702 let cassette = super::reduce("c", None, &document, &adversarial);
703 let param = &cassette.methods[0].params[0];
704 assert_eq!(param.flag, "param-param-body");
705 assert_eq!(param.wire, "body");
707 }
708
709 #[test]
710 fn sibling_rewrites_that_collide_come_out_unique_and_unreserved() {
711 let adversarial = ReducerConfig {
715 reserved_flags: &["body", "param-body"],
716 };
717 let document = json!({"paths": {"/v1/cassettes/c/reports": {
718 "get": {"operationId": "listReports", "parameters": [
719 {"name": "body", "in": "query"},
720 {"name": "param_body", "in": "query"}
721 ]}
722 }}});
723 let cassette = super::reduce("c", None, &document, &adversarial);
724 let flags: Vec<&str> = cassette.methods[0]
725 .params
726 .iter()
727 .map(|p| p.flag.as_str())
728 .collect();
729 assert_eq!(flags, vec!["param-param-body", "param-param-body-2"]);
730 for flag in flags {
731 assert!(
732 !adversarial.reserved_flags.contains(&flag),
733 "{flag:?} is still reserved",
734 );
735 }
736 }
737
738 #[test]
739 fn a_uniqueness_suffix_may_not_land_on_a_reserved_name() {
740 let adversarial = ReducerConfig {
743 reserved_flags: &["since-id-2"],
744 };
745 let document = json!({"paths": {"/v1/cassettes/c/reports": {
746 "get": {"operationId": "listReports", "parameters": [
747 {"name": "since_id", "in": "query"},
748 {"name": "sinceId", "in": "header"}
749 ]}
750 }}});
751 let cassette = super::reduce("c", None, &document, &adversarial);
752 let flags: Vec<&str> = cassette.methods[0]
753 .params
754 .iter()
755 .map(|p| p.flag.as_str())
756 .collect();
757 assert_eq!(flags, vec!["since-id", "since-id-3"]);
758 }
759
760 #[test]
761 fn two_parameters_that_kebab_to_the_same_flag_stay_distinguishable() {
762 let document = json!({"paths": {"/v1/cassettes/c/reports": {
763 "get": {"operationId": "listReports", "parameters": [
764 {"name": "since_id", "in": "query"},
765 {"name": "sinceId", "in": "header"}
766 ]}
767 }}});
768 let cassette = reduce("c", None, &document);
769 let flags: Vec<&str> = cassette.methods[0]
770 .params
771 .iter()
772 .map(|p| p.flag.as_str())
773 .collect();
774 assert_eq!(flags, vec!["since-id", "since-id-2"]);
775 }
776
777 #[test]
778 fn an_operation_without_an_id_gets_one_from_its_verb_and_path() {
779 let document = json!({"paths": {"/v1/cassettes/summary/reports/{id}": {"get": {}}}});
780 let cassette = reduce("summary", None, &document);
781 assert_eq!(cassette.methods[0].name, "get-reports-id");
784 }
785
786 #[test]
787 fn colliding_method_names_are_disambiguated_by_verb() {
788 let document = json!({"paths": {"/v1/cassettes/c/thing": {
789 "get": {"operationId": "doThing"},
790 "post": {"operationId": "do_thing"}
791 }}});
792 let cassette = reduce("c", None, &document);
793 let names: Vec<&str> = cassette.methods.iter().map(|m| m.name.as_str()).collect();
794 assert_eq!(names.len(), 2);
795 assert!(names.contains(&"do-thing"), "got: {names:?}");
796 assert!(
797 names.iter().any(|n| n.starts_with("do-thing-")),
798 "got: {names:?}",
799 );
800 }
801
802 #[test]
803 fn a_document_with_nothing_usable_yields_a_cassette_with_no_methods() {
804 for document in [
806 json!({}),
807 json!({"paths": {}}),
808 json!({"paths": "nonsense"}),
809 ] {
810 assert!(reduce("c", None, &document).methods.is_empty());
811 }
812 }
813
814 #[test]
815 fn the_generated_surface_is_stable_between_reductions() {
816 let first = reduce("hello-world", None, &hello_world());
819 let second = reduce("hello-world", None, &hello_world());
820 let names =
821 |c: &Cassette| -> Vec<String> { c.methods.iter().map(|m| m.name.clone()).collect() };
822 assert_eq!(names(&first), names(&second));
823 }
824
825 #[test]
826 fn kebab_casing_keeps_acronyms_whole() {
827 assert_eq!(kebab_case("getHello"), "get-hello");
828 assert_eq!(kebab_case("since_id"), "since-id");
829 assert_eq!(kebab_case("getHTTPStatus"), "get-http-status");
830 assert_eq!(kebab_case("already-kebab"), "already-kebab");
831 assert_eq!(kebab_case("X"), "x");
832 }
833}