1use clap::{Arg, ArgMatches, Command};
20use snafu::{OptionExt, ResultExt};
21
22use crate::cassettes::spec::{Cassette, Location, Method, Surface};
23use crate::error::{Result, error};
24use crate::transport::Call;
25
26const BODY: &str = "body";
28
29#[must_use]
40pub fn augment<F>(mut base: Command, surface: &Surface, decorate: F) -> Command
41where
42 F: Fn(Command) -> Command,
43{
44 let built_in: Vec<String> = base
45 .get_subcommands()
46 .map(|sub| sub.get_name().to_owned())
47 .collect();
48
49 for cassette in &surface.cassettes {
50 if built_in.iter().any(|name| name == &cassette.name) {
51 tracing::debug!(
52 cassette = %cassette.name,
53 "a cassette shares its name with a built-in command and was not generated",
54 );
55 continue;
56 }
57 base = base.subcommand(cassette_command(cassette, &decorate));
58 }
59 base
60}
61
62#[must_use]
64pub fn cassette_command<F>(cassette: &Cassette, decorate: &F) -> Command
65where
66 F: Fn(Command) -> Command,
67{
68 let about = cassette
69 .description
70 .clone()
71 .unwrap_or_else(|| format!("Methods served by the {} cassette", cassette.name));
72
73 let mut command = Command::new(cassette.name.clone())
74 .about(about)
75 .arg_required_else_help(true)
78 .subcommand_required(true);
79
80 for method in &cassette.methods {
81 command = command.subcommand(method_command(method, decorate));
82 }
83 command
84}
85
86#[must_use]
88pub fn method_command<F>(method: &Method, decorate: &F) -> Command
89where
90 F: Fn(Command) -> Command,
91{
92 let mut command = Command::new(method.name.clone());
93 if let Some(summary) = &method.summary {
94 command = command.about(summary.clone());
95 }
96 command = command.after_help(format!("Calls {} {}", method.http_method, method.path));
99
100 for param in &method.params {
101 let mut arg = Arg::new(param.flag.clone());
102 if let Some(description) = ¶m.description {
103 arg = arg.help(description.clone());
104 }
105 arg = match param.location {
106 Location::Path => arg.required(true).value_name(param.flag.to_uppercase()),
107 Location::Query | Location::Header => arg
108 .long(param.flag.clone())
109 .required(param.required)
110 .value_name("VALUE"),
111 };
112 command = command.arg(arg);
113 }
114
115 if let Some(required) = method.body {
116 command = command.arg(
117 Arg::new(BODY)
118 .long(BODY)
119 .required(required)
120 .value_name("JSON")
121 .help("Request body as JSON, or @<path> to read it from a file"),
122 );
123 }
124
125 decorate(command)
126}
127
128pub fn resolve_invocation<'s>(
135 surface: &'s Surface,
136 name: &str,
137 matches: &ArgMatches,
138) -> Result<(&'s Method, Call<'s>)> {
139 let cassette = surface
140 .cassette(name)
141 .context(error::UnknownCassetteSnafu { name })?;
142 let (method_name, method_matches) =
143 matches.subcommand().context(error::UnknownMethodSnafu {
144 cassette: name,
145 method: "",
146 })?;
147 let method = cassette
148 .methods
149 .iter()
150 .find(|candidate| candidate.name == method_name)
151 .context(error::UnknownMethodSnafu {
152 cassette: name,
153 method: method_name,
154 })?;
155
156 let call = call_for(method, method_matches)?;
157 Ok((method, call))
158}
159
160pub fn call_for<'a>(method: &'a Method, matches: &ArgMatches) -> Result<Call<'a>> {
162 let mut call = Call {
163 method: &method.http_method,
164 path: &method.path,
165 ..Default::default()
166 };
167
168 for param in &method.params {
169 let Some(value) = matches.get_one::<String>(¶m.flag) else {
170 continue;
171 };
172 let pair = (param.wire.clone(), value.clone());
173 match param.location {
174 Location::Path => call.path_params.push(pair),
175 Location::Query => call.query.push(pair),
176 Location::Header => call.headers.push(pair),
177 }
178 }
179
180 if method.body.is_some() {
184 if let Some(raw) = matches.get_one::<String>(BODY) {
185 call.body = Some(read_body(raw)?);
186 }
187 }
188
189 Ok(call)
190}
191
192pub fn read_body(raw: &str) -> Result<String> {
198 let text = match raw.strip_prefix('@') {
199 Some(path) => std::fs::read_to_string(path).context(error::BodyFileSnafu { path })?,
200 None => raw.to_owned(),
201 };
202 let parsed: serde_json::Value = serde_json::from_str(&text).context(error::InvalidBodySnafu)?;
203 serde_json::to_string(&parsed).context(error::RenderBodySnafu)
204}
205
206#[cfg(test)]
207#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
208mod tests {
209 use super::*;
210 use crate::cassettes::spec::{self, ReducerConfig};
211 use clap::ArgAction;
212 use serde_json::json;
213
214 const RESERVED: ReducerConfig<'static> = ReducerConfig {
217 reserved_flags: &["tapes-url", "body", "help", "verbose"],
218 };
219
220 fn with_tapes_url(command: Command) -> Command {
222 command.arg(
223 Arg::new("tapes-url")
224 .long("tapes-url")
225 .env("TAPES_URL")
226 .action(ArgAction::Set)
227 .value_name("URL")
228 .help("Base URL of the tapes server"),
229 )
230 }
231
232 fn augment(base: Command, surface: &Surface) -> Command {
235 super::augment(base, surface, with_tapes_url)
236 }
237
238 fn surface_from(name: &str, document: &serde_json::Value) -> Surface {
239 Surface {
240 cassettes: vec![spec::reduce(name, None, document, &RESERVED)],
241 }
242 }
243
244 fn hello_surface() -> Surface {
245 surface_from(
246 "hello-world",
247 &json!({"paths": {"/v1/cassettes/hello-world/hello": {
248 "get": {"operationId": "getHello", "summary": "Greet"},
249 "post": {"operationId": "createHello", "requestBody": {"required": true}}
250 }}}),
251 )
252 }
253
254 fn root() -> Command {
255 Command::new("tapesctl").subcommand(Command::new("sessions"))
256 }
257
258 #[test]
259 fn a_generated_surface_is_a_well_formed_clap_definition() {
260 augment(root(), &hello_surface()).debug_assert();
264 }
265
266 #[test]
267 fn a_consumer_reserving_both_spellings_still_gets_a_well_formed_command() {
268 let reserved = ReducerConfig {
274 reserved_flags: &["tapes-url", "body", "param-body", "help", "verbose"],
275 };
276 let document = json!({"paths": {"/v1/cassettes/c/thing": {
277 "post": {"operationId": "createThing", "requestBody": {"required": true},
278 "parameters": [
279 {"name": "body", "in": "query"},
280 {"name": "param_body", "in": "query"}
281 ]}
282 }}});
283 let surface = Surface {
284 cassettes: vec![spec::reduce("c", None, &document, &reserved)],
285 };
286 let decorate = |command: Command| {
287 with_tapes_url(command).arg(
288 Arg::new("param-body")
289 .long("param-body")
290 .value_name("VALUE"),
291 )
292 };
293
294 let command = super::augment(root(), &surface, decorate);
295 command.clone().debug_assert();
296
297 let matches = command
299 .try_get_matches_from([
300 "tapesctl",
301 "c",
302 "create-thing",
303 "--body",
304 "{}",
305 "--param-param-body",
306 "wire-body",
307 "--param-param-body-2",
308 "wire-param-body",
309 "--tapes-url",
310 "http://x",
311 ])
312 .unwrap();
313 let (_, cassette_matches) = matches.subcommand().unwrap();
314 let (_, method_matches) = cassette_matches.subcommand().unwrap();
315 assert_eq!(
316 method_matches
317 .get_one::<String>("param-param-body")
318 .unwrap(),
319 "wire-body",
320 );
321 assert_eq!(
322 method_matches
323 .get_one::<String>("param-param-body-2")
324 .unwrap(),
325 "wire-param-body",
326 );
327 }
328
329 #[test]
330 fn a_cassette_becomes_a_noun_and_its_operations_become_methods() {
331 let command = augment(root(), &hello_surface());
332 let cassette = command
333 .get_subcommands()
334 .find(|sub| sub.get_name() == "hello-world")
335 .expect("the cassette noun should be generated");
336 let methods: Vec<&str> = cassette
337 .get_subcommands()
338 .map(clap::Command::get_name)
339 .collect();
340 assert!(methods.contains(&"get-hello"), "got: {methods:?}");
341 assert!(methods.contains(&"create-hello"), "got: {methods:?}");
342 }
343
344 #[test]
345 fn a_cassette_cannot_redefine_a_built_in_command() {
346 let surface = surface_from(
349 "sessions",
350 &json!({"paths": {"/v1/cassettes/sessions/x": {"get": {"operationId": "getX"}}}}),
351 );
352 let command = augment(root(), &surface);
353 let sessions: Vec<&clap::Command> = command
354 .get_subcommands()
355 .filter(|sub| sub.get_name() == "sessions")
356 .collect();
357 assert_eq!(sessions.len(), 1);
358 assert_eq!(sessions[0].get_subcommands().count(), 0);
359 }
360
361 #[test]
362 fn the_generated_help_names_the_route_it_calls() {
363 let mut command = augment(root(), &hello_surface());
365 let help = command
366 .find_subcommand_mut("hello-world")
367 .and_then(|c| c.find_subcommand_mut("get-hello"))
368 .unwrap()
369 .render_long_help()
370 .to_string();
371 assert!(
372 help.contains("GET /v1/cassettes/hello-world/hello"),
373 "got: {help}"
374 );
375 }
376
377 #[test]
378 fn the_decorator_reaches_every_generated_method() {
379 let mut command = augment(root(), &hello_surface());
382 for name in ["get-hello", "create-hello"] {
383 let help = command
384 .find_subcommand_mut("hello-world")
385 .and_then(|c| c.find_subcommand_mut(name))
386 .unwrap()
387 .render_long_help()
388 .to_string();
389 assert!(help.contains("--tapes-url"), "{name} lost the flag: {help}");
390 }
391 }
392
393 #[test]
394 fn a_path_parameter_parses_as_a_positional_and_a_query_parameter_as_a_flag() {
395 let surface = surface_from(
396 "summary",
397 &json!({"paths": {"/v1/cassettes/summary/reports/{id}": {
398 "get": {"operationId": "getReport", "parameters": [
399 {"name": "id", "in": "path", "required": true},
400 {"name": "since", "in": "query"}
401 ]}
402 }}}),
403 );
404 let matches = augment(root(), &surface)
405 .try_get_matches_from([
406 "tapesctl",
407 "summary",
408 "get-report",
409 "r-1",
410 "--since",
411 "yesterday",
412 "--tapes-url",
413 "http://x",
414 ])
415 .unwrap();
416
417 let (name, cassette) = matches.subcommand().unwrap();
418 assert_eq!(name, "summary");
419 let (_, method) = cassette.subcommand().unwrap();
420 assert_eq!(method.get_one::<String>("id").unwrap(), "r-1");
421 assert_eq!(method.get_one::<String>("since").unwrap(), "yesterday");
422 }
423
424 #[test]
425 fn a_missing_required_path_parameter_is_rejected_before_any_request() {
426 let surface = surface_from(
427 "summary",
428 &json!({"paths": {"/v1/cassettes/summary/reports/{id}": {
429 "get": {"operationId": "getReport"}
430 }}}),
431 );
432 assert!(
433 augment(root(), &surface)
434 .try_get_matches_from([
435 "tapesctl",
436 "summary",
437 "get-report",
438 "--tapes-url",
439 "http://x"
440 ])
441 .is_err(),
442 );
443 }
444
445 #[test]
446 fn a_required_body_is_required_and_an_absent_one_is_not_offered() {
447 let command = augment(root(), &hello_surface());
448 assert!(
449 command
450 .clone()
451 .try_get_matches_from([
452 "tapesctl",
453 "hello-world",
454 "create-hello",
455 "--tapes-url",
456 "http://x"
457 ])
458 .is_err(),
459 "a required body must be demanded up front",
460 );
461 assert!(
463 command
464 .try_get_matches_from([
465 "tapesctl",
466 "hello-world",
467 "get-hello",
468 "--body",
469 "{}",
470 "--tapes-url",
471 "http://x",
472 ])
473 .is_err(),
474 );
475 }
476
477 #[test]
478 fn a_method_that_takes_no_body_still_builds_a_call() {
479 let surface = surface_from(
483 "summary",
484 &json!({"paths": {"/v1/cassettes/summary/reports": {
485 "get": {"operationId": "listReports"}
486 }}}),
487 );
488 let matches = augment(root(), &surface)
489 .try_get_matches_from([
490 "tapesctl",
491 "summary",
492 "list-reports",
493 "--tapes-url",
494 "http://x",
495 ])
496 .unwrap();
497 let (_, cassette) = matches.subcommand().unwrap();
498 let (_, method_matches) = cassette.subcommand().unwrap();
499
500 let cassette_spec = surface.cassette("summary").unwrap();
501 let call = call_for(&cassette_spec.methods[0], method_matches).unwrap();
502 assert!(call.body.is_none());
503 }
504
505 #[test]
506 fn a_body_is_validated_as_json_before_it_is_sent() {
507 assert!(read_body("{\"a\":1}").is_ok());
509 assert!(read_body("not json").is_err());
510 }
511
512 #[test]
513 fn a_body_can_be_read_from_a_file() {
514 let dir = tempfile::tempdir().unwrap();
515 let path = dir.path().join("body.json");
516 std::fs::write(&path, "{\"hello\": \"world\"}").unwrap();
517
518 let body = read_body(&format!("@{}", path.display())).unwrap();
519 assert_eq!(body, r#"{"hello":"world"}"#);
520 assert!(read_body("@/nonexistent/body.json").is_err());
521 }
522
523 #[test]
524 fn parameters_are_sent_under_their_wire_names_not_their_flag_names() {
525 let surface = surface_from(
528 "summary",
529 &json!({"paths": {"/v1/cassettes/summary/reports": {
530 "get": {"operationId": "listReports", "parameters": [
531 {"name": "auth_subject", "in": "query"},
532 {"name": "X-Report-Kind", "in": "header"}
533 ]}
534 }}}),
535 );
536 let matches = augment(root(), &surface)
537 .try_get_matches_from([
538 "tapesctl",
539 "summary",
540 "list-reports",
541 "--auth-subject",
542 "local:me",
543 "--x-report-kind",
544 "daily",
545 "--tapes-url",
546 "http://x",
547 ])
548 .unwrap();
549 let (_, cassette) = matches.subcommand().unwrap();
550 let (_, method_matches) = cassette.subcommand().unwrap();
551
552 let cassette_spec = surface.cassette("summary").unwrap();
553 let call = call_for(&cassette_spec.methods[0], method_matches).unwrap();
554
555 assert_eq!(
556 call.query,
557 vec![("auth_subject".to_owned(), "local:me".to_owned())]
558 );
559 assert_eq!(
560 call.headers,
561 vec![("X-Report-Kind".to_owned(), "daily".to_owned())]
562 );
563 }
564
565 #[tokio::test]
566 async fn a_resolved_invocation_calls_the_route_the_spec_named() {
567 use crate::http::DirectHttp;
568 use url::Url;
569 use wiremock::matchers::{method, path, query_param};
570 use wiremock::{Mock, MockServer, ResponseTemplate};
571
572 let server = MockServer::start().await;
573 Mock::given(method("GET"))
574 .and(path("/v1/cassettes/summary/reports/r-1"))
575 .and(query_param("since", "yesterday"))
576 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"report":"r-1"}"#))
577 .mount(&server)
578 .await;
579
580 let surface = surface_from(
581 "summary",
582 &json!({"paths": {"/v1/cassettes/summary/reports/{id}": {
583 "get": {"operationId": "getReport", "parameters": [
584 {"name": "id", "in": "path", "required": true},
585 {"name": "since", "in": "query"}
586 ]}
587 }}}),
588 );
589 let matches = augment(root(), &surface)
590 .try_get_matches_from([
591 "tapesctl",
592 "summary",
593 "get-report",
594 "r-1",
595 "--since",
596 "yesterday",
597 "--tapes-url",
598 &server.uri(),
599 ])
600 .unwrap();
601 let (name, cassette_matches) = matches.subcommand().unwrap();
602
603 let (_method, call) = resolve_invocation(&surface, name, cassette_matches).unwrap();
604 let transport = DirectHttp::new(Url::parse(&server.uri()).unwrap());
605 let result = transport.execute(&call).await;
606 assert!(result.is_ok(), "got: {result:?}");
607 }
608
609 #[tokio::test]
610 async fn a_cassette_error_body_is_surfaced_rather_than_the_bare_status() {
611 use crate::http::DirectHttp;
612 use url::Url;
613 use wiremock::matchers::{method, path};
614 use wiremock::{Mock, MockServer, ResponseTemplate};
615
616 let server = MockServer::start().await;
617 Mock::given(method("GET"))
618 .and(path("/v1/cassettes/summary/reports"))
619 .respond_with(ResponseTemplate::new(502).set_body_string(
620 r#"{"error":"cassette_unavailable","message":"summary is not answering"}"#,
621 ))
622 .mount(&server)
623 .await;
624
625 let surface = surface_from(
626 "summary",
627 &json!({"paths": {"/v1/cassettes/summary/reports": {
628 "get": {"operationId": "listReports"}
629 }}}),
630 );
631 let matches = augment(root(), &surface)
632 .try_get_matches_from([
633 "tapesctl",
634 "summary",
635 "list-reports",
636 "--tapes-url",
637 &server.uri(),
638 ])
639 .unwrap();
640 let (name, cassette_matches) = matches.subcommand().unwrap();
641
642 let (_method, call) = resolve_invocation(&surface, name, cassette_matches).unwrap();
643 let transport = DirectHttp::new(Url::parse(&server.uri()).unwrap());
644 let err = transport.execute(&call).await.unwrap_err();
645 let rendered = format!("{err}");
646 assert!(rendered.contains("502"), "got: {rendered}");
647 assert!(rendered.contains("cassette_unavailable"), "got: {rendered}");
648 }
649
650 #[test]
651 fn an_unknown_method_resolves_to_an_error_not_a_guessed_call() {
652 let surface = hello_surface();
653 let matches = augment(root(), &surface)
654 .try_get_matches_from([
655 "tapesctl",
656 "hello-world",
657 "get-hello",
658 "--tapes-url",
659 "http://x",
660 ])
661 .unwrap();
662 let (_, cassette_matches) = matches.subcommand().unwrap();
663
664 let err = resolve_invocation(&surface, "absent", cassette_matches).unwrap_err();
665 assert!(err.to_string().contains("absent"), "got: {err}");
666 }
667}