From 1f3e922b35122f931a820842c5662bed6867198b Mon Sep 17 00:00:00 2001 From: meshde Date: Sat, 11 Apr 2026 21:39:15 +0400 Subject: [PATCH] feat: add scripted/non-interactive usage support Add four features for scripted/non-interactive CLI usage: - Typed parameter substitution (|boolean, |number, |file) with validation - Multipart file uploads via |file type params - Non-interactive body handling (editor no longer opens by default, use -e) - Structured JSON output via --json flag Also resolves all pre-existing clippy warnings across the codebase and bumps version to 0.6.0 (breaking: editor behavior change). Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 122 +++++++++++- Cargo.toml | 5 +- src/cli/env/use.rs | 3 +- src/cli/ephenv/set.rs | 3 +- src/cli/last/view.rs | 57 +++--- src/cli/mod.rs | 73 ++++++-- src/cli/run.rs | 207 ++++++++++++++++----- src/core/app_config.rs | 2 +- src/core/command.rs | 48 +++++ src/core/config.rs | 2 +- src/core/env.rs | 8 +- src/core/ephenv.rs | 2 +- src/core/openapi.rs | 40 ++-- src/main.rs | 2 - src/utils/error.rs | 1 - src/utils/http.rs | 65 ++++++- src/utils/input.rs | 3 +- tests/fixtures/mod.rs | 31 ++++ tests/run_tests.rs | 412 ++++++++++++++++++++++++++++++++++++++++- 19 files changed, 944 insertions(+), 142 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e7abb4e..4d3909c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -102,6 +102,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f8cb5d814eb646a863c4f24978cff2880c4be96ad8cde2c0f0678732902e271" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "assert_cmd" version = "2.0.17" @@ -818,7 +828,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hit-cli" -version = "0.5.1" +version = "0.6.0" dependencies = [ "arboard", "array_tool", @@ -839,6 +849,7 @@ dependencies = [ "hyper", "inquire", "insta", + "mockito", "openapiv3", "predicates", "regex", @@ -901,6 +912,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "human-panic" version = "2.0.2" @@ -930,6 +947,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1303,6 +1321,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.8" @@ -1336,6 +1364,31 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -1642,6 +1695,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.3" @@ -1705,6 +1767,35 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.12" @@ -1783,6 +1874,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", @@ -2483,6 +2575,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -3041,6 +3139,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 0ca395e..ec05520 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hit-cli" -version = "0.5.1" +version = "0.6.0" edition = "2021" authors = ["Mehmood S. Deshmukh "] homepage = "https://usehit.dev" @@ -29,7 +29,7 @@ hyper = "1.3.1" inquire = "0.7.5" openapiv3 = "1.0.1" regex = "1.10.4" -reqwest = {version="0.12.3", features=["json"]} +reqwest = {version="0.12.3", features=["json", "multipart"]} serde = {version="1.0.200", features=["derive"]} serde_json = "1.0" serde_yaml = "0.9" @@ -40,5 +40,6 @@ tokio = {version = "1.37.0", features = ["full"]} [dev-dependencies] assert_cmd = "2.0.17" insta = {version="1.43.1", features=["json"]} +mockito = "1.7" predicates = "3.1.3" rstest = "0.25.0" diff --git a/src/cli/env/use.rs b/src/cli/env/use.rs index edb709e..8620ef6 100644 --- a/src/cli/env/use.rs +++ b/src/cli/env/use.rs @@ -7,5 +7,6 @@ pub struct EnvUseArguments { } pub fn init(args: EnvUseArguments) -> Result<(), Box> { - Ok(set_env(args.env)) + set_env(args.env); + Ok(()) } diff --git a/src/cli/ephenv/set.rs b/src/cli/ephenv/set.rs index 7f3fad6..eac7463 100644 --- a/src/cli/ephenv/set.rs +++ b/src/cli/ephenv/set.rs @@ -8,5 +8,6 @@ pub struct EphenvSetArguments { } pub fn init(args: EphenvSetArguments) -> Result<(), Box> { - Ok(set_ephenv(args.key, args.value)) + set_ephenv(args.key, args.value); + Ok(()) } diff --git a/src/cli/last/view.rs b/src/cli/last/view.rs index 0515963..586fe93 100644 --- a/src/cli/last/view.rs +++ b/src/cli/last/view.rs @@ -1,7 +1,6 @@ use crate::core::app_config::get_app_config; use crate::utils::input::CustomAutocomplete; use arboard::Clipboard; -use colored_json; use crossterm::event::{read, Event, KeyCode}; use crossterm::terminal; use flatten_json_object::Flattener; @@ -10,7 +9,7 @@ use serde_json::Value; use std::io::stdout; use std::io::Write; -fn get_json_value_from_path<'a, 'b>(json: &'a Value, path: &'b str) -> Option<&'a Value> { +fn get_json_value_from_path<'a>(json: &'a Value, path: &str) -> Option<&'a Value> { json.pointer(format!("/{}", path.replace(".", "/")).as_str()) } @@ -21,48 +20,46 @@ pub fn init() -> Result<(), Box> { .clone(); let mut prev_request = serde_json::to_value(prev_request).unwrap(); - if let Ok(body_json) = serde_json::from_str::(&prev_request["body"].as_str().unwrap()) { + if let Ok(body_json) = serde_json::from_str::(prev_request["body"].as_str().unwrap()) { prev_request["body"] = body_json; } let mut out = stdout(); colored_json::write_colored_json(&prev_request, &mut out).unwrap(); out.flush().unwrap(); - writeln!(out, "").unwrap(); + writeln!(out).unwrap(); println!("Press c to enter copy mode or any other key to exit"); terminal::enable_raw_mode().unwrap(); loop { - if let Ok(event) = read() { - if let Event::Key(key) = event { - terminal::disable_raw_mode().unwrap(); - if key.code == KeyCode::Char('c') { - let flattened_json = Flattener::new().flatten(&prev_request).unwrap(); + if let Ok(Event::Key(key)) = read() { + terminal::disable_raw_mode().unwrap(); + if key.code == KeyCode::Char('c') { + let flattened_json = Flattener::new().flatten(&prev_request).unwrap(); - let json_paths: Vec = flattened_json - .as_object() - .unwrap() - .keys() - .map(|k| k.to_string()) - .collect(); + let json_paths: Vec = flattened_json + .as_object() + .unwrap() + .keys() + .map(|k| k.to_string()) + .collect(); - let user_json_path = Text::new("Enter the JSON path: ") - .with_autocomplete(CustomAutocomplete::new(json_paths)) - .prompt() - .unwrap(); - Clipboard::new() - .unwrap() - .set_text( - get_json_value_from_path(&prev_request, &user_json_path) - .unwrap() - .to_string(), - ) - .unwrap(); - } - break; + let user_json_path = Text::new("Enter the JSON path: ") + .with_autocomplete(CustomAutocomplete::new(json_paths)) + .prompt() + .unwrap(); + Clipboard::new() + .unwrap() + .set_text( + get_json_value_from_path(&prev_request, &user_json_path) + .unwrap() + .to_string(), + ) + .unwrap(); } + break; } } - println!(""); + println!(); Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c11bdd7..f1eea86 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -7,12 +7,18 @@ mod run; use crate::core::command::Command as ConfigCommand; use crate::core::config::{CommandType as ConfigCommandType, Config}; use crate::utils::error::CliError; -use clap::{command, Arg, ArgMatches, Command, FromArgMatches as _, Parser, Subcommand}; +use clap::{Arg, ArgAction, ArgMatches, Command, FromArgMatches as _, Parser, Subcommand}; use clap_complete::CompleteEnv; use convert_case::{Case, Casing}; use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; use std::process::ExitCode; +const GLOBAL_ARG_EDIT_BODY: &str = "edit-body"; +const GLOBAL_ARG_BODY_FILE: &str = "body-file"; +const GLOBAL_ARG_JSON: &str = "json"; + #[derive(Debug, Parser)] #[command(version)] enum StaticCommand { @@ -38,7 +44,7 @@ fn formulate_command( for param in params { subcommand = subcommand.arg( Arg::new(param.to_string()) - .long(¶m.to_string().to_case(Case::Kebab)) + .long(param.to_string().to_case(Case::Kebab)) .value_name(param.to_string()) .help(format!("Provide value for the param :{}", param)), ) @@ -56,36 +62,66 @@ fn formulate_command( command.clone() } -fn obtain_run_command_from_matches( - matches: &ArgMatches, +fn obtain_run_command_from_matches<'a>( + matches: &'a ArgMatches, config_commands: &HashMap>, args_map: &mut HashMap, -) -> ConfigCommand { +) -> (ConfigCommand, &'a ArgMatches) { + let global_args: HashSet<&str> = [GLOBAL_ARG_EDIT_BODY, GLOBAL_ARG_BODY_FILE, GLOBAL_ARG_JSON] + .into_iter() + .collect(); + let subcommand_name = matches.subcommand_name().unwrap(); let config_command_value = config_commands.get(subcommand_name).unwrap(); - let subcommand_matches = matches.subcommand_matches(&subcommand_name).unwrap(); + let subcommand_matches = matches.subcommand_matches(subcommand_name).unwrap(); match **config_command_value { ConfigCommandType::Command(ref config_command) => { for arg_id in subcommand_matches.ids() { + let id_str = arg_id.as_str(); + if global_args.contains(id_str) { + continue; + } args_map.insert( - arg_id.to_string(), + id_str.to_string(), subcommand_matches - .get_one::(arg_id.as_str()) + .get_one::(id_str) .unwrap() .to_string(), ); } - config_command.clone() + (config_command.clone(), subcommand_matches) } ConfigCommandType::NestedCommand(ref config_command) => { - obtain_run_command_from_matches(&subcommand_matches, &config_command, args_map) + obtain_run_command_from_matches(subcommand_matches, config_command, args_map) } } } fn get_run_command(config: &Config) -> Command { - let mut command = Command::new("run").arg_required_else_help(true); + let mut command = Command::new("run") + .arg_required_else_help(true) + .arg( + Arg::new(GLOBAL_ARG_EDIT_BODY) + .long("edit-body") + .short('e') + .action(ArgAction::SetTrue) + .global(true) + .help("Open editor to review/edit request body before sending"), + ) + .arg( + Arg::new(GLOBAL_ARG_BODY_FILE) + .long("body-file") + .global(true) + .help("Read request body from a file instead of using the configured body"), + ) + .arg( + Arg::new(GLOBAL_ARG_JSON) + .long("json") + .action(ArgAction::SetTrue) + .global(true) + .help("Output response as structured JSON (url, status, headers, body)"), + ); command = formulate_command(command, &config.commands); command @@ -110,12 +146,21 @@ pub async fn init() -> ExitCode { let mut args_map = HashMap::new(); - let config_command = obtain_run_command_from_matches( - &run_subcommand_matches, + let (config_command, leaf_matches) = obtain_run_command_from_matches( + run_subcommand_matches, &config.commands, &mut args_map, ); - run::run(&config_command, args_map).await + + let options = run::RunOptions { + edit_body: leaf_matches.get_flag(GLOBAL_ARG_EDIT_BODY), + body_file: leaf_matches + .get_one::(GLOBAL_ARG_BODY_FILE) + .map(PathBuf::from), + json_output: leaf_matches.get_flag(GLOBAL_ARG_JSON), + }; + + run::run(&config_command, args_map, options).await } _ => { let static_command_matches = StaticCommand::from_arg_matches(&matches).unwrap(); diff --git a/src/cli/run.rs b/src/cli/run.rs index 838cfc6..be57034 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -5,7 +5,6 @@ use crate::core::env::get_env; use crate::core::ephenv::get_ephenvs; use crate::utils::error::CliError; use crate::utils::http::handle_request; -use colored_json; use edit::edit; use handlebars::Handlebars; use regex::Regex; @@ -14,16 +13,112 @@ use std::collections::HashMap; use std::error::Error; use std::io::stdout; use std::io::Write; +use std::path::PathBuf; fn replace_params(input: String, params: &HashMap) -> String { params.keys().fold(input, |acc, x| { - acc.replace(&format!(":{}", x), ¶ms.get(x).unwrap()) + acc.replace(&format!(":{}", x), params.get(x).unwrap()) + }) +} + +pub struct RunOptions { + pub edit_body: bool, + pub body_file: Option, + pub json_output: bool, +} + +struct TypedSubstitutionResult { + body: String, + file_fields: HashMap, +} + +fn replace_params_typed( + input: String, + params: &HashMap, + param_types: &HashMap>, +) -> Result> { + let mut result = input.clone(); + let mut file_fields: HashMap = HashMap::new(); + + for (param_name, param_value) in params { + let param_type = param_types + .get(param_name.as_str()) + .and_then(|t| t.as_deref()); + + match param_type { + Some("boolean") => { + if param_value != "true" && param_value != "false" { + return Err(Box::new(CliError { + message: format!( + "Parameter '{}' expects a boolean value (true/false), got '{}'", + param_name, param_value + ), + })); + } + let quoted_placeholder = format!("\":{}|boolean\"", param_name); + result = result.replace("ed_placeholder, param_value); + } + Some("number") => { + if param_value.parse::().is_err() { + return Err(Box::new(CliError { + message: format!( + "Parameter '{}' expects a number value, got '{}'", + param_name, param_value + ), + })); + } + let quoted_placeholder = format!("\":{}|number\"", param_name); + result = result.replace("ed_placeholder, param_value); + } + Some("file") => { + let path = PathBuf::from(param_value); + if !path.exists() { + return Err(Box::new(CliError { + message: format!( + "File not found for parameter '{}': {}", + param_name, param_value + ), + })); + } + + let placeholder = format!(":{}|file", param_name); + if let Ok(mut json_val) = serde_json::from_str::(&result) { + if let Some(obj) = json_val.as_object_mut() { + let mut file_key = None; + for (key, val) in obj.iter() { + if let Some(s) = val.as_str() { + if s == placeholder { + file_key = Some(key.clone()); + break; + } + } + } + if let Some(key) = file_key { + let key_clone = key.clone(); + obj.remove(&key); + result = serde_json::to_string(&json_val).unwrap(); + file_fields.insert(key_clone, path); + } + } + } + } + _ => { + let placeholder = format!(":{}", param_name); + result = result.replace(&placeholder, param_value); + } + } + } + + Ok(TypedSubstitutionResult { + body: result, + file_fields, }) } pub async fn run( api_call: &Command, param_values: HashMap, + options: RunOptions, ) -> Result<(), Box> { let config = Config::new(); let env_var_regex = Regex::new(r"\{\{\w+}}").unwrap(); @@ -37,7 +132,6 @@ pub async fn run( None => { return Err(Box::new(CliError { message: "env not set".to_string(), - help: None, })) } }; @@ -46,7 +140,6 @@ pub async fn run( None => { return Err(Box::new(CliError { message: "env not recognized".to_string(), - help: None, })) } }; @@ -65,21 +158,46 @@ pub async fn run( let url_to_call = replace_params(url_with_env_vars, ¶m_values); - let input = if api_call.body.is_some() { - Some( - edit(replace_params( - hb_handle - .render_template( - &serde_json::to_string_pretty(&api_call.body).unwrap(), - &merged_data, - ) - .unwrap(), - ¶m_values, - )) - .expect("Unable to open system editor"), - ) + let (input, file_fields) = if let Some(ref body_file_path) = options.body_file { + let file_content = std::fs::read_to_string(body_file_path).map_err(|e| { + Box::new(CliError { + message: format!( + "Failed to read body file '{}': {}", + body_file_path.display(), + e + ), + }) + })?; + let rendered = if env_var_regex.is_match(&file_content) { + hb_handle.render_template(&file_content, &merged_data)? + } else { + file_content + }; + (Some(rendered), None) + } else if api_call.body.is_some() { + let serialized = serde_json::to_string_pretty(&api_call.body).unwrap(); + let rendered = hb_handle + .render_template(&serialized, &merged_data) + .unwrap(); + + let param_types = api_call.body_param_types(); + let typed_result = replace_params_typed(rendered, ¶m_values, ¶m_types)?; + + let body_str = if options.edit_body { + edit(&typed_result.body).expect("Unable to open system editor") + } else { + typed_result.body + }; + + let file_fields = if typed_result.file_fields.is_empty() { + None + } else { + Some(typed_result.file_fields) + }; + + (Some(body_str), file_fields) } else { - None + (None, None) }; let response = handle_request( @@ -92,34 +210,39 @@ pub async fn run( .map(|(k, v)| (k, hb_handle.render_template(&v, &merged_data).unwrap())) .collect::>(), input, + file_fields, ) - .await - .unwrap(); + .await?; get_app_config().set_prev_request(response.clone()); - let response_json_result = serde_json::from_str::(response.clone().body.as_str()); - - match response_json_result { - Ok(response_json) => { - let mut out = stdout(); - colored_json::write_colored_json(&response_json, &mut out).unwrap(); - out.flush().unwrap(); - writeln!(out, "").unwrap(); - let mut postscript_env_vars = merged_data.clone(); - postscript_env_vars.extend(param_values); - - api_call - .run_post_command_script( - &serde_json::to_string_pretty(&response.clone()).unwrap(), - &postscript_env_vars, - ) - .unwrap(); - } - Err(_error) => { - println!("{}", response.body); - } - }; + if options.json_output { + println!("{}", serde_json::to_string(&response).unwrap()); + } else { + let response_json_result = serde_json::from_str::(response.clone().body.as_str()); + + match response_json_result { + Ok(response_json) => { + let mut out = stdout(); + colored_json::write_colored_json(&response_json, &mut out).unwrap(); + out.flush().unwrap(); + writeln!(out).unwrap(); + } + Err(_error) => { + println!("{}", response.body); + } + }; + } + + let mut postscript_env_vars = merged_data.clone(); + postscript_env_vars.extend(param_values); + + api_call + .run_post_command_script( + &serde_json::to_string_pretty(&response.clone()).unwrap(), + &postscript_env_vars, + ) + .unwrap(); Ok(()) } diff --git a/src/core/app_config.rs b/src/core/app_config.rs index e29d24e..04dc33e 100644 --- a/src/core/app_config.rs +++ b/src/core/app_config.rs @@ -27,7 +27,7 @@ impl AppConfig { } } - pub fn save(&self) -> () { + pub fn save(&self) { let app_config_dir = get_app_config_dir(); let app_config_file_path = get_app_config_file_path(); diff --git a/src/core/command.rs b/src/core/command.rs index a8c34a8..89cad23 100644 --- a/src/core/command.rs +++ b/src/core/command.rs @@ -38,6 +38,17 @@ fn get_params_from_string(input: &str) -> Vec { .collect() } +pub fn get_param_types_from_string(input: &str) -> HashMap> { + let typed_param_regex = Regex::new(r":(\w+)(?:\|(\w+))?").unwrap(); + let mut result = HashMap::new(); + for caps in typed_param_regex.captures_iter(input) { + let name = caps.get(1).unwrap().as_str().to_string(); + let param_type = caps.get(2).map(|m| m.as_str().to_string()); + result.insert(name, param_type); + } + result +} + impl Command { pub fn route_params(&self) -> Vec { get_params_from_string(self.url.as_str()) @@ -54,6 +65,13 @@ impl Command { self.route_params().union(self.body_params()) } + pub fn body_param_types(&self) -> HashMap> { + match &self.body { + Some(input) => get_param_types_from_string(&input.to_string()), + None => HashMap::new(), + } + } + pub fn run_post_command_script( &self, command_response: &str, @@ -142,4 +160,34 @@ mod tests { ] ) } + + #[test] + fn test_get_param_types_from_string() { + let input = r#"{"dryRun":":dryRun|boolean","limit":":limit|number","name":":name"}"#; + let types = get_param_types_from_string(input); + assert_eq!(types.get("dryRun"), Some(&Some("boolean".to_string()))); + assert_eq!(types.get("limit"), Some(&Some("number".to_string()))); + assert_eq!(types.get("name"), Some(&None)); + } + + #[test] + fn test_body_param_types() { + let cmd = Command { + method: http::HttpMethod::POST, + url: String::from("https://example.com/api"), + headers: HashMap::new(), + body: Some(json!({ + "dryRun": ":dryRun|boolean", + "limit": ":limit|number", + "file": ":filePath|file", + "name": ":name", + })), + postscript: None, + }; + let types = cmd.body_param_types(); + assert_eq!(types.get("dryRun"), Some(&Some("boolean".to_string()))); + assert_eq!(types.get("limit"), Some(&Some("number".to_string()))); + assert_eq!(types.get("filePath"), Some(&Some("file".to_string()))); + assert_eq!(types.get("name"), Some(&None)); + } } diff --git a/src/core/config.rs b/src/core/config.rs index c3753b8..ac4b1b2 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -46,7 +46,7 @@ impl Config { let reader = BufReader::new(file); let config: Config = serde_json::from_reader(reader).expect("Error while reading JSON"); - return config; + config } pub fn save(&self) -> Result<(), std::io::Error> { let file_path = get_config_file_path(); diff --git a/src/core/env.rs b/src/core/env.rs index 306933a..ff5e4ee 100644 --- a/src/core/env.rs +++ b/src/core/env.rs @@ -9,17 +9,13 @@ pub fn get_env() -> Option { None } -pub fn set_env(env: String) -> () { +pub fn set_env(env: String) { let mut app_config = get_app_config(); app_config.set_current_env(env); } pub fn list_envs() -> Vec { - let mut envs = Config::new() - .envs - .keys() - .map(|k| k.clone()) - .collect::>(); + let mut envs = Config::new().envs.keys().cloned().collect::>(); envs.sort(); envs } diff --git a/src/core/ephenv.rs b/src/core/ephenv.rs index 8e3307d..9dedc60 100644 --- a/src/core/ephenv.rs +++ b/src/core/ephenv.rs @@ -9,7 +9,7 @@ pub fn get_ephenvs() -> HashMap { HashMap::new() } -pub fn set_ephenv(key: String, value: String) -> () { +pub fn set_ephenv(key: String, value: String) { let mut app_config = get_app_config(); app_config.set_ephenv(key, value); } diff --git a/src/core/openapi.rs b/src/core/openapi.rs index 6111121..e09e394 100644 --- a/src/core/openapi.rs +++ b/src/core/openapi.rs @@ -38,38 +38,26 @@ pub fn generate_config(spec: &OpenAPI) -> Result> { }; // Process operations (GET, POST, PUT, DELETE, etc.) + process_operation(&mut tag_operations, path, path_item, &path_item.get, "get"); process_operation( &mut tag_operations, - &path, - &path_item, - &path_item.get, - "get", - ); - process_operation( - &mut tag_operations, - &path, - &path_item, + path, + path_item, &path_item.post, "post", ); + process_operation(&mut tag_operations, path, path_item, &path_item.put, "put"); process_operation( &mut tag_operations, - &path, - &path_item, - &path_item.put, - "put", - ); - process_operation( - &mut tag_operations, - &path, - &path_item, + path, + path_item, &path_item.delete, "delete", ); process_operation( &mut tag_operations, - &path, - &path_item, + path, + path_item, &path_item.patch, "patch", ); @@ -128,7 +116,7 @@ fn process_operation<'a>( tag.clone() } else { let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); - match segments.get(0) { + match segments.first() { Some(&segment) => segment.to_string(), None => "default".to_string(), } @@ -136,7 +124,7 @@ fn process_operation<'a>( tag_operations .entry(section) - .or_insert_with(Vec::new) + .or_default() .push((path, path_item, operation.clone())); } } @@ -158,25 +146,25 @@ fn create_command_for_operation( if operation .operation_id .as_ref() - .map_or(false, |id| id.starts_with("get")) + .is_some_and(|id| id.starts_with("get")) { HttpMethod::GET } else if operation .operation_id .as_ref() - .map_or(false, |id| id.starts_with("create")) + .is_some_and(|id| id.starts_with("create")) { HttpMethod::POST } else if operation .operation_id .as_ref() - .map_or(false, |id| id.starts_with("update")) + .is_some_and(|id| id.starts_with("update")) { HttpMethod::PUT } else if operation .operation_id .as_ref() - .map_or(false, |id| id.starts_with("delete")) + .is_some_and(|id| id.starts_with("delete")) { HttpMethod::DELETE } else { diff --git a/src/main.rs b/src/main.rs index 5b129f7..a537de9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,9 +3,7 @@ mod constants; mod core; mod utils; -use human_panic; use std::process; -use tokio; #[tokio::main] async fn main() -> process::ExitCode { diff --git a/src/utils/error.rs b/src/utils/error.rs index c5152dc..4e36b33 100644 --- a/src/utils/error.rs +++ b/src/utils/error.rs @@ -4,7 +4,6 @@ use std::fmt; #[derive(Debug)] pub struct CliError { pub message: String, - pub help: Option, } impl fmt::Display for CliError { diff --git a/src/utils/http.rs b/src/utils/http.rs index 337ef08..c4e8a9b 100644 --- a/src/utils/http.rs +++ b/src/utils/http.rs @@ -1,9 +1,14 @@ use reqwest; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::path::PathBuf; use strum::Display; #[derive(Display, Deserialize, Serialize, Clone, Debug)] +#[expect( + clippy::upper_case_acronyms, + reason = "HTTP method names are conventionally uppercase and are serialized as-is in config files" +)] pub enum HttpMethod { GET, POST, @@ -12,7 +17,7 @@ pub enum HttpMethod { PATCH, } -#[derive(Deserialize, Serialize, Clone)] +#[derive(Deserialize, Serialize, Clone, Debug)] pub struct Response { pub url: String, pub status: u16, @@ -25,7 +30,8 @@ pub async fn handle_request( http_method: &HttpMethod, headers: &HashMap, body: Option, -) -> Result { + file_fields: Option>, +) -> Result> { let client = reqwest::Client::new(); let method: reqwest::Method = match http_method { HttpMethod::GET => reqwest::Method::GET, @@ -39,6 +45,15 @@ pub async fn handle_request( let mut owned_headers = headers.clone(); owned_headers.insert("User-Agent".to_string(), "hit-cli".to_string()); + let has_file_fields = file_fields + .as_ref() + .is_some_and(|fields| !fields.is_empty()); + + if has_file_fields { + owned_headers.remove("Content-Type"); + owned_headers.remove("content-type"); + } + let mut headers_map = reqwest::header::HeaderMap::new(); headers_map.extend(owned_headers.into_iter().map(|(k, v)| { @@ -50,15 +65,47 @@ pub async fn handle_request( let request_builder = reqwest::RequestBuilder::from_parts(client, request).headers(headers_map); - let request_builder = match body { - Some(body) => { - if let Ok(json_body) = serde_json::from_str::(&body) { - request_builder.json(&json_body) - } else { - request_builder.body(body) + let request_builder = if has_file_fields { + let file_fields = file_fields.unwrap(); + let mut form = reqwest::multipart::Form::new(); + + for (field_name, file_path) in &file_fields { + let file_bytes = std::fs::read(file_path)?; + let file_name = file_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let part = reqwest::multipart::Part::bytes(file_bytes).file_name(file_name); + form = form.part(field_name.clone(), part); + } + + if let Some(ref body_str) = body { + if let Ok(json_body) = serde_json::from_str::(body_str) { + if let Some(obj) = json_body.as_object() { + for (key, value) in obj { + let text_value = match value { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + form = form.text(key.clone(), text_value); + } + } + } + } + + request_builder.multipart(form) + } else { + match body { + Some(body) => { + if let Ok(json_body) = serde_json::from_str::(&body) { + request_builder.json(&json_body) + } else { + request_builder.body(body) + } } + None => request_builder, } - None => request_builder, }; let response = request_builder.send().await?; diff --git a/src/utils/input.rs b/src/utils/input.rs index 1d742fb..b7e184a 100644 --- a/src/utils/input.rs +++ b/src/utils/input.rs @@ -19,8 +19,7 @@ impl Autocomplete for CustomAutocomplete { .suggestions .iter() .filter(|s| s.to_lowercase().contains(&input_lower)) - // NOTE(meshde): the following line converts Vec<&String> to Vec - .map(|s| s.clone()) + .cloned() .collect()) } fn get_completion( diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs index 7f357e7..6b4a94b 100644 --- a/tests/fixtures/mod.rs +++ b/tests/fixtures/mod.rs @@ -63,3 +63,34 @@ pub fn get_hit_command_for_setup(setup: &SetupFixture) -> Command { cmd.env("APP_CONFIG_DIR", app_config_dir); return cmd; } + +pub fn setup_with_mock( + temp_dir: TempDir, + server_url: &str, + commands_json: serde_json::Value, +) -> SetupFixture { + let config_path = temp_dir.path().join(".hit").join("config.json"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + + let test_config = serde_json::json!({ + "envs": { + "dev": { + "API_URL": server_url + } + }, + "commands": commands_json + }); + + fs::write(&config_path, test_config.to_string()).unwrap(); + + let setup = SetupFixture { temp_dir }; + + // Set env to "dev" + let mut use_cmd = get_hit_command_for_setup(&setup); + use_cmd.args(["env", "use", "dev"]); + use_cmd + .output() + .expect("Failed to set env to dev in setup_with_mock"); + + setup +} diff --git a/tests/run_tests.rs b/tests/run_tests.rs index af3cf0d..125e625 100644 --- a/tests/run_tests.rs +++ b/tests/run_tests.rs @@ -1,7 +1,9 @@ mod fixtures; use assert_cmd::prelude::*; -use fixtures::{get_hit_command_for_setup, hit_setup, SetupFixture}; +use fixtures::{get_hit_command_for_setup, hit_setup, setup_with_mock, temp_dir, SetupFixture}; +use predicates::prelude::*; use rstest::*; +use tempfile::TempDir; #[rstest] fn test_failure_when_env_not_set( @@ -24,3 +26,411 @@ fn test_failure_when_env_not_recognized(hit_setup: SetupFixture) -> () { cmd.args(["run", "get-by-id", "--id", "meshde"]); cmd.assert().failure().stderr("env not recognized\n"); } + +// --- Feature D: Typed Parameter Substitution --- + +#[rstest] +fn test_typed_boolean_param(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/endpoint") + .match_body(mockito::Matcher::Json(serde_json::json!({ + "dryRun": true, + "name": "John" + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"result":"ok"}"#) + .create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/endpoint", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "dryRun": ":dryRun|boolean", + "name": ":name" + } + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args([ + "run", + "cmd", + "--dry-run", + "true", + "--name", + "John", + "--json", + ]); + cmd.assert().success(); + + mock.assert(); + Ok(()) +} + +#[rstest] +fn test_typed_number_param(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/endpoint") + .match_body(mockito::Matcher::Json(serde_json::json!({ + "limit": 42 + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"result":"ok"}"#) + .create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/endpoint", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "limit": ":limit|number" + } + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args(["run", "cmd", "--limit", "42", "--json"]); + cmd.assert().success(); + + mock.assert(); + Ok(()) +} + +#[rstest] +fn test_typed_boolean_validation_error( + temp_dir: TempDir, +) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let _mock = server.mock("POST", "/endpoint").with_status(200).create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/endpoint", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "dryRun": ":dryRun|boolean" + } + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args(["run", "cmd", "--dry-run", "notabool"]); + cmd.assert() + .failure() + .stderr(predicate::str::contains("boolean")); + + Ok(()) +} + +#[rstest] +fn test_typed_number_validation_error(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let _mock = server.mock("POST", "/endpoint").with_status(200).create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/endpoint", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "limit": ":limit|number" + } + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args(["run", "cmd", "--limit", "notanumber"]); + cmd.assert() + .failure() + .stderr(predicate::str::contains("number")); + + Ok(()) +} + +// --- Feature A: Multipart File Uploads --- + +#[rstest] +fn test_file_upload_multipart(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/upload") + .match_header( + "content-type", + mockito::Matcher::Regex("multipart/form-data.*".to_string()), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"result":"uploaded"}"#) + .create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/upload", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "file": ":filePath|file", + "dryRun": ":dryRun|boolean" + } + } + }); + + // Create a temp CSV file + let csv_path = temp_dir.path().join("test.csv"); + std::fs::write(&csv_path, "col1,col2\nval1,val2\n")?; + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args([ + "run", + "cmd", + "--file-path", + csv_path.to_str().unwrap(), + "--dry-run", + "true", + "--json", + ]); + cmd.assert().success(); + + mock.assert(); + Ok(()) +} + +#[rstest] +fn test_file_not_found_error(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let _mock = server.mock("POST", "/upload").with_status(200).create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/upload", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "file": ":filePath|file" + } + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args(["run", "cmd", "--file-path", "/nonexistent/path.csv"]); + cmd.assert() + .failure() + .stderr(predicate::str::contains("File not found")); + + Ok(()) +} + +// --- Feature B: Non-Interactive Body Handling --- + +#[rstest] +fn test_no_editor_by_default(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/endpoint") + .match_body(mockito::Matcher::Json(serde_json::json!({ + "name": "John" + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"result":"ok"}"#) + .create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/endpoint", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "name": ":name" + } + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args(["run", "cmd", "--name", "John", "--json"]); + cmd.assert().success(); + + mock.assert(); + Ok(()) +} + +#[rstest] +fn test_body_file_flag(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/endpoint") + .match_body(mockito::Matcher::Json(serde_json::json!({ + "custom": "payload" + }))) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"result":"ok"}"#) + .create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/endpoint", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "name": ":name" + } + } + }); + + // Create a temp body file + let body_path = temp_dir.path().join("body.json"); + std::fs::write(&body_path, r#"{"custom": "payload"}"#)?; + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args([ + "run", + "cmd", + "--body-file", + body_path.to_str().unwrap(), + "--json", + ]); + cmd.assert().success(); + + mock.assert(); + Ok(()) +} + +#[rstest] +fn test_body_file_not_found(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let _mock = server.mock("POST", "/endpoint").with_status(200).create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "POST", + "url": "{{API_URL}}/endpoint", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "name": ":name" + } + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args([ + "run", + "cmd", + "--body-file", + "/nonexistent/body.json", + "--json", + ]); + cmd.assert() + .failure() + .stderr(predicate::str::contains("Failed to read body file")); + + Ok(()) +} + +// --- Feature C: Structured JSON Output --- + +#[rstest] +fn test_json_output_structure(temp_dir: TempDir) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/items/123") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"result":"ok"}"#) + .create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "GET", + "url": "{{API_URL}}/items/:id" + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args(["run", "cmd", "--id", "123", "--json"]); + + let output = cmd.output()?; + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout)?; + let json_response: serde_json::Value = serde_json::from_str(&stdout)?; + + assert!(json_response.get("url").is_some()); + assert!(json_response.get("status").is_some()); + assert!(json_response.get("headers").is_some()); + assert!(json_response.get("body").is_some()); + assert_eq!(json_response["status"], 200); + + mock.assert(); + Ok(()) +} + +#[rstest] +fn test_default_output_not_json_structured( + temp_dir: TempDir, +) -> Result<(), Box> { + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/items/123") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"result":"ok"}"#) + .create(); + + let commands = serde_json::json!({ + "cmd": { + "method": "GET", + "url": "{{API_URL}}/items/:id" + } + }); + + let setup = setup_with_mock(temp_dir, &server.url(), commands); + let mut cmd = get_hit_command_for_setup(&setup); + cmd.args(["run", "cmd", "--id", "123"]); + + let output = cmd.output()?; + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout)?; + // Without --json, output should NOT be the structured JSON with url/status/headers/body + assert!(!stdout.starts_with("{\"url\":")); + + mock.assert(); + Ok(()) +}