diff --git a/README.md b/README.md index b5d1b4e..1956448 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ GET https://mhouge.dk/ The file can then be run using the following command: ```sh -hitt +hitt run ``` That is all that is need to send a request. @@ -63,7 +63,7 @@ GET https://mhouge.dk/ By default, hitt does not exit on error status codes. That behavior can be changed by supplying the `--fail-fast` argument. ```sh -hitt --fail-fast +hitt run --fail-fast ``` ### Running all files in directory @@ -71,7 +71,7 @@ hitt --fail-fast The `--recursive` argument can be passed to run all files in a directory: ```sh -hitt --recursive +hitt run --recursive ``` The order of each file execution is platform and file system dependent. That might change in the future, but for now you **should not** rely on the order. @@ -81,7 +81,7 @@ The order of each file execution is platform and file system dependent. That mig The `--hide-headers` argument can be passed to hide the response headers in the output: ```sh -hitt --hide-headers +hitt run --hide-headers ``` ### Hiding response body @@ -89,7 +89,7 @@ hitt --hide-headers The `--hide-body` argument can be passed to hide the response body in the output: ```sh -hitt --hide-body +hitt run --hide-body ``` ### Disabling pretty printing @@ -97,7 +97,7 @@ hitt --hide-body The `--disable-formatting` argument can be passed to disable pretty printing of response body: ```sh -hitt --disable-formatting +hitt run --disable-formatting ``` ## Disclaimer diff --git a/hitt-cli/Cargo.toml b/hitt-cli/Cargo.toml index 6bde48a..fefce4c 100644 --- a/hitt-cli/Cargo.toml +++ b/hitt-cli/Cargo.toml @@ -2,6 +2,7 @@ name = "hitt-cli" version = "0.0.1" edition = "2021" +description = "Command line HTTP testing tool focused on speed and simplicity" [[bin]] name = "hitt" @@ -10,8 +11,11 @@ path = "src/main.rs" [dependencies] async-recursion = "1.0.5" clap = { version = "4.3.24", features = ["derive"] } +console = "0.15.7" hitt-formatter = { path = "../hitt-formatter" } hitt-parser = { path = "../hitt-parser" } hitt-request = { path = "../hitt-request" } reqwest = "0.11.20" +shell-words = "1.1.0" +tempfile = "3.8.1" tokio = { version = "1.32.0", features = ["fs", "macros", "rt-multi-thread"] } diff --git a/hitt-cli/src/commands/mod.rs b/hitt-cli/src/commands/mod.rs new file mode 100644 index 0000000..ba4e784 --- /dev/null +++ b/hitt-cli/src/commands/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod new; +pub(crate) mod run; diff --git a/hitt-cli/src/commands/new.rs b/hitt-cli/src/commands/new.rs new file mode 100644 index 0000000..a20f8ea --- /dev/null +++ b/hitt-cli/src/commands/new.rs @@ -0,0 +1,148 @@ +use std::str::FromStr; + +use console::{Key, Term}; +use hitt_parser::http::{HeaderName, HeaderValue, Uri}; + +use crate::{ + config::NewCommandArguments, + terminal::{ + editor::editor_input, + input::{confirm_input, select_input, text_input_prompt}, + TEXT_RED, TEXT_RESET, + }, +}; + +fn set_method(term: &Term) -> Result { + let http_methods = [ + "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE", + ]; + + select_input(term, "Which HTTP method?", &http_methods) +} + +fn set_url(term: &Term) -> Result { + text_input_prompt( + term, + "What should the url be?", + |input| !input.is_empty() && Uri::from_str(input).is_ok(), + |input| format!("{TEXT_RED}'{input}' is not a valid url{TEXT_RESET}"), + ) +} + +fn set_headers(term: &Term) -> Result, std::io::Error> { + let mut headers = Vec::new(); + + let mut writing_headers = + confirm_input(term, "Do you want to add headers? (Y/n)", Key::Char('y'))?; + + let key_validator = |input: &str| !input.is_empty() && HeaderName::from_str(input).is_ok(); + let format_key_error = + |input: &str| format!("{TEXT_RED}'{input}' is not a valid header key{TEXT_RESET}"); + + let value_validator = |input: &str| !input.is_empty() && HeaderValue::from_str(input).is_ok(); + let format_value_error = + |input: &str| format!("{TEXT_RED}'{input}' is not a valid header value{TEXT_RESET}"); + + while writing_headers { + let key = text_input_prompt( + term, + "What should the key be?", + key_validator, + format_key_error, + )?; + + let value = text_input_prompt( + term, + "What should the value be?", + value_validator, + format_value_error, + )?; + + headers.push((key, value)); + + writing_headers = confirm_input( + term, + "Do you want to add more headers? (Y/n)", + Key::Char('y'), + )?; + } + + Ok(headers) +} + +fn try_find_content_type(headers: &[(String, String)]) -> Option<&str> { + for (key, value) in headers { + if key.eq_ignore_ascii_case("content-type") { + return Some(value); + } + } + + None +} + +fn set_body(term: &Term, content_type: Option<&str>) -> Result, std::io::Error> { + if !confirm_input(term, "Do you want to add a body? (Y/n)", Key::Char('y'))? { + return Ok(None); + } + + editor_input(term, content_type) +} + +async fn save_request( + path: &std::path::Path, + method: String, + url: String, + headers: &[(String, String)], + body: Option, +) -> Result<(), std::io::Error> { + let mut contents = format!("{method} {url}\n"); + + if !headers.is_empty() { + for (key, value) in headers { + contents.push_str(key); + contents.push_str(": "); + contents.push_str(value); + contents.push('\n'); + } + } + + if let Some(body) = body { + contents.push('\n'); + contents.push_str(&body); + contents.push('\n'); + } + + tokio::fs::write(path, contents).await +} + +async fn check_if_exist(term: &Term, path: &std::path::Path) -> Result<(), std::io::Error> { + if tokio::fs::try_exists(path).await? { + let should_continue = confirm_input( + term, + &format!("File '{path:?}' already exist, do you want to continue? (y/N)"), + Key::Char('n'), + )?; + + if !should_continue { + std::process::exit(0); + } + } + + Ok(()) +} + +pub(crate) async fn new_command(args: &NewCommandArguments) -> Result<(), std::io::Error> { + let term = console::Term::stdout(); + + check_if_exist(&term, &args.path).await?; + + let method = set_method(&term)?; + + let url = set_url(&term)?; + + let headers = set_headers(&term)?; + + let body = set_body(&term, try_find_content_type(&headers))?; + + save_request(&args.path, method, url, &headers, body).await +} diff --git a/hitt-cli/src/commands/run.rs b/hitt-cli/src/commands/run.rs new file mode 100644 index 0000000..5e1a8d5 --- /dev/null +++ b/hitt-cli/src/commands/run.rs @@ -0,0 +1,24 @@ +use crate::{ + config::RunCommandArguments, + fs::{handle_dir, handle_file, is_directory}, + terminal::print_error, +}; + +pub(crate) async fn run_command(args: &RunCommandArguments) -> Result<(), std::io::Error> { + let http_client = reqwest::Client::new(); + + // TODO: figure out a way to remove this clone + let cloned_path = args.path.clone(); + + match is_directory(&args.path).await { + Ok(true) => handle_dir(&http_client, cloned_path, args).await, + Ok(false) => handle_file(&http_client, cloned_path, args).await, + Err(io_error) => { + print_error(format!( + "error checking if {:?} is a directory\n{io_error:#?}", + args.path + )); + std::process::exit(1); + } + } +} diff --git a/hitt-cli/src/config/mod.rs b/hitt-cli/src/config/mod.rs index 66ebbde..25d2598 100644 --- a/hitt-cli/src/config/mod.rs +++ b/hitt-cli/src/config/mod.rs @@ -1,13 +1,25 @@ -#[derive(clap::Parser, Debug)] -#[clap( - author = "Mads Hougesen, mhouge.dk", - version, - about = "hitt is a command line HTTP testing tool focused on speed and simplicity." -)] -pub(crate) struct CliArguments { +use clap::{Args, Parser, Subcommand}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +#[command(propagate_version = true)] +pub(crate) struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum Commands { + Run(RunCommandArguments), + New(NewCommandArguments), +} + +/// Send request +#[derive(Args, Debug)] +pub(crate) struct RunCommandArguments { /// Path to .http file, or directory if supplied with the `--recursive` argument #[arg()] - pub(crate) path: String, + pub(crate) path: std::path::PathBuf, /// Exit on error response status code #[arg(long, default_value_t = false)] @@ -29,3 +41,10 @@ pub(crate) struct CliArguments { #[arg(long, short, default_value_t = false)] pub(crate) recursive: bool, } + +/// Create new http request +#[derive(Args, Debug)] +pub(crate) struct NewCommandArguments { + #[arg()] + pub(crate) path: std::path::PathBuf, +} diff --git a/hitt-cli/src/fs/mod.rs b/hitt-cli/src/fs/mod.rs index e05ae71..84b55fd 100644 --- a/hitt-cli/src/fs/mod.rs +++ b/hitt-cli/src/fs/mod.rs @@ -2,8 +2,8 @@ use async_recursion::async_recursion; use hitt_request::send_request; use crate::{ - config::CliArguments, - printing::{handle_response, print_error}, + config::RunCommandArguments, + terminal::{handle_response, print_error}, }; async fn get_file_content(path: std::path::PathBuf) -> Result { @@ -19,7 +19,7 @@ pub(crate) async fn is_directory(path: &std::path::Path) -> Result Result<(), std::io::Error> { println!("hitt: running {path:?}"); @@ -44,7 +44,7 @@ pub(crate) async fn handle_file( async fn handle_dir_entry( http_client: &reqwest::Client, entry: tokio::fs::DirEntry, - args: &CliArguments, + args: &RunCommandArguments, ) -> Result<(), std::io::Error> { let metadata = entry.metadata().await?; @@ -68,7 +68,7 @@ async fn handle_dir_entry( pub(crate) async fn handle_dir( http_client: &reqwest::Client, path: std::path::PathBuf, - args: &CliArguments, + args: &RunCommandArguments, ) -> Result<(), std::io::Error> { if !args.recursive { print_error(format!( diff --git a/hitt-cli/src/main.rs b/hitt-cli/src/main.rs index efa7a0e..afe4fb8 100644 --- a/hitt-cli/src/main.rs +++ b/hitt-cli/src/main.rs @@ -1,45 +1,19 @@ -use std::str::FromStr; - use clap::Parser; -use config::CliArguments; -use fs::{handle_dir, handle_file, is_directory}; -use printing::print_error; +use commands::new::new_command; +use commands::run::run_command; +use config::{Cli, Commands}; +mod commands; mod config; mod fs; -mod printing; - -async fn run( - http_client: &reqwest::Client, - path: std::path::PathBuf, - args: &CliArguments, -) -> Result<(), std::io::Error> { - match is_directory(&path).await { - Ok(true) => handle_dir(http_client, path, args).await, - Ok(false) => handle_file(http_client, path, args).await, - Err(io_error) => { - print_error(format!( - "error checking if {path:?} is a directory\n{io_error:#?}" - )); - std::process::exit(1); - } - } -} +mod terminal; #[tokio::main] async fn main() -> Result<(), std::io::Error> { - let args = CliArguments::parse(); - - let http_client = reqwest::Client::new(); + let cli = Cli::parse(); - match std::path::PathBuf::from_str(&args.path) { - Ok(path) => run(&http_client, path, &args).await, - Err(parse_path_error) => { - print_error(format!( - "error parsing path {} as filepath\n{parse_path_error:#?}", - args.path - )); - std::process::exit(1); - } + match &cli.command { + Commands::Run(args) => run_command(args).await, + Commands::New(args) => new_command(args).await, } } diff --git a/hitt-cli/src/printing/body.rs b/hitt-cli/src/terminal/body.rs similarity index 94% rename from hitt-cli/src/printing/body.rs rename to hitt-cli/src/terminal/body.rs index 3915fec..acf6369 100644 --- a/hitt-cli/src/printing/body.rs +++ b/hitt-cli/src/terminal/body.rs @@ -1,6 +1,6 @@ use hitt_formatter::ContentType; -use crate::printing::{TEXT_RESET, TEXT_YELLOW}; +use crate::terminal::{TEXT_RESET, TEXT_YELLOW}; #[inline] fn __print_body(body: &str) { diff --git a/hitt-cli/src/terminal/editor.rs b/hitt-cli/src/terminal/editor.rs new file mode 100644 index 0000000..40a99b9 --- /dev/null +++ b/hitt-cli/src/terminal/editor.rs @@ -0,0 +1,113 @@ +use std::io::{Read, Write}; + +use console::{Key, Term}; + +use super::input::confirm_input; + +#[inline] +fn get_default_editor() -> std::ffi::OsString { + if let Some(prog) = std::env::var_os("VISUAL") { + return prog; + } + + if let Some(prog) = std::env::var_os("EDITOR") { + return prog; + } + + if cfg!(windows) { + "notepad.exe".into() + } else { + "vi".into() + } +} + +#[inline] +fn open_editor( + cmd: &str, + args: &[String], + path: &std::path::Path, +) -> Result { + std::process::Command::new(cmd) + .args(args) + .arg(path) + .spawn()? + .wait() +} + +#[inline] +fn create_temp_file(ext: &str) -> Result { + tempfile::Builder::new() + .prefix("hitt-") + .suffix(ext) + .rand_bytes(12) + .tempfile() +} + +#[inline] +fn build_editor_cmd(editor_cmd: String) -> (String, Vec) { + match shell_words::split(&editor_cmd) { + Ok(mut parts) => { + let cmd = parts.remove(0); + (cmd, parts) + } + Err(_) => (editor_cmd, vec![]), + } +} + +#[inline] +fn content_type_to_ext(content_type: Option<&str>) -> &'static str { + match content_type { + Some("application/json") => ".json", + Some("text/css") => ".css", + Some("text/csv") => ".csv", + Some("text/html") => ".html", + Some("text/javascript") => ".js", + Some("application/ld+json") => ".jsonld", + Some("application/x-httpd-php") => ".php", + Some("application/x-sh") => ".sh", + Some("image/svg+xml") => ".svg", + Some("application/xml") | Some("text/xml") => ".xml", + _ => ".txt", + } +} + +pub fn editor_input( + term: &Term, + content_type: Option<&str>, +) -> Result, std::io::Error> { + let default_editor = get_default_editor().into_string().unwrap(); + + let mut file = create_temp_file(content_type_to_ext(content_type))?; + + file.flush()?; + + let path = file.path(); + + let ts = std::fs::metadata(path)?.modified()?; + + let (cmd, args) = build_editor_cmd(default_editor); + + loop { + let status = open_editor(&cmd, &args, path)?; + + if status.success() && ts >= std::fs::metadata(path)?.modified()? { + let confirm_close = confirm_input( + term, + "The body was not set, did you exit on purpose? (Y/n)", + Key::Char('y'), + )?; + + if confirm_close { + return Ok(None); + } + + continue; + } + + let mut written_file = std::fs::File::open(path)?; + let mut file_contents = String::new(); + written_file.read_to_string(&mut file_contents)?; + + return Ok(Some(file_contents)); + } +} diff --git a/hitt-cli/src/printing/headers.rs b/hitt-cli/src/terminal/headers.rs similarity index 88% rename from hitt-cli/src/printing/headers.rs rename to hitt-cli/src/terminal/headers.rs index 01f4277..025734f 100644 --- a/hitt-cli/src/printing/headers.rs +++ b/hitt-cli/src/terminal/headers.rs @@ -1,4 +1,4 @@ -use crate::printing::{TEXT_RESET, TEXT_YELLOW}; +use crate::terminal::{TEXT_RESET, TEXT_YELLOW}; use super::print_error; diff --git a/hitt-cli/src/terminal/input.rs b/hitt-cli/src/terminal/input.rs new file mode 100644 index 0000000..afcc363 --- /dev/null +++ b/hitt-cli/src/terminal/input.rs @@ -0,0 +1,198 @@ +use console::{Key, Term}; + +use super::{write_prompt, write_prompt_answer, TEXT_GREEN, TEXT_RESET}; + +pub(crate) fn text_input_prompt( + term: &Term, + prompt: &str, + validator: fn(&str) -> bool, + error_message: fn(&str) -> String, +) -> Result { + let mut input = String::new(); + + while !validator(&input) { + let mut line_count = 0; + + write_prompt(term, prompt)?; + line_count += 1; + + if !input.is_empty() { + term.write_line(&error_message(&input))?; + line_count += 1; + } + + input = term.read_line()?.trim().to_string(); + + line_count += 1; + + term.clear_last_lines(line_count)?; + } + + write_prompt_answer(term, prompt, &input)?; + + Ok(input) +} + +pub(crate) fn confirm_input( + term: &Term, + prompt: &str, + default_value: Key, +) -> Result { + loop { + let mut line_count = 0; + + write_prompt(term, prompt)?; + line_count += 1; + + let input = term.read_key()?; + + term.clear_last_lines(line_count)?; + + if input == Key::Char('y') + || input == Key::Char('Y') + || (input == Key::Enter && default_value == Key::Char('y')) + { + write_prompt_answer(term, prompt, "y")?; + return Ok(true); + } + + if input == Key::Char('n') + || input == Key::Char('N') + || (input == Key::Enter && default_value == Key::Char('n')) + { + write_prompt_answer(term, prompt, "n")?; + return Ok(false); + } + } +} + +pub(crate) fn select_input( + term: &Term, + prompt: &str, + items: &[&str], +) -> Result { + if items.len() < 2 { + return Ok(items[0].to_string()); + } + + let mut selecting = true; + let mut option_index = 0; + + while selecting { + let mut line_count = 0; + + write_prompt(term, prompt)?; + line_count += 1; + + for (item_index, item) in items.iter().enumerate() { + if item_index == option_index { + term.write_line(&format!("{TEXT_GREEN}> {item }{TEXT_RESET}"))?; + } else { + term.write_line(&format!(" {item }"))?; + } + line_count += 1; + } + + let key = term.read_key()?; + + term.clear_last_lines(line_count)?; + + match key { + Key::ArrowUp | Key::Char('k') => { + option_index = if option_index == 0 { + items.len() - 1 + } else { + option_index - 1 + } + } + Key::ArrowDown | Key::Char('j') => { + option_index = if option_index < items.len() - 1 { + option_index + 1 + } else { + 0 + } + } + Key::Enter => selecting = false, + _ => continue, + } + } + + let selected = items[option_index].to_string(); + + write_prompt_answer(term, prompt, &selected)?; + + Ok(selected) +} + +/* +pub(crate) fn editor_input(term: &Term) -> Result { + let mut input: Vec> = vec![vec![]]; + + loop { + let mut line_count = 0; + + write_prompt(term, "Body input")?; + line_count += 1; + + let input_len = input.len(); + + for line in input.iter() { + let formatted_line: String = line.iter().collect(); + + term.write + term.write_line(&formatted_line)?; + line_count += 1; + } + + let (x, y) = term.size(); + + term.move_cursor_to(0, x as usize - 3)?; + + match term.read_key()? { + Key::Unknown => todo!(), + Key::UnknownEscSeq(_) => todo!(), + + Key::Enter => input.push(Vec::new()), + Key::Escape => todo!(), + Key::Backspace => { + if input[input_len - 1].is_empty() { + if input_len > 1 { + input.pop(); + } + } else { + input[input_len - 1].pop(); + } + } + Key::Home => todo!(), + Key::End => break, + Key::Tab => todo!(), + Key::BackTab => todo!(), + Key::Alt => todo!(), + Key::Del => todo!(), + Key::Shift => todo!(), + Key::Insert => todo!(), + Key::ArrowLeft + | Key::ArrowRight + | Key::ArrowUp + | Key::ArrowDown + | Key::PageUp + | Key::PageDown => continue, + Key::Char(ch) => input[input_len - 1].push(ch), + v => todo!(), + }; + + term.clear_last_lines(line_count)?; + } + + let mut x = String::new(); + + for line in input { + for c in line { + x.push(c); + } + x.push('\n'); + } + + Ok(x.trim().to_string()) +} +*/ diff --git a/hitt-cli/src/printing/mod.rs b/hitt-cli/src/terminal/mod.rs similarity index 65% rename from hitt-cli/src/printing/mod.rs rename to hitt-cli/src/terminal/mod.rs index 8fcc915..664df48 100644 --- a/hitt-cli/src/printing/mod.rs +++ b/hitt-cli/src/terminal/mod.rs @@ -1,15 +1,18 @@ +use console::Term; use hitt_request::HittResponse; use crate::{ - config::CliArguments, - printing::{body::print_body, headers::print_headers}, + config::RunCommandArguments, + terminal::{body::print_body, headers::print_headers}, }; use self::status::print_status; -mod body; -mod headers; -mod status; +pub(crate) mod body; +pub(crate) mod editor; +pub(crate) mod headers; +pub(crate) mod input; +pub(crate) mod status; pub const STYLE_RESET: &str = "\x1B[0m"; @@ -23,7 +26,7 @@ pub const TEXT_YELLOW: &str = "\x1B[33m"; pub const TEXT_RESET: &str = "\x1B[39m"; -pub(crate) fn handle_response(response: HittResponse, args: &CliArguments) { +pub(crate) fn handle_response(response: HittResponse, args: &RunCommandArguments) { print_status( &response.http_version, &response.method, @@ -57,3 +60,15 @@ pub(crate) fn handle_response(response: HittResponse, args: &CliArguments) { pub(crate) fn print_error(message: String) { eprintln!("{TEXT_RED}hitt: {message}{TEXT_RESET}") } + +pub(crate) fn write_prompt(term: &Term, prompt: &str) -> Result<(), std::io::Error> { + term.write_line(prompt) +} + +pub(crate) fn write_prompt_answer( + term: &Term, + prompt: &str, + answer: &str, +) -> Result<(), std::io::Error> { + term.write_line(&format!("{prompt} {TEXT_GREEN}[{answer}]{TEXT_RESET}")) +} diff --git a/hitt-cli/src/printing/status.rs b/hitt-cli/src/terminal/status.rs similarity index 87% rename from hitt-cli/src/printing/status.rs rename to hitt-cli/src/terminal/status.rs index 4b031f7..f8ac15d 100644 --- a/hitt-cli/src/printing/status.rs +++ b/hitt-cli/src/terminal/status.rs @@ -1,6 +1,6 @@ use hitt_parser::http; -use crate::printing::{STYLE_BOLD, STYLE_RESET, TEXT_GREEN, TEXT_RED, TEXT_RESET}; +use crate::terminal::{STYLE_BOLD, STYLE_RESET, TEXT_GREEN, TEXT_RED, TEXT_RESET}; #[inline] pub(crate) fn print_status(