use axum::Json;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use reqwest::Method;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::str::FromStr;
use std::time::Duration;
const DEFAULT_TIMEOUT_MS: u64 = 15_000;
const MAX_TIMEOUT_MS: u64 = 60_000;
const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
#[derive(Deserialize)]
pub struct SimulateHttpBody {
pub url: String,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub method: Option<String>,
#[serde(default)]
pub headers: Option<BTreeMap<String, String>>,
#[serde(default)]
pub body: Option<Value>,
#[serde(default)]
pub timeout_ms: Option<u64>,
}
#[derive(Serialize)]
pub struct SimulateHttpResponse {
pub status: u16,
pub headers: BTreeMap<String, String>,
pub body: Value,
pub body_is_json: bool,
pub elapsed_ms: u128,
}
pub async fn post_simulate_http(Json(body): Json<SimulateHttpBody>) -> impl IntoResponse {
match execute(body).await {
Ok(resp) => (StatusCode::OK, Json(serde_json::to_value(resp).unwrap())).into_response(),
Err(err) => (
StatusCode::BAD_GATEWAY,
Json(json!({
"error": err.kind.as_str(),
"message": err.message,
})),
)
.into_response(),
}
}
#[derive(Debug)]
struct SimError {
kind: SimErrorKind,
message: String,
}
#[derive(Debug)]
enum SimErrorKind {
InvalidUrl,
InvalidMethod,
InvalidHeader,
RequestFailed,
Timeout,
BodyTooLarge,
}
impl SimErrorKind {
fn as_str(&self) -> &'static str {
match self {
Self::InvalidUrl => "invalid_url",
Self::InvalidMethod => "invalid_method",
Self::InvalidHeader => "invalid_header",
Self::RequestFailed => "request_failed",
Self::Timeout => "timeout",
Self::BodyTooLarge => "body_too_large",
}
}
}
async fn execute(body: SimulateHttpBody) -> Result<SimulateHttpResponse, SimError> {
let url = resolve_url(&body.url, body.base_url.as_deref())?;
let method = parse_method(body.method.as_deref())?;
let headers = build_headers(body.headers.as_ref())?;
let timeout = pick_timeout(body.timeout_ms);
let client = reqwest::Client::builder()
.timeout(timeout)
.redirect(reqwest::redirect::Policy::limited(5))
.build()
.map_err(|e| SimError {
kind: SimErrorKind::RequestFailed,
message: format!("client build failed: {e}"),
})?;
let mut req = client.request(method, url).headers(headers);
if let Some(payload) = body.body
&& !payload.is_null()
{
req = match payload {
Value::String(text) => req.body(text),
other => req.json(&other),
};
}
let started = std::time::Instant::now();
let response = req.send().await.map_err(map_reqwest_error)?;
let status = response.status().as_u16();
let resp_headers = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect::<BTreeMap<_, _>>();
let bytes = response.bytes().await.map_err(map_reqwest_error)?;
if bytes.len() > MAX_RESPONSE_BYTES {
return Err(SimError {
kind: SimErrorKind::BodyTooLarge,
message: format!(
"response body {} bytes exceeds limit {}",
bytes.len(),
MAX_RESPONSE_BYTES
),
});
}
let (parsed_body, is_json) = parse_body(&bytes);
Ok(SimulateHttpResponse {
status,
headers: resp_headers,
body: parsed_body,
body_is_json: is_json,
elapsed_ms: started.elapsed().as_millis(),
})
}
fn resolve_url(raw: &str, base: Option<&str>) -> Result<reqwest::Url, SimError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(SimError {
kind: SimErrorKind::InvalidUrl,
message: "url is empty".into(),
});
}
match reqwest::Url::parse(trimmed) {
Ok(url) => ensure_http_scheme(url),
Err(_) => resolve_with_base(trimmed, base),
}
}
fn resolve_with_base(path: &str, base: Option<&str>) -> Result<reqwest::Url, SimError> {
let Some(raw_base) = base.map(str::trim).filter(|s| !s.is_empty()) else {
return Err(SimError {
kind: SimErrorKind::InvalidUrl,
message: format!(
"'{path}' is relative but no base_url was provided. Set the Base URL field to an absolute http(s) origin such as https://api.example.com.",
),
});
};
let base_url = reqwest::Url::parse(raw_base).map_err(|e| SimError {
kind: SimErrorKind::InvalidUrl,
message: format!("base_url '{raw_base}' is not a valid absolute URL: {e}"),
})?;
let joined = base_url.join(path).map_err(|e| SimError {
kind: SimErrorKind::InvalidUrl,
message: format!("could not join '{path}' onto base_url '{raw_base}': {e}"),
})?;
ensure_http_scheme(joined)
}
fn ensure_http_scheme(url: reqwest::Url) -> Result<reqwest::Url, SimError> {
match url.scheme() {
"http" | "https" => Ok(url),
other => Err(SimError {
kind: SimErrorKind::InvalidUrl,
message: format!("scheme '{other}' is not allowed"),
}),
}
}
fn parse_method(raw: Option<&str>) -> Result<Method, SimError> {
let value = raw.unwrap_or("GET").trim();
if value.is_empty() {
return Ok(Method::GET);
}
Method::from_str(&value.to_uppercase()).map_err(|e| SimError {
kind: SimErrorKind::InvalidMethod,
message: format!("{e}"),
})
}
fn build_headers(map: Option<&BTreeMap<String, String>>) -> Result<HeaderMap, SimError> {
let mut headers = HeaderMap::new();
let Some(map) = map else {
return Ok(headers);
};
for (name, value) in map {
let header_name = HeaderName::from_str(name.as_str()).map_err(|e| SimError {
kind: SimErrorKind::InvalidHeader,
message: format!("header name '{name}': {e}"),
})?;
let header_value = HeaderValue::from_str(value.as_str()).map_err(|e| SimError {
kind: SimErrorKind::InvalidHeader,
message: format!("header value for '{name}': {e}"),
})?;
headers.insert(header_name, header_value);
}
Ok(headers)
}
fn pick_timeout(requested: Option<u64>) -> Duration {
let ms = requested.unwrap_or(DEFAULT_TIMEOUT_MS).min(MAX_TIMEOUT_MS);
Duration::from_millis(ms.max(1))
}
fn parse_body(bytes: &[u8]) -> (Value, bool) {
if bytes.is_empty() {
return (Value::Null, false);
}
match serde_json::from_slice::<Value>(bytes) {
Ok(value) => (value, true),
Err(_) => {
let text = String::from_utf8_lossy(bytes).into_owned();
(Value::String(text), false)
}
}
}
fn map_reqwest_error(err: reqwest::Error) -> SimError {
if err.is_timeout() {
return SimError {
kind: SimErrorKind::Timeout,
message: err.to_string(),
};
}
SimError {
kind: SimErrorKind::RequestFailed,
message: err.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_url_rejects_empty() {
let err = resolve_url(" ", None).unwrap_err();
assert!(matches!(err.kind, SimErrorKind::InvalidUrl));
}
#[test]
fn resolve_url_rejects_non_http_scheme() {
let err = resolve_url("file:///etc/passwd", None).unwrap_err();
assert!(matches!(err.kind, SimErrorKind::InvalidUrl));
}
#[test]
fn resolve_url_accepts_https() {
let url = resolve_url("https://example.com/x", None).unwrap();
assert_eq!(url.host_str(), Some("example.com"));
}
#[test]
fn resolve_url_requires_base_for_relative() {
let err = resolve_url("/api/x", None).unwrap_err();
assert!(matches!(err.kind, SimErrorKind::InvalidUrl));
assert!(err.message.contains("base_url"));
}
#[test]
fn resolve_url_joins_relative_with_base() {
let url = resolve_url("/api/x", Some("https://example.com/v1")).unwrap();
assert_eq!(url.as_str(), "https://example.com/api/x");
}
#[test]
fn resolve_url_joins_relative_preserving_base_path() {
let url = resolve_url("tickets", Some("https://example.com/v1/")).unwrap();
assert_eq!(url.as_str(), "https://example.com/v1/tickets");
}
#[test]
fn resolve_url_rejects_invalid_base() {
let err = resolve_url("/api/x", Some("not a url")).unwrap_err();
assert!(matches!(err.kind, SimErrorKind::InvalidUrl));
}
#[test]
fn parse_method_defaults_to_get() {
assert_eq!(parse_method(None).unwrap(), Method::GET);
assert_eq!(parse_method(Some(" ")).unwrap(), Method::GET);
}
#[test]
fn parse_method_accepts_lowercase() {
assert_eq!(parse_method(Some("post")).unwrap(), Method::POST);
}
#[test]
fn build_headers_rejects_invalid_name() {
let mut map = BTreeMap::new();
map.insert("Bad Header".into(), "value".into());
let err = build_headers(Some(&map)).unwrap_err();
assert!(matches!(err.kind, SimErrorKind::InvalidHeader));
}
#[test]
fn pick_timeout_clamps_max() {
let picked = pick_timeout(Some(999_999));
assert_eq!(picked.as_millis() as u64, MAX_TIMEOUT_MS);
}
#[test]
fn pick_timeout_uses_default_when_missing() {
let picked = pick_timeout(None);
assert_eq!(picked.as_millis() as u64, DEFAULT_TIMEOUT_MS);
}
#[test]
fn parse_body_handles_json() {
let (value, is_json) = parse_body(br#"{"ok":true}"#);
assert!(is_json);
assert_eq!(value["ok"], Value::Bool(true));
}
#[test]
fn parse_body_falls_back_to_string() {
let (value, is_json) = parse_body(b"<html>hi</html>");
assert!(!is_json);
assert_eq!(value, Value::String("<html>hi</html>".into()));
}
#[test]
fn parse_body_empty_is_null() {
let (value, is_json) = parse_body(b"");
assert!(!is_json);
assert_eq!(value, Value::Null);
}
}