Skip to main content

typed_openapi/
tree.rs

1//! The clap tree, and the trip back from `ArgMatches` to a sent request.
2//!
3//! The tree is two levels: one subcommand per resource the document groups its
4//! paths into, and one subcommand under that per operation — `vouchers update`
5//! rather than `update-voucher`. Both names are decided while the document is
6//! reduced and travel in the reduced model, so nothing here derives them.
7//!
8//! [`commands`] turns the document into those subcommands and [`dispatch`] runs
9//! whichever one the user typed, so an adopter who wants the generated surface
10//! and nothing else writes those two calls and renders the [`Outcome`].
11//!
12//! [`select`] and [`Selection::send`] are the same trip with a seam in the
13//! middle, for an adopter who has a check of their own to make — holding the
14//! body to a generated type, say — between what the user typed and what goes
15//! out. [`dispatch`] is the two of them in order.
16//!
17//! The flag-to-wire-name translation lives here and nowhere else: below this
18//! module the vocabulary is the document's own names, which is why a Rust
19//! caller can use the same request builder without ever meeting a flag.
20
21use std::io::Read as _;
22use std::path::{Path, PathBuf};
23
24use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint, builder::PossibleValuesParser};
25use http::Uri;
26use thiserror::Error;
27
28use crate::model::{
29    Body, COMMIT, Document, Effect, FIELD_PART, FILE_PART, Field, JSON_BODY, Location, Operation,
30    Param, RAW_BODY,
31};
32use crate::names::CommandName;
33use crate::plan::{Plan, PlanError};
34use crate::scalar::Scalar;
35use crate::transport::{HttpRequest, HttpResponse, SyncClient};
36use crate::values::{Part, Payload, Values};
37
38/// Something about the arguments the document cannot repair.
39#[derive(Debug, Error)]
40pub enum ArgError {
41    #[error("cannot read {}: {source}", path.display())]
42    ReadBody {
43        path: PathBuf,
44        #[source]
45        source: std::io::Error,
46    },
47    #[error("{} does not hold JSON: {source}", path.display())]
48    ParseBody {
49        path: PathBuf,
50        #[source]
51        source: serde_json::Error,
52    },
53    #[error("`--{flag} {raw}` is not `NAME=VALUE`")]
54    PartSyntax { flag: &'static str, raw: String },
55}
56
57/// One subcommand per group, in document order, holding the operations under
58/// it in document order.
59#[must_use]
60pub fn commands(doc: &Document) -> Vec<Command> {
61    let mut groups: Vec<(&CommandName, Vec<&Operation>)> = Vec::new();
62    for op in doc {
63        match groups.iter_mut().find(|(name, _)| *name == op.group()) {
64            Some((_, under)) => under.push(op),
65            None => groups.push((op.group(), vec![op])),
66        }
67    }
68    groups
69        .into_iter()
70        .map(|(name, under)| group(name, under))
71        .collect()
72}
73
74/// The subcommand for one group: a name the document grouped by, and every
75/// operation it grouped under it.
76fn group(name: &CommandName, under: Vec<&Operation>) -> Command {
77    Command::new(name.as_str().to_owned())
78        .about(format!("Operations on {name}"))
79        .subcommand_required(true)
80        .arg_required_else_help(true)
81        .subcommands(under.into_iter().map(command))
82}
83
84/// The subcommand for one operation.
85#[must_use]
86pub fn command(op: &Operation) -> Command {
87    let mut cmd = Command::new(op.command().as_str().to_owned());
88    if let Some(summary) = op.summary() {
89        cmd = cmd.about(summary.to_owned());
90    }
91    cmd = cmd.long_about(long_about(op));
92    for param in op.params() {
93        cmd = cmd.arg(param_arg(param));
94    }
95    cmd = body_args(cmd, op.body());
96    if op.effect() == Effect::Write {
97        cmd = cmd.arg(
98            Arg::new(COMMIT)
99                .long(COMMIT)
100                .action(ArgAction::SetTrue)
101                .help("Send the request. Without it this is a dry run that prints it"),
102        );
103    }
104    cmd
105}
106
107/// The long help: what the document says, then the wire request this
108/// subcommand stands for, so an agent can see the method and path without
109/// opening the document.
110fn long_about(op: &Operation) -> String {
111    use std::fmt::Write as _;
112
113    let mut out = String::new();
114    if let Some(text) = op.description().or_else(|| op.summary()) {
115        out.push_str(text);
116        out.push_str("\n\n");
117    }
118    let _ = write!(
119        out,
120        "{} {}  (operationId: {})",
121        op.method(),
122        op.path(),
123        op.id()
124    );
125    if op.effect() == Effect::Write {
126        out.push_str("\n\nThis operation writes. Without --commit it is a dry run.");
127    }
128    out
129}
130
131/// Did the user confirm this operation? A read carries no `--commit` flag, so
132/// there is nothing to ask it.
133#[must_use]
134pub fn confirmed(op: &Operation, matches: &ArgMatches) -> bool {
135    op.effect() == Effect::Write && matches.get_flag(COMMIT)
136}
137
138/// Read one operation's arguments out of its `ArgMatches`, under the names the
139/// document uses.
140pub fn values(op: &Operation, matches: &ArgMatches) -> Result<Values, ArgError> {
141    let mut values = Values::new();
142    for param in op.params() {
143        if let Some(raw) = matches.get_one::<String>(param.flag()) {
144            values = values.param(param.name(), raw);
145        }
146    }
147    Ok(values.body(payload(op, matches)?))
148}
149
150/// What running one operation produced.
151///
152/// The two arms are the gate's two answers, carried far enough to render: a
153/// response that came back, or the request that was not sent. Printing is the
154/// adopter's — this crate decides and executes, and hands the result over.
155#[derive(Debug)]
156pub enum Outcome {
157    /// The request went out; this is what came back, status and all. A 4xx is
158    /// an outcome, not an error: the body that came with it is what a caller
159    /// needs.
160    Sent(HttpResponse),
161    /// A write without confirmation. Nothing was sent, and this is the exact
162    /// request that a confirmed run would have sent.
163    DryRun(HttpRequest),
164}
165
166/// Why an operation the user named did not run.
167///
168/// The transport's own error is the source of [`Self::Transport`] rather than
169/// a variant of its own, so this enum does not grow a type parameter for the
170/// client and an adopter's error type can absorb it whole.
171#[derive(Debug, Error)]
172pub enum DispatchError {
173    #[error("no command given")]
174    NoCommand,
175    #[error("no operation named `{group} {command}` in the document")]
176    Unknown { group: String, command: String },
177    #[error(transparent)]
178    Arg(#[from] ArgError),
179    #[error(transparent)]
180    Plan(#[from] PlanError),
181    #[error("transport: {0}")]
182    Transport(#[source] Box<dyn std::error::Error + Send + Sync>),
183}
184
185/// One operation the user named, with its arguments read and its gate answered
186/// — everything needed to send it, and nothing sent yet.
187///
188/// This is the seam an adopter puts a check of their own into. `send` consumes
189/// it, so the request is built once and cannot be sent twice.
190#[derive(Debug)]
191pub struct Selection<'d> {
192    operation: &'d Operation,
193    values: Values,
194    confirmed: bool,
195}
196
197impl<'d> Selection<'d> {
198    /// The operation the subcommand named.
199    #[must_use]
200    pub fn operation(&self) -> &'d Operation {
201        self.operation
202    }
203
204    /// The arguments, under the document's own names rather than the flags they
205    /// arrived as. A body a generated type should vet is
206    /// `selection.values().payload()`.
207    #[must_use]
208    pub fn values(&self) -> &Values {
209        &self.values
210    }
211
212    /// Whether the gate was answered — `false` for every write the user did not
213    /// confirm, and for a read it does not apply.
214    #[must_use]
215    pub fn confirmed(&self) -> bool {
216        self.confirmed
217    }
218
219    /// Build the request, put it to the gate, and do what the gate decided.
220    pub fn send<C: SyncClient>(self, client: &C, base: &Uri) -> Result<Outcome, DispatchError> {
221        match Plan::build(self.operation, base, self.values, self.confirmed)? {
222            Plan::Send(request) => client
223                .send(request)
224                .map(Outcome::Sent)
225                .map_err(|error| DispatchError::Transport(Box::new(error))),
226            Plan::DryRun(request) => Ok(Outcome::DryRun(request)),
227        }
228    }
229}
230
231/// Read the two subcommands the user typed, whichever command the groups were
232/// mounted on.
233///
234/// `matches` belongs to that command: the root itself when the operations are
235/// the whole CLI, or the `raw` subcommand when they sit under one. This
236/// function reads the group below it and the operation below that, and never
237/// looks above it, which is what lets the same tree mount anywhere.
238pub fn select<'d>(doc: &'d Document, matches: &ArgMatches) -> Result<Selection<'d>, DispatchError> {
239    let (group, under) = matches.subcommand().ok_or(DispatchError::NoCommand)?;
240    let (command, args) = under.subcommand().ok_or(DispatchError::NoCommand)?;
241    let operation = doc
242        .by_command(group, command)
243        .ok_or_else(|| DispatchError::Unknown {
244            group: group.to_owned(),
245            command: command.to_owned(),
246        })?;
247    Ok(Selection {
248        values: values(operation, args)?,
249        confirmed: confirmed(operation, args),
250        operation,
251    })
252}
253
254/// [`select`], then [`Selection::send`]: the whole generated surface in one
255/// call, for an adopter with no check of their own to make.
256pub fn dispatch<C: SyncClient>(
257    doc: &Document,
258    base: &Uri,
259    client: &C,
260    matches: &ArgMatches,
261) -> Result<Outcome, DispatchError> {
262    select(doc, matches)?.send(client, base)
263}
264
265fn payload(op: &Operation, matches: &ArgMatches) -> Result<Option<Payload>, ArgError> {
266    match op.body() {
267        Body::None => Ok(None),
268        // A missing required body is `Invocation::new`'s to report, so that one
269        // place decides what satisfies an operation.
270        Body::JsonWhole { .. } => matches
271            .get_one::<PathBuf>(JSON_BODY)
272            .map(|path| read_json(path).map(Payload::Json))
273            .transpose(),
274        Body::Opaque { .. } => matches
275            .get_one::<PathBuf>(RAW_BODY)
276            .map(|path| read_bytes(path).map(Payload::Raw))
277            .transpose(),
278        Body::Multipart { .. } => parts(matches).map(|parts| parts.map(Payload::Multipart)),
279        Body::JsonFields(fields) => Ok(Some(Payload::Json(assembled(fields, matches)?))),
280    }
281}
282
283/// `--json-body` is the base document; per-field flags are merged over it, so a
284/// flag beside a file is an edit rather than a value the CLI silently drops.
285fn assembled(fields: &[Field], matches: &ArgMatches) -> Result<serde_json::Value, ArgError> {
286    let mut body = match matches.get_one::<PathBuf>(JSON_BODY) {
287        Some(path) => read_json(path)?,
288        None => serde_json::Value::Object(serde_json::Map::new()),
289    };
290    let Some(object) = body.as_object_mut() else {
291        return Ok(body);
292    };
293    for field in fields {
294        let Some(raw) = matches.get_one::<String>(field.flag()) else {
295            continue;
296        };
297        // The flag's value parser has already accepted this, so the same rule
298        // is not applied twice with two error shapes; the second call is a
299        // conversion, and `Invocation::new` re-checks the whole set anyway.
300        if let Ok(value) = field.scalar().parse(raw) {
301            object.insert(field.name().to_owned(), value);
302        }
303    }
304    Ok(body)
305}
306
307/// `--file NAME=PATH` and `--field NAME=VALUE`, in the order they were given.
308fn parts(matches: &ArgMatches) -> Result<Option<Vec<Part>>, ArgError> {
309    let mut parts = Vec::new();
310    for raw in strings(matches, FIELD_PART) {
311        let (name, value) = split_part(FIELD_PART, raw)?;
312        parts.push(Part::text(name, value));
313    }
314    for raw in strings(matches, FILE_PART) {
315        let (name, path) = split_part(FILE_PART, raw)?;
316        let path = PathBuf::from(path);
317        let filename = path
318            .file_name()
319            .map_or_else(|| name.to_owned(), |f| f.to_string_lossy().into_owned());
320        parts.push(Part::file(name, filename, read_bytes(&path)?));
321    }
322    Ok((!parts.is_empty()).then_some(parts))
323}
324
325fn strings<'m>(matches: &'m ArgMatches, id: &str) -> impl Iterator<Item = &'m String> {
326    matches.get_many::<String>(id).into_iter().flatten()
327}
328
329/// `NAME=REST`, splitting at the first `=` so a value may contain more.
330fn split_part<'r>(flag: &'static str, raw: &'r str) -> Result<(&'r str, &'r str), ArgError> {
331    match raw.split_once('=') {
332        Some((name, rest)) if !name.is_empty() => Ok((name, rest)),
333        Some(_) | None => Err(ArgError::PartSyntax {
334            flag,
335            raw: raw.to_owned(),
336        }),
337    }
338}
339
340fn param_arg(param: &Param) -> Arg {
341    // A document that describes nothing still knows where the value goes, and
342    // a help page with an empty line beside a flag helps nobody.
343    let described = param.description().map_or_else(
344        || {
345            Some(format!(
346                "The `{}` {} parameter",
347                param.name(),
348                match param.location() {
349                    Location::Path => "path",
350                    Location::Query => "query",
351                    Location::Header => "header",
352                }
353            ))
354        },
355        |text| Some(text.to_owned()),
356    );
357    value_arg(
358        param.flag(),
359        param.scalar(),
360        help_line(
361            described.as_deref(),
362            param.scalar(),
363            wire(param.renamed(), param.name()),
364        ),
365        param.required(),
366    )
367}
368
369/// A flag that had to move aside says which wire name it carries.
370fn wire(renamed: bool, name: &str) -> Option<String> {
371    renamed.then(|| format!("sends `{name}`"))
372}
373
374fn body_args(cmd: Command, body: &Body) -> Command {
375    match body {
376        Body::None => cmd,
377        Body::JsonFields(fields) => json_field_args(cmd, fields),
378        Body::JsonWhole { required } => cmd.arg(file_arg(JSON_BODY, *required).help(
379            "JSON body read from a file; `-` is stdin. This body is nested, so it has \
380             no per-field flags",
381        )),
382        Body::Multipart { names, required } => multipart_args(cmd, names, *required),
383        Body::Opaque {
384            media_type,
385            required,
386        } => cmd.arg(file_arg(RAW_BODY, *required).help(format!(
387            "Request body read from a file, sent verbatim as `{media_type}`; `-` is stdin. \
388             This CLI does not assemble that media type"
389        ))),
390    }
391}
392
393/// One flag per scalar property, and `--json-body` as the base document they
394/// are merged over.
395fn json_field_args(cmd: Command, fields: &[Field]) -> Command {
396    let mut cmd = cmd;
397    for field in fields {
398        let mut arg = value_arg(
399            field.flag(),
400            field.scalar(),
401            help_line(
402                field.description(),
403                field.scalar(),
404                wire(field.renamed(), field.name()),
405            ),
406            false,
407        );
408        if field.required() {
409            arg = arg.required_unless_present(JSON_BODY);
410        }
411        cmd = cmd.arg(arg);
412    }
413    cmd.arg(file_arg(JSON_BODY, false).help(
414        "JSON body read from a file; `-` is stdin. It is the base document: \
415         the per-field flags above are merged over it, so a flag wins over \
416         the same key in the file",
417    ))
418}
419
420/// `--file` and `--field`, repeatable. The part names are the document's, but
421/// they are advice rather than a constraint: a document that declares none (or
422/// declares them wrongly) is common, and refusing would help nobody.
423fn multipart_args(cmd: Command, names: &[String], required: bool) -> Command {
424    let declared = if names.is_empty() {
425        String::new()
426    } else {
427        format!(" The document declares: {}.", names.join(", "))
428    };
429    cmd.arg(
430        Arg::new(FILE_PART)
431            .long(FILE_PART)
432            .value_name("NAME=PATH")
433            .action(ArgAction::Append)
434            .required(required)
435            .value_hint(ValueHint::Other)
436            .help(format!(
437                "One file part of the multipart body, repeatable.{declared}"
438            )),
439    )
440    .arg(
441        Arg::new(FIELD_PART)
442            .long(FIELD_PART)
443            .value_name("NAME=VALUE")
444            .action(ArgAction::Append)
445            .value_hint(ValueHint::Other)
446            .help("One text part of the multipart body, repeatable"),
447    )
448}
449
450fn value_arg(flag: &str, scalar: &Scalar, help: Option<String>, required: bool) -> Arg {
451    let mut arg = Arg::new(flag.to_owned())
452        .long(flag.to_owned())
453        .value_name(scalar.value_name())
454        .required(required)
455        // Never `FilePath`: a value flag is not a path, and a dynamic completer
456        // that offers the working directory for `--currency` is worse than one
457        // that offers nothing.
458        .value_hint(ValueHint::Other);
459    // The document's rule runs at parse time, so a bad amount reports itself
460    // the way a bad enum value does — one error shape for one kind of mistake.
461    arg = if let Scalar::Choice(values) = scalar {
462        arg.value_parser(PossibleValuesParser::new(values))
463    } else {
464        let scalar = scalar.clone();
465        arg.value_parser(move |raw: &str| {
466            scalar
467                .parse(raw)
468                .map(|_| raw.to_owned())
469                .map_err(|e| e.to_string())
470        })
471    };
472    if let Some(help) = help {
473        arg = arg.help(help);
474    }
475    arg
476}
477
478fn file_arg(flag: &'static str, required: bool) -> Arg {
479    Arg::new(flag)
480        .long(flag)
481        .value_name("FILE")
482        .required(required)
483        .value_parser(clap::value_parser!(PathBuf))
484        .value_hint(ValueHint::FilePath)
485}
486
487/// The description, then whatever the document constrains, then the wire name
488/// if the flag had to move aside — each in brackets, none of them invented.
489fn help_line(description: Option<&str>, scalar: &Scalar, wire: Option<String>) -> Option<String> {
490    let notes: Vec<String> = scalar.note().into_iter().chain(wire).collect();
491    match (description, notes.is_empty()) {
492        (Some(text), true) => Some(text.to_owned()),
493        (Some(text), false) => Some(format!("{text} ({})", notes.join("; "))),
494        (None, true) => None,
495        (None, false) => Some(notes.join("; ")),
496    }
497}
498
499fn read_json(path: &Path) -> Result<serde_json::Value, ArgError> {
500    let bytes = read_bytes(path)?;
501    serde_json::from_slice(&bytes).map_err(|source| ArgError::ParseBody {
502        path: path.to_owned(),
503        source,
504    })
505}
506
507fn read_bytes(path: &Path) -> Result<Vec<u8>, ArgError> {
508    let read = |source| ArgError::ReadBody {
509        path: path.to_owned(),
510        source,
511    };
512    if path == Path::new("-") {
513        let mut bytes = Vec::new();
514        std::io::stdin().read_to_end(&mut bytes).map_err(read)?;
515        return Ok(bytes);
516    }
517    std::fs::read(path).map_err(read)
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn a_part_splits_at_the_first_equals_only() {
526        assert_eq!(
527            split_part(FILE_PART, "file=a=b").ok(),
528            Some(("file", "a=b"))
529        );
530        assert_eq!(split_part(FIELD_PART, "k=").ok(), Some(("k", "")));
531        assert!(split_part(FILE_PART, "nope").is_err());
532        assert!(split_part(FILE_PART, "=path").is_err());
533    }
534}