1use 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, Gate, JSON_BODY, Location,
30 Operation, Param, RAW_BODY, Shape,
31};
32use crate::names::{CommandName, renamed};
33use crate::plan::{Answers, Plan, PlanError};
34use crate::scalar::Scalar;
35use crate::transport::{HttpRequest, HttpResponse, SyncClient};
36use crate::values::{Part, Payload, Values};
37
38#[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#[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
74fn 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#[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 arg in op.params().iter().filter_map(param_arg) {
93 cmd = cmd.arg(arg);
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 gates(cmd, op)
105}
106
107#[must_use]
123pub fn gates(cmd: Command, op: &Operation) -> Command {
124 op.gates()
125 .iter()
126 .fold(cmd, |cmd, gate| cmd.arg(gate_arg(gate)))
127}
128
129fn gate_arg(gate: &Gate) -> Arg {
136 Arg::new(gate.as_str().to_owned())
137 .long(gate.as_str().to_owned())
138 .action(ArgAction::SetTrue)
139 .required(true)
140 .help(format!(
141 "Required, and demanded in addition to --commit: this operation \
142 stands behind the `{gate}` gate"
143 ))
144}
145
146fn long_about(op: &Operation) -> String {
150 use std::fmt::Write as _;
151
152 let mut out = String::new();
153 if let Some(text) = op.description().or_else(|| op.summary()) {
154 out.push_str(text);
155 out.push_str("\n\n");
156 }
157 let _ = write!(
158 out,
159 "{} {} (operationId: {})",
160 op.method(),
161 op.path(),
162 op.id()
163 );
164 if op.effect() == Effect::Write {
165 out.push_str("\n\nThis operation writes. Without --commit it is a dry run.");
166 }
167 if !op.gates().is_empty() {
168 let named: Vec<String> = op.gates().iter().map(|gate| format!("--{gate}")).collect();
169 let _ = write!(
170 out,
171 "\n\nNamed gates: {}. Each one is required, and demanded in \
172 addition to --commit.",
173 named.join(", ")
174 );
175 }
176 for param in op.params() {
180 if let Shape::Unreachable(why) = param.shape() {
181 let _ = write!(
182 out,
183 "\n\n`{}` has no flag: it is {why}. The request goes out without it.",
184 param.name()
185 );
186 }
187 }
188 out
189}
190
191#[must_use]
202pub fn answers(op: &Operation, matches: &ArgMatches) -> Answers {
203 if op.effect() == Effect::Read {
204 return Answers::new();
205 }
206 let mut answered = Answers::new();
207 if flag(matches, COMMIT) {
208 answered = answered.commit();
209 }
210 for gate in op.gates() {
211 if flag(matches, gate.as_str()) {
212 answered = answered.gate(gate.as_str());
213 }
214 }
215 answered
216}
217
218fn flag(matches: &ArgMatches, id: &str) -> bool {
225 matches
226 .try_get_one::<bool>(id)
227 .ok()
228 .flatten()
229 .copied()
230 .unwrap_or(false)
231}
232
233pub fn values(op: &Operation, matches: &ArgMatches) -> Result<Values, ArgError> {
236 let mut values = Values::new();
237 for param in op.params() {
238 let Shape::Flag { flag, .. } = param.shape() else {
239 continue;
240 };
241 for raw in strings(matches, flag) {
245 values = values.param(param.name(), raw);
246 }
247 }
248 Ok(values.body(payload(op, matches)?))
249}
250
251#[derive(Debug)]
257pub enum Outcome {
258 Sent(HttpResponse),
262 DryRun(HttpRequest),
265}
266
267#[derive(Debug, Error)]
273pub enum DispatchError {
274 #[error("no command given")]
275 NoCommand,
276 #[error("no operation named `{group} {command}` in the document")]
277 Unknown { group: String, command: String },
278 #[error(transparent)]
279 Arg(#[from] ArgError),
280 #[error(transparent)]
281 Plan(#[from] PlanError),
282 #[error("transport: {0}")]
302 Transport(#[source] Box<dyn std::error::Error + Send + Sync>),
303}
304
305#[derive(Debug)]
311pub struct Selection<'d> {
312 operation: &'d Operation,
313 values: Values,
314 answers: Answers,
315}
316
317impl<'d> Selection<'d> {
318 #[must_use]
320 pub fn operation(&self) -> &'d Operation {
321 self.operation
322 }
323
324 #[must_use]
328 pub fn values(&self) -> &Values {
329 &self.values
330 }
331
332 #[must_use]
336 pub fn answers(&self) -> &Answers {
337 &self.answers
338 }
339
340 pub fn send<C: SyncClient>(self, client: &C, base: &Uri) -> Result<Outcome, DispatchError> {
342 match Plan::build(self.operation, base, self.values, &self.answers)? {
343 Plan::Send(request) => client
344 .send(request)
345 .map(Outcome::Sent)
346 .map_err(|error| DispatchError::Transport(Box::new(error))),
347 Plan::DryRun(request) => Ok(Outcome::DryRun(request)),
348 }
349 }
350}
351
352pub fn select<'d>(doc: &'d Document, matches: &ArgMatches) -> Result<Selection<'d>, DispatchError> {
360 let (group, under) = matches.subcommand().ok_or(DispatchError::NoCommand)?;
361 let (command, args) = under.subcommand().ok_or(DispatchError::NoCommand)?;
362 let operation = doc
363 .by_command(group, command)
364 .ok_or_else(|| DispatchError::Unknown {
365 group: group.to_owned(),
366 command: command.to_owned(),
367 })?;
368 Ok(Selection {
369 values: values(operation, args)?,
370 answers: answers(operation, args),
371 operation,
372 })
373}
374
375pub fn dispatch<C: SyncClient>(
378 doc: &Document,
379 base: &Uri,
380 client: &C,
381 matches: &ArgMatches,
382) -> Result<Outcome, DispatchError> {
383 select(doc, matches)?.send(client, base)
384}
385
386fn payload(op: &Operation, matches: &ArgMatches) -> Result<Option<Payload>, ArgError> {
387 match op.body() {
388 Body::None => Ok(None),
389 Body::JsonWhole { .. } => matches
392 .get_one::<PathBuf>(JSON_BODY)
393 .map(|path| read_json(path).map(Payload::Json))
394 .transpose(),
395 Body::Opaque { .. } => matches
396 .get_one::<PathBuf>(RAW_BODY)
397 .map(|path| read_bytes(path).map(Payload::Raw))
398 .transpose(),
399 Body::Multipart { .. } => parts(matches).map(|parts| parts.map(Payload::Multipart)),
400 Body::JsonFields(fields) => Ok(Some(Payload::Json(assembled(fields, matches)?))),
401 }
402}
403
404fn assembled(fields: &[Field], matches: &ArgMatches) -> Result<serde_json::Value, ArgError> {
407 let mut body = match matches.get_one::<PathBuf>(JSON_BODY) {
408 Some(path) => read_json(path)?,
409 None => serde_json::Value::Object(serde_json::Map::new()),
410 };
411 let Some(object) = body.as_object_mut() else {
412 return Ok(body);
413 };
414 for field in fields {
415 let Some(raw) = matches.get_one::<String>(field.flag()) else {
416 continue;
417 };
418 if let Ok(value) = field.scalar().parse(raw) {
422 object.insert(field.name().to_owned(), value);
423 }
424 }
425 Ok(body)
426}
427
428fn parts(matches: &ArgMatches) -> Result<Option<Vec<Part>>, ArgError> {
430 let mut parts = Vec::new();
431 for raw in strings(matches, FIELD_PART) {
432 let (name, value) = split_part(FIELD_PART, raw)?;
433 parts.push(Part::text(name, value));
434 }
435 for raw in strings(matches, FILE_PART) {
436 let (name, path) = split_part(FILE_PART, raw)?;
437 let path = PathBuf::from(path);
438 let filename = path
439 .file_name()
440 .map_or_else(|| name.to_owned(), |f| f.to_string_lossy().into_owned());
441 parts.push(Part::file(name, filename, read_bytes(&path)?));
442 }
443 Ok((!parts.is_empty()).then_some(parts))
444}
445
446fn strings<'m>(matches: &'m ArgMatches, id: &str) -> impl Iterator<Item = &'m String> {
447 matches.get_many::<String>(id).into_iter().flatten()
448}
449
450fn split_part<'r>(flag: &'static str, raw: &'r str) -> Result<(&'r str, &'r str), ArgError> {
452 match raw.split_once('=') {
453 Some((name, rest)) if !name.is_empty() => Ok((name, rest)),
454 Some(_) | None => Err(ArgError::PartSyntax {
455 flag,
456 raw: raw.to_owned(),
457 }),
458 }
459}
460
461fn param_arg(param: &Param) -> Option<Arg> {
465 let Shape::Flag {
466 flag,
467 location,
468 scalar,
469 join,
470 } = param.shape()
471 else {
472 return None;
473 };
474 let described = param.description().map_or_else(
477 || {
478 Some(format!(
479 "The `{}` {} parameter",
480 param.name(),
481 match location {
482 Location::Path => "path",
483 Location::Query => "query",
484 Location::Header => "header",
485 }
486 ))
487 },
488 |text| Some(text.to_owned()),
489 );
490 let notes = join
491 .map(|join| join.note().to_owned())
492 .into_iter()
493 .chain(wire(renamed(flag, param.name()), param.name()));
494 let mut arg = value_arg(
495 flag,
496 scalar,
497 help_line(described.as_deref(), scalar, notes),
498 param.required(),
499 );
500 if join.is_some() {
501 arg = arg.action(ArgAction::Append);
502 }
503 Some(arg)
504}
505
506fn wire(renamed: bool, name: &str) -> Option<String> {
508 renamed.then(|| format!("sends `{name}`"))
509}
510
511fn body_args(cmd: Command, body: &Body) -> Command {
512 match body {
513 Body::None => cmd,
514 Body::JsonFields(fields) => json_field_args(cmd, fields),
515 Body::JsonWhole { required } => cmd.arg(file_arg(JSON_BODY, *required).help(
516 "JSON body read from a file; `-` is stdin. This body is nested, so it has \
517 no per-field flags",
518 )),
519 Body::Multipart { names, required } => multipart_args(cmd, names, *required),
520 Body::Opaque {
521 media_type,
522 required,
523 } => cmd.arg(file_arg(RAW_BODY, *required).help(format!(
524 "Request body read from a file, sent verbatim as `{media_type}`; `-` is stdin. \
525 This CLI does not assemble that media type"
526 ))),
527 }
528}
529
530fn json_field_args(cmd: Command, fields: &[Field]) -> Command {
533 let mut cmd = cmd;
534 for field in fields {
535 let mut arg = value_arg(
536 field.flag(),
537 field.scalar(),
538 help_line(
539 field.description(),
540 field.scalar(),
541 wire(field.renamed(), field.name()),
542 ),
543 false,
544 );
545 if field.required() {
546 arg = arg.required_unless_present(JSON_BODY);
547 }
548 cmd = cmd.arg(arg);
549 }
550 cmd.arg(file_arg(JSON_BODY, false).help(
551 "JSON body read from a file; `-` is stdin. It is the base document: \
552 the per-field flags above are merged over it, so a flag wins over \
553 the same key in the file",
554 ))
555}
556
557fn multipart_args(cmd: Command, names: &[String], required: bool) -> Command {
561 let declared = if names.is_empty() {
562 String::new()
563 } else {
564 format!(" The document declares: {}.", names.join(", "))
565 };
566 cmd.arg(
567 Arg::new(FILE_PART)
568 .long(FILE_PART)
569 .value_name("NAME=PATH")
570 .action(ArgAction::Append)
571 .required(required)
572 .value_hint(ValueHint::Other)
573 .help(format!(
574 "One file part of the multipart body, repeatable.{declared}"
575 )),
576 )
577 .arg(
578 Arg::new(FIELD_PART)
579 .long(FIELD_PART)
580 .value_name("NAME=VALUE")
581 .action(ArgAction::Append)
582 .value_hint(ValueHint::Other)
583 .help("One text part of the multipart body, repeatable"),
584 )
585}
586
587fn value_arg(flag: &str, scalar: &Scalar, help: Option<String>, required: bool) -> Arg {
588 let mut arg = Arg::new(flag.to_owned())
589 .long(flag.to_owned())
590 .value_name(scalar.value_name())
591 .required(required)
592 .value_hint(ValueHint::Other);
596 arg = if let Scalar::Choice(values) = scalar {
599 arg.value_parser(PossibleValuesParser::new(values))
600 } else {
601 let scalar = scalar.clone();
602 arg.value_parser(move |raw: &str| {
603 scalar
604 .parse(raw)
605 .map(|_| raw.to_owned())
606 .map_err(|e| e.to_string())
607 })
608 };
609 if let Some(help) = help {
610 arg = arg.help(help);
611 }
612 arg
613}
614
615fn file_arg(flag: &'static str, required: bool) -> Arg {
616 Arg::new(flag)
617 .long(flag)
618 .value_name("FILE")
619 .required(required)
620 .value_parser(clap::value_parser!(PathBuf))
621 .value_hint(ValueHint::FilePath)
622}
623
624fn help_line(
628 description: Option<&str>,
629 scalar: &Scalar,
630 extra: impl IntoIterator<Item = String>,
631) -> Option<String> {
632 let notes: Vec<String> = scalar.note().into_iter().chain(extra).collect();
633 match (description, notes.is_empty()) {
634 (Some(text), true) => Some(text.to_owned()),
635 (Some(text), false) => Some(format!("{text} ({})", notes.join("; "))),
636 (None, true) => None,
637 (None, false) => Some(notes.join("; ")),
638 }
639}
640
641fn read_json(path: &Path) -> Result<serde_json::Value, ArgError> {
642 let bytes = read_bytes(path)?;
643 serde_json::from_slice(&bytes).map_err(|source| ArgError::ParseBody {
644 path: path.to_owned(),
645 source,
646 })
647}
648
649fn read_bytes(path: &Path) -> Result<Vec<u8>, ArgError> {
650 let read = |source| ArgError::ReadBody {
651 path: path.to_owned(),
652 source,
653 };
654 if path == Path::new("-") {
655 let mut bytes = Vec::new();
656 std::io::stdin().read_to_end(&mut bytes).map_err(read)?;
657 return Ok(bytes);
658 }
659 std::fs::read(path).map_err(read)
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 #[test]
667 fn a_part_splits_at_the_first_equals_only() {
668 assert_eq!(
669 split_part(FILE_PART, "file=a=b").ok(),
670 Some(("file", "a=b"))
671 );
672 assert_eq!(split_part(FIELD_PART, "k=").ok(), Some(("k", "")));
673 assert!(split_part(FILE_PART, "nope").is_err());
674 assert!(split_part(FILE_PART, "=path").is_err());
675 }
676}