use async_trait::async_trait;
use super::{Builtin, Context};
use crate::error::Result;
use crate::interpreter::ExecResult;
pub struct Http;
#[derive(Debug, PartialEq)]
enum ItemType {
JsonField(String, String),
JsonRawField(String, String),
Header(String, String),
QueryParam(String, String),
}
struct HttpConfig {
method: String,
url: String,
items: Vec<ItemType>,
#[allow(dead_code)]
json_mode: bool,
form_mode: bool,
verbose: bool,
headers_only: bool,
output_file: Option<String>,
}
fn is_http_method(s: &str) -> bool {
matches!(
s.to_uppercase().as_str(),
"GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS"
)
}
fn parse_item(s: &str) -> Option<ItemType> {
if let Some(pos) = s.find(":=") {
let key = &s[..pos];
let val = &s[pos + 2..];
if !key.is_empty() {
return Some(ItemType::JsonRawField(key.to_string(), val.to_string()));
}
}
if let Some(pos) = s.find("==") {
let key = &s[..pos];
let val = &s[pos + 2..];
if !key.is_empty() {
return Some(ItemType::QueryParam(key.to_string(), val.to_string()));
}
}
if let Some(pos) = s.find('=') {
if pos > 0 && &s[pos - 1..pos] != ":" && (pos + 1 >= s.len() || &s[pos + 1..pos + 2] != "=")
{
let key = &s[..pos];
let val = &s[pos + 1..];
return Some(ItemType::JsonField(key.to_string(), val.to_string()));
}
}
if let Some(pos) = s.find(':') {
if pos > 0
&& (pos + 1 >= s.len() || &s[pos + 1..pos + 2] != "=")
&& !s[..pos].contains("//")
&& s[..pos]
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
let key = &s[..pos];
let val = &s[pos + 1..].trim_start();
return Some(ItemType::Header(key.to_string(), val.to_string()));
}
}
None
}
fn parse_http_args(args: &[String]) -> std::result::Result<HttpConfig, String> {
let mut json_mode = false;
let mut form_mode = false;
let mut verbose = false;
let mut headers_only = false;
let mut output_file = None;
let mut method: Option<String> = None;
let mut url: Option<String> = None;
let mut items = Vec::new();
let mut p = super::arg_parser::ArgParser::new(args);
while !p.is_done() {
if p.flag_any(&["--json", "-j"]) {
json_mode = true;
} else if p.flag_any(&["--form", "-f"]) {
form_mode = true;
} else if p.flag_any(&["-v", "--verbose"]) {
verbose = true;
} else if p.flag_any(&["-h", "--headers"]) {
headers_only = true;
} else if p.flag_any(&["-b", "--body"]) {
} else if let Some(val) = p.flag_value_any(&["-o", "--output"], "http")? {
output_file = Some(val.to_string());
} else if p.is_flag() && url.is_none() {
let flag = p.current().unwrap_or("?");
return Err(format!("http: unknown option '{}'", flag));
} else if let Some(arg) = p.positional() {
if url.is_none() {
if is_http_method(arg) && method.is_none() {
method = Some(arg.to_uppercase());
} else {
url = Some(arg.to_string());
}
} else {
match parse_item(arg) {
Some(item) => items.push(item),
None => {
return Err(format!("http: invalid item '{arg}'"));
}
}
}
}
}
let url = url.ok_or_else(|| "http: missing URL".to_string())?;
let has_data = items.iter().any(|item| {
matches!(
item,
ItemType::JsonField(_, _) | ItemType::JsonRawField(_, _)
)
});
let method = method.unwrap_or_else(|| {
if has_data {
"POST".to_string()
} else {
"GET".to_string()
}
});
if json_mode && form_mode {
return Err("http: --json and --form are mutually exclusive".to_string());
}
Ok(HttpConfig {
method,
url,
items,
json_mode,
form_mode,
verbose,
headers_only,
output_file,
})
}
fn build_url_with_query(base_url: &str, items: &[ItemType]) -> String {
let query_items: Vec<(&str, &str)> = items
.iter()
.filter_map(|item| {
if let ItemType::QueryParam(k, v) = item {
Some((k.as_str(), v.as_str()))
} else {
None
}
})
.collect();
if query_items.is_empty() {
return base_url.to_string();
}
let encoded: String = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(query_items)
.finish();
let sep = if base_url.contains('?') { "&" } else { "?" };
format!("{}{}{}", base_url, sep, encoded)
}
fn build_json_body(items: &[ItemType]) -> String {
let mut map = serde_json::Map::new();
for item in items {
match item {
ItemType::JsonField(k, v) => {
map.insert(k.clone(), serde_json::Value::String(v.clone()));
}
ItemType::JsonRawField(k, v) => {
let raw: serde_json::Value =
serde_json::from_str(v).unwrap_or(serde_json::Value::String(v.clone()));
map.insert(k.clone(), raw);
}
_ => {}
}
}
if map.is_empty() {
return String::new();
}
serde_json::to_string_pretty(&serde_json::Value::Object(map)).unwrap_or_default()
}
fn build_form_body(items: &[ItemType]) -> String {
let form_items: Vec<(&str, &str)> = items
.iter()
.filter_map(|item| {
if let ItemType::JsonField(k, v) = item {
Some((k.as_str(), v.as_str()))
} else {
None
}
})
.collect();
url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(form_items)
.finish()
}
fn format_request(config: &HttpConfig) -> String {
let mut output = String::new();
let url = build_url_with_query(&config.url, &config.items);
output.push_str(&format!("{} {} HTTP/1.1\n", config.method, url));
for item in &config.items {
if let ItemType::Header(k, v) = item {
output.push_str(&format!("{}: {}\n", k, v));
}
}
let has_data = config.items.iter().any(|item| {
matches!(
item,
ItemType::JsonField(_, _) | ItemType::JsonRawField(_, _)
)
});
if has_data {
if config.form_mode {
output.push_str("Content-Type: application/x-www-form-urlencoded\n");
} else {
output.push_str("Content-Type: application/json\n");
}
}
output.push('\n');
if has_data {
if config.form_mode {
output.push_str(&build_form_body(&config.items));
} else {
output.push_str(&build_json_body(&config.items));
}
output.push('\n');
}
output
}
#[async_trait]
impl Builtin for Http {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if ctx.args.is_empty() {
return Ok(ExecResult::err(
"http: usage: http [METHOD] URL [ITEMS...]\n".to_string(),
1,
));
}
let config = match parse_http_args(ctx.args) {
Ok(c) => c,
Err(e) => return Ok(ExecResult::err(format!("{e}\n"), 1)),
};
#[cfg(feature = "http_client")]
{
if let Some(http_client) = ctx.http_client {
return execute_http_request(http_client, &config, &ctx).await;
}
}
let _ = config.output_file;
let mut output = String::new();
output.push_str(&format_request(&config));
if !config.verbose && !config.headers_only {
output.push_str("http: network access not configured\n");
}
Ok(ExecResult::ok(output))
}
}
#[cfg(feature = "http_client")]
async fn execute_http_request(
http_client: &crate::network::HttpClient,
config: &HttpConfig,
ctx: &Context<'_>,
) -> Result<ExecResult> {
use crate::network::Method;
let method = match config.method.as_str() {
"GET" => Method::Get,
"POST" => Method::Post,
"PUT" => Method::Put,
"DELETE" => Method::Delete,
"PATCH" => Method::Patch,
"HEAD" => Method::Head,
_ => {
return Ok(ExecResult::err(
format!("http: unsupported method: {}\n", config.method),
1,
));
}
};
let url = build_url_with_query(&config.url, &config.items);
let mut header_pairs: Vec<(String, String)> = Vec::new();
for item in &config.items {
if let ItemType::Header(k, v) = item {
header_pairs.push((k.clone(), v.clone()));
}
}
let has_data = config.items.iter().any(|item| {
matches!(
item,
ItemType::JsonField(_, _) | ItemType::JsonRawField(_, _)
)
});
let body_str = if has_data {
if config.form_mode {
header_pairs.push((
"Content-Type".to_string(),
"application/x-www-form-urlencoded".to_string(),
));
build_form_body(&config.items)
} else {
header_pairs.push(("Content-Type".to_string(), "application/json".to_string()));
build_json_body(&config.items)
}
} else {
String::new()
};
let body_bytes = if body_str.is_empty() {
None
} else {
Some(body_str.as_bytes())
};
let result = http_client
.request_with_headers(method, &url, body_bytes, &header_pairs)
.await;
match result {
Ok(response) => {
let mut output = String::new();
if config.verbose {
output.push_str(&format!("{} {} HTTP/1.1\n", config.method, url));
for (k, v) in &header_pairs {
output.push_str(&format!("{}: {}\n", k, v));
}
output.push('\n');
output.push_str(&format!("HTTP/1.1 {}\n", response.status));
for (k, v) in &response.headers {
output.push_str(&format!("{}: {}\n", k, v));
}
output.push('\n');
}
if config.headers_only {
output.push_str(&format!("HTTP/1.1 {}\n", response.status));
for (k, v) in &response.headers {
output.push_str(&format!("{}: {}\n", k, v));
}
} else {
output.push_str(&response.body_string());
}
if let Some(ref file_path) = config.output_file {
let path = super::resolve_path(ctx.cwd, file_path);
if let Err(e) = ctx.fs.write_file(&path, output.as_bytes()).await {
return Ok(ExecResult::err(
format!("http: failed to write to {}: {}\n", file_path, e),
1,
));
}
return Ok(ExecResult::ok(String::new()));
}
Ok(ExecResult::ok(output))
}
Err(e) => Ok(ExecResult::err(format!("http: {}\n", e), 1)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use crate::fs::InMemoryFs;
async fn run_http(args: &[&str]) -> ExecResult {
let fs = Arc::new(InMemoryFs::new());
let mut variables = HashMap::new();
let env = HashMap::new();
let mut cwd = PathBuf::from("/");
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None);
Http.execute(ctx).await.unwrap()
}
#[tokio::test]
async fn test_no_args() {
let result = run_http(&[]).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("usage"));
}
#[tokio::test]
async fn test_simple_get() {
let result = run_http(&["https://example.com/api"]).await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("GET https://example.com/api"));
}
#[tokio::test]
async fn test_explicit_method() {
let result = run_http(&["DELETE", "https://example.com/api/1"]).await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("DELETE https://example.com/api/1"));
}
#[tokio::test]
async fn test_post_with_json_data() {
let result = run_http(&["https://example.com/api", "name=test", "count:=42"]).await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("POST"));
assert!(result.stdout.contains("Content-Type: application/json"));
assert!(result.stdout.contains("\"name\": \"test\""));
assert!(result.stdout.contains("\"count\": 42"));
}
#[tokio::test]
async fn test_custom_header() {
let result = run_http(&[
"GET",
"https://example.com/api",
"Authorization:Bearer token123",
])
.await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("Authorization: Bearer token123"));
}
#[tokio::test]
async fn test_query_params() {
let result = run_http(&["https://example.com/search", "q==rust", "page==1"]).await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("q=rust"));
assert!(result.stdout.contains("page=1"));
}
#[tokio::test]
async fn test_form_mode() {
let result = run_http(&[
"--form",
"POST",
"https://example.com/login",
"user=admin",
"pass=secret",
])
.await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("application/x-www-form-urlencoded"));
assert!(result.stdout.contains("user=admin&pass=secret"));
}
#[tokio::test]
async fn test_json_and_form_mutually_exclusive() {
let result = run_http(&["--json", "--form", "https://example.com/api", "key=val"]).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("mutually exclusive"));
}
#[tokio::test]
async fn test_missing_url() {
let result = run_http(&["GET"]).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("missing URL"));
}
#[tokio::test]
async fn test_unknown_option() {
let result = run_http(&["--unknown", "https://example.com"]).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("unknown option"));
}
#[tokio::test]
async fn test_network_not_configured_message() {
let result = run_http(&["https://example.com/api"]).await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("network access not configured"));
}
#[tokio::test]
async fn test_missing_o_argument() {
let result = run_http(&["-o"]).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("-o requires an argument"));
}
#[test]
fn test_parse_item_json_field() {
assert_eq!(
parse_item("name=value"),
Some(ItemType::JsonField("name".to_string(), "value".to_string()))
);
}
#[test]
fn test_parse_item_raw_field() {
assert_eq!(
parse_item("count:=42"),
Some(ItemType::JsonRawField(
"count".to_string(),
"42".to_string()
))
);
}
#[test]
fn test_parse_item_header() {
assert_eq!(
parse_item("Accept:application/json"),
Some(ItemType::Header(
"Accept".to_string(),
"application/json".to_string()
))
);
}
#[test]
fn test_parse_item_query() {
assert_eq!(
parse_item("q==search term"),
Some(ItemType::QueryParam(
"q".to_string(),
"search term".to_string()
))
);
}
#[test]
fn test_json_body_escapes_quotes() {
let items = vec![ItemType::JsonField(
"name".to_string(),
r#"test","admin":true,"x":"y"#.to_string(),
)];
let body = build_json_body(&items);
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
parsed["name"].as_str().unwrap(),
r#"test","admin":true,"x":"y"#
);
assert!(parsed.get("admin").is_none());
}
#[test]
fn test_json_body_escapes_backslash_and_newline() {
let items = vec![ItemType::JsonField(
"msg".to_string(),
"line1\nline2\\end".to_string(),
)];
let body = build_json_body(&items);
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["msg"].as_str().unwrap(), "line1\nline2\\end");
}
#[test]
fn test_json_body_raw_field_unchanged() {
let items = vec![ItemType::JsonRawField(
"count".to_string(),
"42".to_string(),
)];
let body = build_json_body(&items);
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["count"].as_i64().unwrap(), 42);
}
#[test]
fn test_query_param_injection_encoded() {
let items = vec![ItemType::QueryParam(
"q".to_string(),
"foo&admin=true".to_string(),
)];
let url = build_url_with_query("https://example.com", &items);
assert!(!url.contains("admin=true"));
assert!(url.contains("q=foo%26admin%3Dtrue") || url.contains("q=foo%26admin=true"));
}
#[test]
fn test_query_param_normal_value() {
let items = vec![ItemType::QueryParam(
"search".to_string(),
"hello world".to_string(),
)];
let url = build_url_with_query("https://example.com", &items);
assert!(url.contains("search=hello"));
}
#[test]
fn test_form_body_injection_encoded() {
let items = vec![ItemType::JsonField(
"user".to_string(),
"admin&role=superadmin".to_string(),
)];
let body = build_form_body(&items);
assert!(!body.contains("role=superadmin"));
assert!(
body.contains("user=admin%26role%3Dsuperadmin")
|| body.contains("user=admin%26role%3Dsuperadmin")
);
}
#[test]
fn test_form_body_normal_value() {
let items = vec![ItemType::JsonField("name".to_string(), "test".to_string())];
let body = build_form_body(&items);
assert_eq!(body, "name=test");
}
}