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, 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#[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 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
107fn 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#[must_use]
134pub fn confirmed(op: &Operation, matches: &ArgMatches) -> bool {
135 op.effect() == Effect::Write && matches.get_flag(COMMIT)
136}
137
138pub 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#[derive(Debug)]
156pub enum Outcome {
157 Sent(HttpResponse),
161 DryRun(HttpRequest),
164}
165
166#[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#[derive(Debug)]
191pub struct Selection<'d> {
192 operation: &'d Operation,
193 values: Values,
194 confirmed: bool,
195}
196
197impl<'d> Selection<'d> {
198 #[must_use]
200 pub fn operation(&self) -> &'d Operation {
201 self.operation
202 }
203
204 #[must_use]
208 pub fn values(&self) -> &Values {
209 &self.values
210 }
211
212 #[must_use]
215 pub fn confirmed(&self) -> bool {
216 self.confirmed
217 }
218
219 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
231pub 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
254pub 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 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
283fn 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 if let Ok(value) = field.scalar().parse(raw) {
301 object.insert(field.name().to_owned(), value);
302 }
303 }
304 Ok(body)
305}
306
307fn 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
329fn 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 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
369fn 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
393fn 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
420fn 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 .value_hint(ValueHint::Other);
459 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
487fn 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}