greentic-flow-builder 0.3.1

Greentic Flow Builder — orchestrator that powers Adaptive Card design via the adaptive-card-mcp toolkit
Documentation
//! `POST /api/simulate-http` — proxy an outbound HTTP request from the
//! browser-side demo simulator.
//!
//! Why a backend proxy? The UI simulates flows containing `component-http`
//! nodes. Browsers block cross-origin calls without CORS, and flow configs
//! may reference secrets that must never leave the server. This route forwards
//! the request through `reqwest`, shielding the client from CORS and keeping
//! headers like bearer tokens out of the browser devtools.

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,
    /// Optional absolute base URL that `url` is resolved against when `url`
    /// is relative. Lets the UI simulate component-http nodes that store the
    /// base URL in `config.base_url` and keep per-call paths relative.
    #[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,
    /// `true` when `body` was JSON-decoded, `false` when it is a raw string.
    pub body_is_json: bool,
    /// Elapsed round-trip time in milliseconds.
    pub elapsed_ms: u128,
}

/// Handler: validate the request, execute it, return a normalized response.
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);
    }
}