use serde_json::Value;
#[derive(Debug, Clone)]
pub struct FetchInput {
pub path: String,
pub method: String,
pub headers: Vec<(String, String)>,
pub body: Option<String>,
pub query: Vec<(String, String)>,
}
#[derive(Debug, Clone)]
pub struct FetchOutput {
pub ok: bool,
pub status: u16,
pub data: Value,
pub headers: Vec<(String, String)>,
}
fn is_reserved_flag(key: &str) -> bool {
matches!(key, "method" | "body" | "data" | "header")
}
fn reserved_short(ch: char) -> Option<&'static str> {
match ch {
'X' => Some("method"),
'd' => Some("data"),
'H' => Some("header"),
_ => None,
}
}
pub fn parse_argv(argv: &[String]) -> FetchInput {
let mut segments: Vec<String> = Vec::new();
let mut headers: Vec<(String, String)> = Vec::new();
let mut query: Vec<(String, String)> = Vec::new();
let mut method: Option<String> = None;
let mut body: Option<String> = None;
let mut handle_reserved = |key: &str, value: &str| match key {
"method" => method = Some(value.to_uppercase()),
"body" | "data" => body = Some(value.to_string()),
"header" => {
if let Some(colon_idx) = value.find(':') {
let name = value[..colon_idx].trim().to_string();
let val = value[colon_idx + 1..].trim().to_string();
headers.push((name, val));
}
}
_ => {}
};
let mut i = 0;
while i < argv.len() {
let token = &argv[i];
if token.starts_with("--") {
if let Some(eq_idx) = token.find('=') {
let key = &token[2..eq_idx];
let value = &token[eq_idx + 1..];
if is_reserved_flag(key) {
handle_reserved(key, value);
} else {
query.push((key.to_string(), value.to_string()));
}
i += 1;
} else {
let key = &token[2..];
let value = argv.get(i + 1).map(|s| s.as_str()).unwrap_or("");
if is_reserved_flag(key) {
handle_reserved(key, value);
i += 2;
} else {
query.push((key.to_string(), value.to_string()));
i += 2;
}
}
} else if token.starts_with('-') && token.len() == 2 {
let short = token.chars().nth(1).unwrap_or('?');
let value = argv.get(i + 1).map(|s| s.as_str()).unwrap_or("");
if let Some(mapped) = reserved_short(short) {
handle_reserved(mapped, value);
i += 2;
} else {
i += 2;
}
} else {
segments.push(token.clone());
i += 1;
}
}
let path = if segments.is_empty() {
"/".to_string()
} else {
format!("/{}", segments.join("/"))
};
let resolved_method = method.unwrap_or_else(|| {
if body.is_some() {
"POST".to_string()
} else {
"GET".to_string()
}
});
FetchInput {
path,
method: resolved_method,
headers,
body,
query,
}
}
pub fn is_streaming_response(content_type: Option<&str>) -> bool {
content_type == Some("application/x-ndjson")
}
#[async_trait::async_trait]
pub trait FetchHandler: Send + Sync {
async fn handle(&self, request: FetchInput) -> FetchOutput;
}
pub struct FetchGatewayOptions {
pub description: Option<String>,
pub base_path: Option<String>,
pub output_policy: Option<crate::output::OutputPolicy>,
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(tokens: &[&str]) -> Vec<String> {
tokens.iter().map(|s| s.to_string()).collect()
}
#[test]
fn test_basic_path() {
let input = parse_argv(&argv(&["users", "123"]));
assert_eq!(input.path, "/users/123");
assert_eq!(input.method, "GET");
assert!(input.body.is_none());
}
#[test]
fn test_empty_path() {
let input = parse_argv(&argv(&[]));
assert_eq!(input.path, "/");
}
#[test]
fn test_method_long() {
let input = parse_argv(&argv(&["--method", "PUT", "users", "123"]));
assert_eq!(input.method, "PUT");
assert_eq!(input.path, "/users/123");
}
#[test]
fn test_method_short() {
let input = parse_argv(&argv(&["-X", "DELETE", "users", "123"]));
assert_eq!(input.method, "DELETE");
}
#[test]
fn test_body_sets_post() {
let input = parse_argv(&argv(&["-d", r#"{"name":"test"}"#, "users"]));
assert_eq!(input.method, "POST");
assert_eq!(input.body.as_deref(), Some(r#"{"name":"test"}"#));
}
#[test]
fn test_explicit_method_overrides_body() {
let input = parse_argv(&argv(&["-X", "PUT", "-d", r#"{"name":"test"}"#, "users"]));
assert_eq!(input.method, "PUT");
assert!(input.body.is_some());
}
#[test]
fn test_headers() {
let input = parse_argv(&argv(&["-H", "Authorization: Bearer token123", "users"]));
assert_eq!(input.headers.len(), 1);
assert_eq!(input.headers[0].0, "Authorization");
assert_eq!(input.headers[0].1, "Bearer token123");
}
#[test]
fn test_query_params() {
let input = parse_argv(&argv(&["users", "--limit", "10", "--offset", "20"]));
assert_eq!(input.path, "/users");
assert_eq!(input.query.len(), 2);
assert!(input.query.iter().any(|(k, v)| k == "limit" && v == "10"));
assert!(input.query.iter().any(|(k, v)| k == "offset" && v == "20"));
}
#[test]
fn test_query_with_equals() {
let input = parse_argv(&argv(&["users", "--limit=10"]));
assert_eq!(input.query.len(), 1);
assert_eq!(input.query[0].0, "limit");
assert_eq!(input.query[0].1, "10");
}
#[test]
fn test_data_long() {
let input = parse_argv(&argv(&["--data", r#"{"x":1}"#, "api"]));
assert_eq!(input.body.as_deref(), Some(r#"{"x":1}"#));
}
#[test]
fn test_body_long() {
let input = parse_argv(&argv(&["--body", r#"{"x":1}"#, "api"]));
assert_eq!(input.body.as_deref(), Some(r#"{"x":1}"#));
}
#[test]
fn test_is_streaming_response() {
assert!(is_streaming_response(Some("application/x-ndjson")));
assert!(!is_streaming_response(Some("application/json")));
assert!(!is_streaming_response(None));
}
#[test]
fn test_header_equals_syntax() {
let input = parse_argv(&argv(&["--header=Content-Type: application/json", "api"]));
assert_eq!(input.headers.len(), 1);
assert_eq!(input.headers[0].0, "Content-Type");
assert_eq!(input.headers[0].1, "application/json");
}
#[test]
fn test_mixed_everything() {
let input = parse_argv(&argv(&[
"-X",
"POST",
"-H",
"Authorization: Bearer tok",
"-d",
r#"{"a":1}"#,
"--limit",
"5",
"api",
"v1",
"data",
]));
assert_eq!(input.method, "POST");
assert_eq!(input.path, "/api/v1/data");
assert_eq!(input.body.as_deref(), Some(r#"{"a":1}"#));
assert_eq!(input.headers.len(), 1);
assert_eq!(input.query.len(), 1);
assert_eq!(input.query[0], ("limit".to_string(), "5".to_string()));
}
}