Skip to main content

tapes_client/cli/
mod.rs

1//! Synthesizing clap commands from a cassette surface, and resolving them
2//! back into calls.
3//!
4//! Every generated command is built here rather than derived, because the set
5//! is not known until a server has been asked. The shape deliberately matches
6//! a consumer's hand-written surface — `<noun> <method>`, whatever shared
7//! flags the consumer decorates on, and the server's JSON printed verbatim —
8//! so a cassette command is not visibly a second-class citizen next to a
9//! hand-written one.
10//!
11//! # The consumer executes
12//!
13//! This module stops at [`resolve_invocation`], which turns a parsed match
14//! back into the [`Method`] it names and the [`Call`] to make. Executing the
15//! call and printing the response stay with the consumer: tapesctl reads its
16//! own `--tapes-url` flag (added through the [`augment`] decorator), builds
17//! its client, and prints the way its hand-written commands do.
18
19use 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
26/// The flag a request body is supplied through.
27const BODY: &str = "body";
28
29/// Add a subcommand for every cassette on the surface.
30///
31/// Cassette nouns are appended to the static ones rather than replacing them,
32/// and a cassette whose name collides with a built-in command is skipped: a
33/// server must not be able to redefine what a consumer's own command means on
34/// someone's machine.
35///
36/// `decorate` is applied to every generated method command; it is where a
37/// consumer adds the flags its dispatch reads back (tapesctl adds its
38/// `--tapes-url`, with the `TAPES_URL` env fallback).
39#[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/// The subcommand for one cassette.
63#[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        // Without a method there is nothing to call, and the help that lists
76        // them is the more useful answer than an error.
77        .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/// The subcommand for one method.
87#[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    // The route is the one piece of context a user cannot recover from the
97    // command name, and it is what makes a generated surface auditable.
98    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) = &param.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
128/// Resolve a matched cassette invocation back into the method it names and
129/// the call to make.
130///
131/// `matches` is the cassette-level match; its own subcommand names the method.
132/// Executing the returned [`Call`] — and everything about where to send it —
133/// is the consumer's.
134pub 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
160/// Assemble the request for a matched method.
161pub 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>(&param.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    // Only ask for `--body` when the operation declared one. clap panics on a
181    // lookup of an argument id the command does not define, so an unconditional
182    // read would crash every method that takes no body.
183    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
192/// Resolve a `--body` value, which is either JSON or `@<path>`.
193///
194/// The body is parsed before it is sent, not passed through: a typo in a JSON
195/// literal is otherwise reported by the cassette as a 400 whose message is about
196/// the cassette's schema rather than about the quoting mistake that caused it.
197pub 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    /// The list tapesctl reserves, which these moved tests were written
215    /// against.
216    const RESERVED: ReducerConfig<'static> = ReducerConfig {
217        reserved_flags: &["tapes-url", "body", "help", "verbose"],
218    };
219
220    /// The decorator tapesctl passes: its server flag, with the env fallback.
221    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    /// Shadow of [`super::augment`] pinning that decorator, so the moved test
233    /// bodies read exactly as they did before the extraction.
234    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        // clap panics at runtime on a malformed definition, and this crate
261        // denies panics — so a spec that produced one would be a crash the user
262        // triggers just by pointing a consumer at their own server.
263        augment(root(), &hello_surface()).debug_assert();
264    }
265
266    #[test]
267    fn a_consumer_reserving_both_spellings_still_gets_a_well_formed_command() {
268        // The adversarial case behind the reserved list's re-rewrite: the
269        // consumer's decorator defines --param-body as well as --body, and a
270        // cassette parameter named `body` must be pushed past BOTH spellings
271        // — one rewrite pass would hand clap a duplicate id and panic at
272        // command construction.
273        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        // And the rewritten flags are usable, not just panic-free.
298        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        // A server that shipped a cassette named `sessions` would otherwise
347        // change what an existing command does on the user's machine.
348        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        // The one thing a user cannot infer from the command name.
364        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        // The decorated flag is what a consumer's dispatch reads back; a
380        // method it missed would parse and then have nowhere to send the call.
381        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        // `get-hello` declares no request body, so `--body` is not a flag it has.
462        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        // clap panics on a lookup of an argument id the command does not
480        // define, so reading `--body` unconditionally crashed every method that
481        // declares none — which is most of them.
482        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        // The cassette's 400 would be about its schema, not about the quoting.
508        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        // `--auth-subject` on the command line, `auth_subject` on the wire —
526        // the same split a hand-written surface makes.
527        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}