use std::{
collections::HashSet,
fs::{self, File},
io,
path::{Path, PathBuf},
str::FromStr,
};
use anyhow::{anyhow, Result};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::{blocking::multipart, Method};
use crate::cli::BodyType;
use crate::nested_json;
use crate::utils::{expand_tilde, unescape};
pub const FORM_CONTENT_TYPE: &str = "application/x-www-form-urlencoded";
pub const JSON_CONTENT_TYPE: &str = "application/json";
pub const JSON_ACCEPT: &str = "application/json, */*;q=0.5";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequestItem {
HttpHeader(String, String),
HttpHeaderToUnset(String),
UrlParam(String, String),
DataField {
key: String,
raw_key: String,
value: String,
},
DataFieldFromFile {
key: String,
raw_key: String,
value: String,
},
JsonField(String, serde_json::Value),
JsonFieldFromFile(String, String),
FormFile {
key: String,
file_name: String,
file_type: Option<String>,
file_name_header: Option<String>,
},
}
impl FromStr for RequestItem {
type Err = clap::Error;
fn from_str(request_item: &str) -> clap::Result<RequestItem> {
const SPECIAL_CHARS: &str = "=@:;\\";
const SEPS: &[&str] = &["=@", ":=@", "==", ":=", "=", "@", ":"];
fn split(request_item: &str) -> Option<(&str, &'static str, &str)> {
let mut char_inds = request_item.char_indices();
while let Some((ind, ch)) = char_inds.next() {
if ch == '\\' {
char_inds.next();
continue;
}
for sep in SEPS {
if let Some(value) = request_item[ind..].strip_prefix(sep) {
let key = &request_item[..ind];
return Some((key, sep, value));
}
}
}
None
}
if let Some((raw_key, sep, value)) = split(request_item) {
let raw_key = raw_key.to_string();
let key = unescape(&raw_key, SPECIAL_CHARS);
let value = unescape(value, SPECIAL_CHARS);
match sep {
"==" => Ok(RequestItem::UrlParam(key, value)),
"=" => Ok(RequestItem::DataField {
key,
raw_key,
value,
}),
":=" => Ok(RequestItem::JsonField(
raw_key,
serde_json::from_str(&value).map_err(|err| {
clap::Error::raw(
clap::ErrorKind::InvalidValue,
format!(
"Invalid value for '[REQUEST_ITEM]...': {:?} {}",
request_item, err
),
)
})?,
)),
"@" => {
let PartWithParams {
value,
file_type,
file_name_header,
} = parse_part_params(&value);
Ok(RequestItem::FormFile {
key,
file_name: value,
file_type,
file_name_header,
})
}
":" if value.is_empty() => Ok(RequestItem::HttpHeaderToUnset(key)),
":" => Ok(RequestItem::HttpHeader(key, value)),
"=@" => Ok(RequestItem::DataFieldFromFile {
key,
raw_key,
value,
}),
":=@" => Ok(RequestItem::JsonFieldFromFile(raw_key, value)),
_ => unreachable!(),
}
} else if let Some(header) = request_item.strip_suffix(';') {
Ok(RequestItem::HttpHeader(header.to_owned(), "".to_owned()))
} else {
Err(clap::Error::raw(
clap::ErrorKind::InvalidValue,
format!("Invalid value for '[REQUEST_ITEM]...': {:?}", request_item),
))
}
}
}
#[derive(Debug, PartialEq, Eq)]
struct PartWithParams {
value: String,
file_type: Option<String>,
file_name_header: Option<String>,
}
fn parse_part_params(mut text: &str) -> PartWithParams {
const TYPE_SEP: &str = ";type=";
const FNAME_SEP: &str = ";filename=";
let mut file_type = None;
let mut file_name_header = None;
let mut delims = vec![TYPE_SEP, FNAME_SEP];
while let Some((pre, delim, post)) = rsplit_once_any(text, &delims) {
match delim {
TYPE_SEP => file_type = Some(post.to_owned()),
FNAME_SEP => file_name_header = Some(post.to_owned()),
_ => unreachable!(),
}
delims.retain(|&x| x != delim);
text = pre;
}
PartWithParams {
value: text.to_owned(),
file_type,
file_name_header,
}
}
fn rsplit_once_any<'a, 'b>(
text: &'a str,
delimiters: &'b [&'static str],
) -> Option<(&'a str, &'static str, &'a str)> {
let mut res = None;
let mut best = 0;
for &delim in delimiters {
if let Some(pos) = text.rfind(delim) {
if pos >= best {
best = pos;
res = Some((&text[..pos], delim, &text[pos + delim.len()..]));
}
}
}
res
}
#[derive(Default, Debug)]
pub struct RequestItems {
pub items: Vec<RequestItem>,
pub body_type: BodyType,
}
pub enum Body {
Json(serde_json::Value),
Form(Vec<(String, String)>),
Multipart(multipart::Form),
Raw(Vec<u8>),
File {
file_name: PathBuf,
file_type: Option<HeaderValue>,
file_name_header: Option<String>,
},
}
impl Body {
pub fn is_empty(&self) -> bool {
match self {
Body::Json(value) => value.is_null(),
Body::Form(items) => items.is_empty(),
Body::Multipart(..) => false,
Body::File { .. } => false,
Body::Raw(..) => false,
}
}
pub fn pick_method(&self) -> Method {
if self.is_empty() {
Method::GET
} else {
Method::POST
}
}
}
impl RequestItems {
pub fn has_form_files(&self) -> bool {
self.items
.iter()
.any(|item| matches!(item, RequestItem::FormFile { .. }))
}
pub fn headers(&self) -> Result<(HeaderMap<HeaderValue>, HashSet<HeaderName>)> {
let mut headers = HeaderMap::new();
#[allow(clippy::mutable_key_type)]
let mut headers_to_unset = HashSet::new();
for item in &self.items {
match item {
RequestItem::HttpHeader(key, value) => {
let key = HeaderName::from_bytes(key.as_bytes())?;
let value = HeaderValue::from_str(value)?;
headers_to_unset.remove(&key);
headers.append(key, value);
}
RequestItem::HttpHeaderToUnset(key) => {
let key = HeaderName::from_bytes(key.as_bytes())?;
headers.remove(&key);
headers_to_unset.insert(key);
}
RequestItem::UrlParam(..) => {}
RequestItem::DataField { .. } => {}
RequestItem::DataFieldFromFile { .. } => {}
RequestItem::JsonField(..) => {}
RequestItem::JsonFieldFromFile(..) => {}
RequestItem::FormFile { .. } => {}
}
}
Ok((headers, headers_to_unset))
}
pub fn query(&self) -> Vec<(&str, &str)> {
let mut query = vec![];
for item in &self.items {
if let RequestItem::UrlParam(key, value) = item {
query.push((key.as_str(), value.as_str()));
}
}
query
}
fn body_as_json(self) -> Result<Body> {
use serde_json::Value;
let mut body = None;
for item in self.items {
let (raw_key, value) = match item {
RequestItem::JsonField(raw_key, value) => (raw_key, value),
RequestItem::JsonFieldFromFile(raw_key, value) => {
let value = serde_json::from_str(&fs::read_to_string(expand_tilde(value))?)?;
(raw_key, value)
}
RequestItem::DataField { raw_key, value, .. } => (raw_key, Value::String(value)),
RequestItem::DataFieldFromFile { raw_key, value, .. } => {
let value = fs::read_to_string(expand_tilde(value))?;
(raw_key, Value::String(value))
}
RequestItem::FormFile { .. } => unreachable!(),
RequestItem::HttpHeader(..)
| RequestItem::HttpHeaderToUnset(..)
| RequestItem::UrlParam(..) => continue,
};
let json_path = nested_json::parse_path(&raw_key)?;
body = nested_json::insert(body, &json_path, value)
.map_err(|e| e.with_json_path(raw_key))?
.into();
}
Ok(Body::Json(body.unwrap_or(Value::Null)))
}
fn body_as_form(self) -> Result<Body> {
let mut text_fields = Vec::<(String, String)>::new();
for item in self.items {
match item {
RequestItem::JsonField(..) | RequestItem::JsonFieldFromFile(..) => {
return Err(anyhow!("JSON values are not supported in Form fields"));
}
RequestItem::DataField { key, value, .. } => text_fields.push((key, value)),
RequestItem::DataFieldFromFile { key, value, .. } => {
let path = expand_tilde(value);
text_fields.push((key, fs::read_to_string(path)?));
}
RequestItem::FormFile { .. } => unreachable!(),
RequestItem::HttpHeader(..) => {}
RequestItem::HttpHeaderToUnset(..) => {}
RequestItem::UrlParam(..) => {}
}
}
Ok(Body::Form(text_fields))
}
fn body_as_multipart(self) -> Result<Body> {
let mut form = multipart::Form::new();
for item in self.items {
match item {
RequestItem::JsonField(..) | RequestItem::JsonFieldFromFile(..) => {
return Err(anyhow!("JSON values are not supported in multipart fields"));
}
RequestItem::DataField { key, value, .. } => {
form = form.text(key, value);
}
RequestItem::DataFieldFromFile { key, value, .. } => {
let path = expand_tilde(value);
form = form.text(key, fs::read_to_string(path)?);
}
RequestItem::FormFile {
key,
file_name,
file_type,
file_name_header,
} => {
let mut part = file_to_part(expand_tilde(&file_name))?;
if let Some(file_type) = file_type {
part = part.mime_str(&file_type)?;
}
if let Some(file_name_header) = file_name_header {
part = part.file_name(file_name_header);
}
form = form.part(key, part);
}
RequestItem::HttpHeader(..) => {}
RequestItem::HttpHeaderToUnset(..) => {}
RequestItem::UrlParam(..) => {}
}
}
Ok(Body::Multipart(form))
}
fn body_from_file(self) -> Result<Body> {
let mut body = None;
if self
.items
.iter()
.any(|item| matches!(item, RequestItem::FormFile {key, ..} if !key.is_empty()))
{
return Err(anyhow!(
"Can't use file fields in JSON mode (perhaps you meant --form?)"
));
}
for item in self.items {
match item {
RequestItem::DataField { .. }
| RequestItem::JsonField(..)
| RequestItem::DataFieldFromFile { .. }
| RequestItem::JsonFieldFromFile(..) => {
return Err(anyhow!(
"Request body (from a file) and request data (key=value) cannot be mixed."
));
}
RequestItem::FormFile {
key,
file_name,
file_type,
file_name_header,
} => {
assert!(key.is_empty());
if body.is_some() {
return Err(anyhow!("Can't read request from multiple files"));
}
body = Some(Body::File {
file_type: file_type
.as_deref()
.or_else(|| mime_guess::from_path(&file_name).first_raw())
.map(HeaderValue::from_str)
.transpose()?,
file_name: expand_tilde(file_name),
file_name_header,
});
}
RequestItem::HttpHeader(..)
| RequestItem::HttpHeaderToUnset(..)
| RequestItem::UrlParam(..) => {}
}
}
let body = body.expect("Should have had at least one file field");
Ok(body)
}
pub fn body(self) -> Result<Body> {
match self.body_type {
BodyType::Multipart => self.body_as_multipart(),
BodyType::Form if self.has_form_files() => self.body_as_multipart(),
BodyType::Form => self.body_as_form(),
BodyType::Json if self.has_form_files() => self.body_from_file(),
BodyType::Json => self.body_as_json(),
}
}
pub fn is_multipart(&self) -> bool {
match self.body_type {
BodyType::Multipart => true,
BodyType::Form => self.has_form_files(),
BodyType::Json => false,
}
}
pub fn pick_method(&self) -> Method {
if self.body_type == BodyType::Multipart {
return Method::POST;
}
for item in &self.items {
match item {
RequestItem::HttpHeader(..)
| RequestItem::HttpHeaderToUnset(..)
| RequestItem::UrlParam(..) => continue,
RequestItem::DataField { .. }
| RequestItem::DataFieldFromFile { .. }
| RequestItem::JsonField(..)
| RequestItem::JsonFieldFromFile(..)
| RequestItem::FormFile { .. } => return Method::POST,
}
}
Method::GET
}
}
pub fn file_to_part(path: impl AsRef<Path>) -> io::Result<multipart::Part> {
let path = path.as_ref();
let file_name = path
.file_name()
.map(|file_name| file_name.to_string_lossy().to_string());
let file = File::open(path)?;
let file_length = file.metadata()?.len();
let mut part = multipart::Part::reader_with_length(file, file_length);
if let Some(file_name) = file_name {
part = part.file_name(file_name);
}
Ok(part)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn request_item_parsing() {
use serde_json::json;
use RequestItem::*;
fn parse(text: &str) -> RequestItem {
text.parse().unwrap()
}
assert_eq!(
parse("foo=bar"),
DataField {
key: "foo".into(),
raw_key: "foo".into(),
value: "bar".into()
}
);
assert_eq!(
parse("foo=@data.json"),
DataFieldFromFile {
key: "foo".into(),
raw_key: "foo".into(),
value: "data.json".into()
}
);
assert_eq!(parse("foo==bar"), UrlParam("foo".into(), "bar".into()));
assert_eq!(
parse(r"foo\==bar"),
DataField {
key: "foo=".into(),
raw_key: r"foo\=".into(),
value: "bar".into()
}
);
assert_eq!(parse("foo:bar"), HttpHeader("foo".into(), "bar".into()));
assert_eq!(parse("foo:=[1,2]"), JsonField("foo".into(), json!([1, 2])));
assert_eq!(
parse("foo:=@data.json"),
JsonFieldFromFile("foo".into(), "data.json".into())
);
"foo:=bar".parse::<RequestItem>().unwrap_err();
assert_eq!(
parse(r"f\o\o=\ba\r"),
DataField {
key: r"f\o\o".into(),
raw_key: r"f\o\o".into(),
value: r"\ba\r".into()
},
);
assert_eq!(
parse(r"f\=\:\@\;oo=b\:\:\:ar"),
DataField {
key: "f=:@;oo".into(),
raw_key: r"f\=\:\@\;oo".into(),
value: "b:::ar".into()
},
);
assert_eq!(parse("foobar:"), HttpHeaderToUnset("foobar".into()));
assert_eq!(parse("foobar;"), HttpHeader("foobar".into(), "".into()));
assert_eq!(
parse("foo@bar"),
FormFile {
key: "foo".into(),
file_name: "bar".into(),
file_type: None,
file_name_header: None,
}
);
assert_eq!(
parse("foo@bar;type=qux"),
FormFile {
key: "foo".into(),
file_name: "bar".into(),
file_type: Some("qux".into()),
file_name_header: None,
},
);
assert_eq!(
parse("foo@bar;type=qux;type=qux"),
FormFile {
key: "foo".into(),
file_name: "bar;type=qux".into(),
file_type: Some("qux".into()),
file_name_header: None,
},
);
assert_eq!(
parse("foo@"),
FormFile {
key: "foo".into(),
file_name: "".into(),
file_type: None,
file_name_header: None,
}
);
"foobar".parse::<RequestItem>().unwrap_err();
"".parse::<RequestItem>().unwrap_err();
assert_eq!(
parse(r"foo=bar\"),
DataField {
key: "foo".into(),
raw_key: "foo".into(),
value: r"bar\".into()
}
);
assert_eq!(
parse(r"foo\\=bar"),
DataField {
key: r"foo\".into(),
raw_key: r"foo\\".into(),
value: "bar".into()
}
);
assert_eq!(
parse("\u{00B5}=\u{00B5}"),
DataField {
key: "\u{00B5}".into(),
raw_key: "\u{00B5}".into(),
value: "\u{00B5}".into()
},
);
assert_eq!(
parse("="),
DataField {
key: "".into(),
raw_key: "".into(),
value: "".into()
}
);
}
#[test]
fn param_parsing() {
assert_eq!(
parse_part_params("foo;type=bar;filename=baz"),
PartWithParams {
value: "foo".into(),
file_type: Some("bar".into()),
file_name_header: Some("baz".into()),
}
);
assert_eq!(
parse_part_params(";type=foo"),
PartWithParams {
value: "".into(),
file_type: Some("foo".into()),
file_name_header: None,
}
);
assert_eq!(
parse_part_params("foo;type=bar;type=baz;filename=qux"),
PartWithParams {
value: "foo;type=bar".into(),
file_type: Some("baz".into()),
file_name_header: Some("qux".into()),
}
);
assert_eq!(
parse_part_params("foo;type=bar;filename=qux;type=baz"),
PartWithParams {
value: "foo;type=bar".into(),
file_type: Some("baz".into()),
file_name_header: Some("qux".into()),
}
);
assert_eq!(
parse_part_params("foo;x=y"),
PartWithParams {
value: "foo;x=y".into(),
file_type: None,
file_name_header: None,
}
);
assert_eq!(
parse_part_params(""),
PartWithParams {
value: "".into(),
file_type: None,
file_name_header: None,
}
);
}
}