use std::io::Read as _;
use std::path::{Path, PathBuf};
use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint, builder::PossibleValuesParser};
use http::Uri;
use thiserror::Error;
use crate::model::{
Body, COMMIT, Document, Effect, FIELD_PART, FILE_PART, Field, JSON_BODY, Location, Operation,
Param, RAW_BODY,
};
use crate::names::CommandName;
use crate::plan::{Plan, PlanError};
use crate::scalar::Scalar;
use crate::transport::{HttpRequest, HttpResponse, SyncClient};
use crate::values::{Part, Payload, Values};
#[derive(Debug, Error)]
pub enum ArgError {
#[error("cannot read {}: {source}", path.display())]
ReadBody {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{} does not hold JSON: {source}", path.display())]
ParseBody {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("`--{flag} {raw}` is not `NAME=VALUE`")]
PartSyntax { flag: &'static str, raw: String },
}
#[must_use]
pub fn commands(doc: &Document) -> Vec<Command> {
let mut groups: Vec<(&CommandName, Vec<&Operation>)> = Vec::new();
for op in doc {
match groups.iter_mut().find(|(name, _)| *name == op.group()) {
Some((_, under)) => under.push(op),
None => groups.push((op.group(), vec![op])),
}
}
groups
.into_iter()
.map(|(name, under)| group(name, under))
.collect()
}
fn group(name: &CommandName, under: Vec<&Operation>) -> Command {
Command::new(name.as_str().to_owned())
.about(format!("Operations on {name}"))
.subcommand_required(true)
.arg_required_else_help(true)
.subcommands(under.into_iter().map(command))
}
#[must_use]
pub fn command(op: &Operation) -> Command {
let mut cmd = Command::new(op.command().as_str().to_owned());
if let Some(summary) = op.summary() {
cmd = cmd.about(summary.to_owned());
}
cmd = cmd.long_about(long_about(op));
for param in op.params() {
cmd = cmd.arg(param_arg(param));
}
cmd = body_args(cmd, op.body());
if op.effect() == Effect::Write {
cmd = cmd.arg(
Arg::new(COMMIT)
.long(COMMIT)
.action(ArgAction::SetTrue)
.help("Send the request. Without it this is a dry run that prints it"),
);
}
cmd
}
fn long_about(op: &Operation) -> String {
use std::fmt::Write as _;
let mut out = String::new();
if let Some(text) = op.description().or_else(|| op.summary()) {
out.push_str(text);
out.push_str("\n\n");
}
let _ = write!(
out,
"{} {} (operationId: {})",
op.method(),
op.path(),
op.id()
);
if op.effect() == Effect::Write {
out.push_str("\n\nThis operation writes. Without --commit it is a dry run.");
}
out
}
#[must_use]
pub fn confirmed(op: &Operation, matches: &ArgMatches) -> bool {
op.effect() == Effect::Write && matches.get_flag(COMMIT)
}
pub fn values(op: &Operation, matches: &ArgMatches) -> Result<Values, ArgError> {
let mut values = Values::new();
for param in op.params() {
if let Some(raw) = matches.get_one::<String>(param.flag()) {
values = values.param(param.name(), raw);
}
}
Ok(values.body(payload(op, matches)?))
}
#[derive(Debug)]
pub enum Outcome {
Sent(HttpResponse),
DryRun(HttpRequest),
}
#[derive(Debug, Error)]
pub enum DispatchError {
#[error("no command given")]
NoCommand,
#[error("no operation named `{group} {command}` in the document")]
Unknown { group: String, command: String },
#[error(transparent)]
Arg(#[from] ArgError),
#[error(transparent)]
Plan(#[from] PlanError),
#[error("transport: {0}")]
Transport(#[source] Box<dyn std::error::Error + Send + Sync>),
}
#[derive(Debug)]
pub struct Selection<'d> {
operation: &'d Operation,
values: Values,
confirmed: bool,
}
impl<'d> Selection<'d> {
#[must_use]
pub fn operation(&self) -> &'d Operation {
self.operation
}
#[must_use]
pub fn values(&self) -> &Values {
&self.values
}
#[must_use]
pub fn confirmed(&self) -> bool {
self.confirmed
}
pub fn send<C: SyncClient>(self, client: &C, base: &Uri) -> Result<Outcome, DispatchError> {
match Plan::build(self.operation, base, self.values, self.confirmed)? {
Plan::Send(request) => client
.send(request)
.map(Outcome::Sent)
.map_err(|error| DispatchError::Transport(Box::new(error))),
Plan::DryRun(request) => Ok(Outcome::DryRun(request)),
}
}
}
pub fn select<'d>(doc: &'d Document, matches: &ArgMatches) -> Result<Selection<'d>, DispatchError> {
let (group, under) = matches.subcommand().ok_or(DispatchError::NoCommand)?;
let (command, args) = under.subcommand().ok_or(DispatchError::NoCommand)?;
let operation = doc
.by_command(group, command)
.ok_or_else(|| DispatchError::Unknown {
group: group.to_owned(),
command: command.to_owned(),
})?;
Ok(Selection {
values: values(operation, args)?,
confirmed: confirmed(operation, args),
operation,
})
}
pub fn dispatch<C: SyncClient>(
doc: &Document,
base: &Uri,
client: &C,
matches: &ArgMatches,
) -> Result<Outcome, DispatchError> {
select(doc, matches)?.send(client, base)
}
fn payload(op: &Operation, matches: &ArgMatches) -> Result<Option<Payload>, ArgError> {
match op.body() {
Body::None => Ok(None),
Body::JsonWhole { .. } => matches
.get_one::<PathBuf>(JSON_BODY)
.map(|path| read_json(path).map(Payload::Json))
.transpose(),
Body::Opaque { .. } => matches
.get_one::<PathBuf>(RAW_BODY)
.map(|path| read_bytes(path).map(Payload::Raw))
.transpose(),
Body::Multipart { .. } => parts(matches).map(|parts| parts.map(Payload::Multipart)),
Body::JsonFields(fields) => Ok(Some(Payload::Json(assembled(fields, matches)?))),
}
}
fn assembled(fields: &[Field], matches: &ArgMatches) -> Result<serde_json::Value, ArgError> {
let mut body = match matches.get_one::<PathBuf>(JSON_BODY) {
Some(path) => read_json(path)?,
None => serde_json::Value::Object(serde_json::Map::new()),
};
let Some(object) = body.as_object_mut() else {
return Ok(body);
};
for field in fields {
let Some(raw) = matches.get_one::<String>(field.flag()) else {
continue;
};
if let Ok(value) = field.scalar().parse(raw) {
object.insert(field.name().to_owned(), value);
}
}
Ok(body)
}
fn parts(matches: &ArgMatches) -> Result<Option<Vec<Part>>, ArgError> {
let mut parts = Vec::new();
for raw in strings(matches, FIELD_PART) {
let (name, value) = split_part(FIELD_PART, raw)?;
parts.push(Part::text(name, value));
}
for raw in strings(matches, FILE_PART) {
let (name, path) = split_part(FILE_PART, raw)?;
let path = PathBuf::from(path);
let filename = path
.file_name()
.map_or_else(|| name.to_owned(), |f| f.to_string_lossy().into_owned());
parts.push(Part::file(name, filename, read_bytes(&path)?));
}
Ok((!parts.is_empty()).then_some(parts))
}
fn strings<'m>(matches: &'m ArgMatches, id: &str) -> impl Iterator<Item = &'m String> {
matches.get_many::<String>(id).into_iter().flatten()
}
fn split_part<'r>(flag: &'static str, raw: &'r str) -> Result<(&'r str, &'r str), ArgError> {
match raw.split_once('=') {
Some((name, rest)) if !name.is_empty() => Ok((name, rest)),
Some(_) | None => Err(ArgError::PartSyntax {
flag,
raw: raw.to_owned(),
}),
}
}
fn param_arg(param: &Param) -> Arg {
let described = param.description().map_or_else(
|| {
Some(format!(
"The `{}` {} parameter",
param.name(),
match param.location() {
Location::Path => "path",
Location::Query => "query",
Location::Header => "header",
}
))
},
|text| Some(text.to_owned()),
);
value_arg(
param.flag(),
param.scalar(),
help_line(
described.as_deref(),
param.scalar(),
wire(param.renamed(), param.name()),
),
param.required(),
)
}
fn wire(renamed: bool, name: &str) -> Option<String> {
renamed.then(|| format!("sends `{name}`"))
}
fn body_args(cmd: Command, body: &Body) -> Command {
match body {
Body::None => cmd,
Body::JsonFields(fields) => json_field_args(cmd, fields),
Body::JsonWhole { required } => cmd.arg(file_arg(JSON_BODY, *required).help(
"JSON body read from a file; `-` is stdin. This body is nested, so it has \
no per-field flags",
)),
Body::Multipart { names, required } => multipart_args(cmd, names, *required),
Body::Opaque {
media_type,
required,
} => cmd.arg(file_arg(RAW_BODY, *required).help(format!(
"Request body read from a file, sent verbatim as `{media_type}`; `-` is stdin. \
This CLI does not assemble that media type"
))),
}
}
fn json_field_args(cmd: Command, fields: &[Field]) -> Command {
let mut cmd = cmd;
for field in fields {
let mut arg = value_arg(
field.flag(),
field.scalar(),
help_line(
field.description(),
field.scalar(),
wire(field.renamed(), field.name()),
),
false,
);
if field.required() {
arg = arg.required_unless_present(JSON_BODY);
}
cmd = cmd.arg(arg);
}
cmd.arg(file_arg(JSON_BODY, false).help(
"JSON body read from a file; `-` is stdin. It is the base document: \
the per-field flags above are merged over it, so a flag wins over \
the same key in the file",
))
}
fn multipart_args(cmd: Command, names: &[String], required: bool) -> Command {
let declared = if names.is_empty() {
String::new()
} else {
format!(" The document declares: {}.", names.join(", "))
};
cmd.arg(
Arg::new(FILE_PART)
.long(FILE_PART)
.value_name("NAME=PATH")
.action(ArgAction::Append)
.required(required)
.value_hint(ValueHint::Other)
.help(format!(
"One file part of the multipart body, repeatable.{declared}"
)),
)
.arg(
Arg::new(FIELD_PART)
.long(FIELD_PART)
.value_name("NAME=VALUE")
.action(ArgAction::Append)
.value_hint(ValueHint::Other)
.help("One text part of the multipart body, repeatable"),
)
}
fn value_arg(flag: &str, scalar: &Scalar, help: Option<String>, required: bool) -> Arg {
let mut arg = Arg::new(flag.to_owned())
.long(flag.to_owned())
.value_name(scalar.value_name())
.required(required)
.value_hint(ValueHint::Other);
arg = if let Scalar::Choice(values) = scalar {
arg.value_parser(PossibleValuesParser::new(values))
} else {
let scalar = scalar.clone();
arg.value_parser(move |raw: &str| {
scalar
.parse(raw)
.map(|_| raw.to_owned())
.map_err(|e| e.to_string())
})
};
if let Some(help) = help {
arg = arg.help(help);
}
arg
}
fn file_arg(flag: &'static str, required: bool) -> Arg {
Arg::new(flag)
.long(flag)
.value_name("FILE")
.required(required)
.value_parser(clap::value_parser!(PathBuf))
.value_hint(ValueHint::FilePath)
}
fn help_line(description: Option<&str>, scalar: &Scalar, wire: Option<String>) -> Option<String> {
let notes: Vec<String> = scalar.note().into_iter().chain(wire).collect();
match (description, notes.is_empty()) {
(Some(text), true) => Some(text.to_owned()),
(Some(text), false) => Some(format!("{text} ({})", notes.join("; "))),
(None, true) => None,
(None, false) => Some(notes.join("; ")),
}
}
fn read_json(path: &Path) -> Result<serde_json::Value, ArgError> {
let bytes = read_bytes(path)?;
serde_json::from_slice(&bytes).map_err(|source| ArgError::ParseBody {
path: path.to_owned(),
source,
})
}
fn read_bytes(path: &Path) -> Result<Vec<u8>, ArgError> {
let read = |source| ArgError::ReadBody {
path: path.to_owned(),
source,
};
if path == Path::new("-") {
let mut bytes = Vec::new();
std::io::stdin().read_to_end(&mut bytes).map_err(read)?;
return Ok(bytes);
}
std::fs::read(path).map_err(read)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_part_splits_at_the_first_equals_only() {
assert_eq!(
split_part(FILE_PART, "file=a=b").ok(),
Some(("file", "a=b"))
);
assert_eq!(split_part(FIELD_PART, "k=").ok(), Some(("k", "")));
assert!(split_part(FILE_PART, "nope").is_err());
assert!(split_part(FILE_PART, "=path").is_err());
}
}