pub mod ai_prompt;
pub mod bench;
pub mod captured;
pub mod chain;
pub mod curl;
pub mod discover;
pub mod faker;
pub mod file;
pub mod history;
pub mod lookup;
pub mod mock;
pub mod proxy;
pub mod schema;
pub mod script;
pub mod sources;
pub mod template;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Request {
pub method: String,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Option<String>,
pub insecure: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
NoUrl,
UnterminatedQuote,
Empty,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::NoUrl => write!(f, "no URL found in request"),
ParseError::UnterminatedQuote => write!(f, "unterminated quote in curl command"),
ParseError::Empty => write!(f, "empty input"),
}
}
}
impl std::error::Error for ParseError {}
pub fn parse(input: &str) -> Result<Request, ParseError> {
parse_with_base(input, None)
}
pub fn parse_with_base(
input: &str,
base_dir: Option<&std::path::Path>,
) -> Result<Request, ParseError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(ParseError::Empty);
}
if looks_like_http_file(trimmed) {
return file::parse(trimmed);
}
match curl::parse_curl_with_base(trimmed, base_dir) {
Ok(r) => Ok(r),
Err(curl_err) => file::parse(trimmed).map_err(|_| curl_err),
}
}
fn looks_like_http_file(text: &str) -> bool {
for line in text.lines() {
let t = line.trim();
if t.is_empty() || t.starts_with('#') || t.starts_with("//") {
continue;
}
let head = t.split_whitespace().next().unwrap_or("");
return matches!(
head.to_ascii_uppercase().as_str(),
"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"
);
}
false
}
#[derive(Debug, Clone)]
pub struct Response {
pub status: u16,
pub status_text: String,
pub headers: Vec<(String, String)>,
pub body: String,
pub body_bytes: Vec<u8>,
pub elapsed: Duration,
pub timing: Timing,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Timing {
pub wait: Duration,
pub receive: Duration,
}
impl Response {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
pub fn content_type(&self) -> Option<&str> {
self.header("content-type")
}
pub fn looks_like_json(&self) -> bool {
self.content_type()
.map(|ct| ct.contains("json"))
.unwrap_or(false)
|| {
let b = self.body.trim_start();
b.starts_with('{') || b.starts_with('[')
}
}
}
pub fn send(req: &Request) -> Result<Response, String> {
let mut client_builder = reqwest::blocking::Client::builder().timeout(Duration::from_secs(30));
if req.insecure {
client_builder = client_builder.danger_accept_invalid_certs(true);
}
let client = client_builder
.build()
.map_err(|e| format!("client build failed: {e}"))?;
let method = reqwest::Method::from_bytes(req.method.to_uppercase().as_bytes())
.map_err(|_| format!("invalid HTTP method {:?}", req.method))?;
let mut builder = client.request(method, &req.url);
for (k, v) in &req.headers {
builder = builder.header(k.as_str(), v.as_str());
}
if let Some(body) = &req.body {
builder = builder.body(body.clone());
}
let start = Instant::now();
let resp = builder.send().map_err(|e| transport_error(&e))?;
let wait = start.elapsed();
let recv_start = Instant::now();
let status = resp.status().as_u16();
let status_text = resp.status().canonical_reason().unwrap_or("").to_string();
let headers = resp
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
const MAX_BODY: usize = 16 * 1024 * 1024;
let (body, body_bytes) = {
use std::io::Read;
let mut buf = Vec::with_capacity(64 * 1024);
let mut reader = resp.take(MAX_BODY as u64 + 1);
reader
.read_to_end(&mut buf)
.map_err(|e| format!("reading body failed: {e}"))?;
let truncated = buf.len() > MAX_BODY;
if truncated {
buf.truncate(MAX_BODY);
}
let mut display = String::from_utf8_lossy(&buf).into_owned();
if truncated {
display.push_str(
"\n\n[mnml: response body truncated at 16 MiB — drop to curl for the full payload]",
);
}
(display, buf)
};
let receive = recv_start.elapsed();
let elapsed = start.elapsed();
Ok(Response {
status,
status_text,
headers,
body,
body_bytes,
elapsed,
timing: Timing { wait, receive },
})
}
fn transport_error(e: &reqwest::Error) -> String {
if e.is_timeout() {
"request timed out".to_string()
} else if e.is_connect() {
format!("connection failed: {e}")
} else if e.is_builder() {
format!("bad request: {e}")
} else {
e.to_string()
}
}
pub(crate) fn dedupe_keep_last(headers: Vec<(String, String)>) -> Vec<(String, String)> {
let mut order: Vec<String> = Vec::new();
let mut last: std::collections::HashMap<String, (String, String)> =
std::collections::HashMap::new();
for (k, v) in headers {
let key = k.to_ascii_lowercase();
if !last.contains_key(&key) {
order.push(key.clone());
}
last.insert(key, (k, v));
}
order.into_iter().filter_map(|k| last.remove(&k)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_dispatches_to_curl_and_http_file() {
let c = parse("curl 'https://x.com/a' -H 'accept: */*'").unwrap();
assert_eq!(c.url, "https://x.com/a");
assert_eq!(c.method, "GET");
let h = parse("POST https://x.com/b\nContent-Type: application/json\n\n{\"a\":1}").unwrap();
assert_eq!(h.method, "POST");
assert_eq!(h.url, "https://x.com/b");
assert_eq!(h.body.as_deref(), Some("{\"a\":1}"));
assert_eq!(parse(" "), Err(ParseError::Empty));
assert_eq!(parse("nonsense").unwrap().url, "nonsense");
}
#[test]
fn dedupe_keeps_last_value_at_first_position() {
let got = dedupe_keep_last(vec![
("Accept".into(), "a".into()),
("X".into(), "1".into()),
("accept".into(), "b".into()),
]);
assert_eq!(
got,
vec![("accept".into(), "b".into()), ("X".into(), "1".into())]
);
}
}