Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support reading args from a config file #165

Merged
merged 11 commits into from
Aug 2, 2021
51 changes: 48 additions & 3 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@ use std::convert::TryFrom;
use std::env;
use std::ffi::OsString;
use std::fmt;
use std::fs;
use std::io::Write;
use std::mem;
use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;

use reqwest::{Method, Url};
use serde::{Deserialize, Serialize};
use structopt::clap::{self, arg_enum, AppSettings, Error, ErrorKind, Result};
use structopt::StructOpt;

use crate::{buffer::Buffer, request_items::RequestItem};
use crate::{buffer::Buffer, request_items::RequestItem, utils::config_dir};

// Some doc comments were copy-pasted from HTTPie

Expand All @@ -33,6 +35,7 @@ use crate::{buffer::Buffer, request_items::RequestItem};
AppSettings::DeriveDisplayOrder,
AppSettings::UnifiedHelpMessage,
AppSettings::ColoredHelp,
AppSettings::AllArgsOverrideSelf,
blyxxyz marked this conversation as resolved.
Show resolved Hide resolved
],
)]
pub struct Cli {
Expand Down Expand Up @@ -297,8 +300,17 @@ const NEGATION_FLAGS: &[&str] = &[
];

impl Cli {
pub fn from_args() -> Self {
Cli::from_iter(std::env::args_os())
pub fn parse() -> Self {
if let Some(default_args) = default_cli_args() {
let mut args = std::env::args_os();
Cli::from_iter(
std::iter::once(args.next().unwrap_or_else(|| "xh".into()))
.chain(default_args.into_iter().map(Into::into))
.chain(args),
)
} else {
Cli::from_iter(std::env::args_os())
}
}

pub fn from_iter<I>(iter: I) -> Self
Expand Down Expand Up @@ -481,6 +493,39 @@ impl Cli {
}
}

#[derive(Serialize, Deserialize)]
struct Config {
default_options: Vec<String>,
}

fn default_cli_args() -> Option<Vec<String>> {
let content = match fs::read_to_string(config_dir()?.join("config.json")) {
Ok(file) => Some(file),
Err(err) => {
if err.kind() != std::io::ErrorKind::NotFound {
eprintln!(
"\n{}: warning: Unable to read config file: {}\n",
env!("CARGO_PKG_NAME"),
err
);
}
None
}
}?;

match serde_json::from_str::<Config>(&content) {
Ok(config) => Some(config.default_options),
Err(err) => {
eprintln!(
"\n{}: warning: Unable to parse config file: {}\n",
env!("CARGO_PKG_NAME"),
err
);
None
}
}
}

fn parse_method(method: &str) -> Option<Method> {
// This unfortunately matches "localhost"
if !method.is_empty() && method.chars().all(|c| c.is_ascii_alphabetic()) {
Expand Down
4 changes: 2 additions & 2 deletions src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ pub fn download_file(
dest_name = file_name;
buffer = Box::new(open_opts.open(&dest_name)?);
} else if test_pretend_term() || atty::is(Stream::Stdout) {
let (new_name, handle) = open_new_file(get_file_name(&response, &orig_url).into())?;
let (new_name, handle) = open_new_file(get_file_name(&response, orig_url).into())?;
dest_name = new_name;
buffer = Box::new(handle);
} else {
Expand All @@ -208,7 +208,7 @@ pub fn download_file(
total_length = Some(total_for_content_range(header, starting_length)?);
} else {
starting_length = 0;
total_length = get_content_length(&response.headers());
total_length = get_content_length(response.headers());
}

let starting_time = Instant::now();
Expand Down
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ fn get_user_agent() -> &'static str {

#[exit_status::main]
fn main() -> Result<i32> {
let args = Cli::from_args();
let args = Cli::parse();

if args.curl {
to_curl::print_curl_translation(args)?;
Expand Down
65 changes: 61 additions & 4 deletions src/printer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::{
buffer::Buffer,
cli::{Pretty, Theme},
formatting::{get_json_formatter, Highlighter},
utils::{copy_largebuf, get_content_type, test_mode, valid_json, ContentType, BUFFER_SIZE},
utils::{copy_largebuf, test_mode, BUFFER_SIZE},
};

const BINARY_SUPPRESSOR: &str = concat!(
Expand Down Expand Up @@ -299,7 +299,7 @@ impl Printer {
}

let request_line = format!("{} {}{} {:?}\n", method, url.path(), query_string, version);
let headers = &self.headers_to_string(&headers, self.sort_headers);
let headers = self.headers_to_string(&headers, self.sort_headers);

self.print_headers(&(request_line + &headers))?;
self.buffer.print("\n\n")?;
Expand All @@ -320,7 +320,7 @@ impl Printer {
}

pub fn print_request_body(&mut self, request: &mut Request) -> anyhow::Result<()> {
let content_type = get_content_type(&request.headers());
let content_type = get_content_type(request.headers());
if let Some(body) = request.body_mut() {
let body = body.buffer()?;
if body.contains(&b'\0') {
Expand All @@ -336,7 +336,7 @@ impl Printer {
}

pub fn print_response_body(&mut self, mut response: Response) -> anyhow::Result<()> {
let content_type = get_content_type(&response.headers());
let content_type = get_content_type(response.headers());
if !self.buffer.is_terminal() {
if (self.color || self.indent_json) && content_type.is_text() {
// The user explicitly asked for formatting even though this is
Expand Down Expand Up @@ -387,6 +387,63 @@ impl Printer {
}
}

pub enum ContentType {
blyxxyz marked this conversation as resolved.
Show resolved Hide resolved
Json,
Html,
Xml,
JavaScript,
Css,
Text,
UrlencodedForm,
Multipart,
Unknown,
}

impl ContentType {
pub fn is_text(&self) -> bool {
!matches!(
self,
ContentType::Unknown | ContentType::UrlencodedForm | ContentType::Multipart
)
}
}

pub fn get_content_type(headers: &HeaderMap) -> ContentType {
headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|content_type| {
if content_type.contains("json") {
Some(ContentType::Json)
} else if content_type.contains("html") {
Some(ContentType::Html)
} else if content_type.contains("xml") {
Some(ContentType::Xml)
} else if content_type.contains("multipart") {
Some(ContentType::Multipart)
} else if content_type.contains("x-www-form-urlencoded") {
Some(ContentType::UrlencodedForm)
} else if content_type.contains("javascript") {
Some(ContentType::JavaScript)
} else if content_type.contains("css") {
Some(ContentType::Css)
} else if content_type.contains("text") {
// We later check if this one's JSON
// HTTPie checks for "json", "javascript" and "text" in one place:
// https://github.com/httpie/httpie/blob/a32ad344dd/httpie/output/formatters/json.py#L14
// We have it more spread out but it behaves more or less the same
Some(ContentType::Text)
} else {
None
}
})
.unwrap_or(ContentType::Unknown)
}

pub fn valid_json(text: &str) -> bool {
serde_json::from_str::<serde::de::IgnoredAny>(text).is_ok()
}

/// Decode a streaming response in a way that matches `.text()`.
///
/// Note that in practice this seems to behave like String::from_utf8_lossy(),
Expand Down
6 changes: 3 additions & 3 deletions src/request_items.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,12 +265,12 @@ impl RequestItems {
for item in &self.0 {
match item {
RequestItem::HttpHeader(key, value) => {
let key = HeaderName::from_bytes(&key.as_bytes())?;
let value = HeaderValue::from_str(&value)?;
let key = HeaderName::from_bytes(key.as_bytes())?;
let value = HeaderValue::from_str(value)?;
headers.insert(key, value);
}
RequestItem::HttpHeaderToUnset(key) => {
let key = HeaderName::from_bytes(&key.as_bytes())?;
let key = HeaderName::from_bytes(key.as_bytes())?;
headers_to_unset.push(key);
}
RequestItem::UrlParam(..) => {}
Expand Down
2 changes: 1 addition & 1 deletion src/url.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pub fn construct_url(
}
let mut url: Url = if url.starts_with(':') {
format!("{}{}{}", default_scheme, "localhost", url).parse()?
} else if !regex!("[a-zA-Z0-9]://.+").is_match(&url) {
} else if !regex!("[a-zA-Z0-9]://.+").is_match(url) {
format!("{}{}", default_scheme, url).parse()?
} else {
url.parse()?
Expand Down
62 changes: 6 additions & 56 deletions src/utils.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
use std::{
env::var_os,
io::{self, Write},
path::PathBuf,
};

use reqwest::header::{HeaderMap, CONTENT_TYPE};

/// Whether to make some things more deterministic for the benefit of tests
pub fn test_mode() -> bool {
// In integration tests the binary isn't compiled with cfg(test), so we
Expand All @@ -21,59 +20,14 @@ pub fn test_default_color() -> bool {
var_os("XH_TEST_MODE_COLOR").is_some()
}

pub enum ContentType {
Json,
Html,
Xml,
JavaScript,
Css,
Text,
UrlencodedForm,
Multipart,
Unknown,
}

impl ContentType {
pub fn is_text(&self) -> bool {
!matches!(
self,
ContentType::Unknown | ContentType::UrlencodedForm | ContentType::Multipart
)
pub fn config_dir() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("XH_CONFIG_DIR") {
Some(dir.into())
} else {
dirs::config_dir().map(|dir| dir.join("xh"))
}
}

pub fn get_content_type(headers: &HeaderMap) -> ContentType {
headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|content_type| {
if content_type.contains("json") {
Some(ContentType::Json)
} else if content_type.contains("html") {
Some(ContentType::Html)
} else if content_type.contains("xml") {
Some(ContentType::Xml)
} else if content_type.contains("multipart") {
Some(ContentType::Multipart)
} else if content_type.contains("x-www-form-urlencoded") {
Some(ContentType::UrlencodedForm)
} else if content_type.contains("javascript") {
Some(ContentType::JavaScript)
} else if content_type.contains("css") {
Some(ContentType::Css)
} else if content_type.contains("text") {
// We later check if this one's JSON
// HTTPie checks for "json", "javascript" and "text" in one place:
// https://github.com/httpie/httpie/blob/a32ad344dd/httpie/output/formatters/json.py#L14
// We have it more spread out but it behaves more or less the same
Some(ContentType::Text)
} else {
None
}
})
.unwrap_or(ContentType::Unknown)
}

// https://stackoverflow.com/a/45145246/5915221
#[macro_export]
macro_rules! vec_of_strings {
Expand Down Expand Up @@ -121,7 +75,3 @@ pub fn copy_largebuf(reader: &mut impl io::Read, writer: &mut impl Write) -> io:
}
}
}

pub fn valid_json(text: &str) -> bool {
serde_json::from_str::<serde::de::IgnoredAny>(text).is_ok()
}
41 changes: 41 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1323,3 +1323,44 @@ fn accept_encoding_not_modifiable_in_download_mode() {
.assert();
mock.assert();
}

#[test]
fn read_args_from_config() {
let config_dir = tempdir().unwrap();
File::create(config_dir.path().join("config.json")).unwrap();
std::fs::write(
config_dir.path().join("config.json"),
serde_json::json!({"default_options": ["--form", "--print=hbHB"]}).to_string(),
)
.unwrap();

get_command()
.env("XH_CONFIG_DIR", config_dir.path())
.arg(":")
.arg("--offline")
.arg("--print=B") // this should overwrite the value from config.json
.arg("sort=asc")
.arg("limit=100")
.assert()
.stdout("sort=asc&limit=100\n\n")
.success();
}

#[test]
fn warns_if_config_is_invalid() {
let config_dir = tempdir().unwrap();
File::create(config_dir.path().join("config.json")).unwrap();
std::fs::write(
config_dir.path().join("config.json"),
serde_json::json!({"default_options": "--form"}).to_string(),
)
.unwrap();

get_command()
.env("XH_CONFIG_DIR", config_dir.path())
.arg(":")
.arg("--offline")
.assert()
.stderr(contains("Unable to parse config file"))
.success();
}