moonlight-http 0.1.2

HTTP shadow proxy for comparing candidate service behavior against reference targets.
Documentation
use clap::{ArgAction, Parser};
use moonlight_core::config::{
    extend, normalize_base_url, normalize_timeout, AppConfig, ResponseTiming, ReturnFallback,
    ReturnTarget,
};
use std::{net::SocketAddr, path::PathBuf};

#[derive(Debug, Parser)]
#[command(
    name = "moonlight-http",
    version,
    about = "Run the Moonlight HTTP shadow proxy",
    after_help = "Examples:\n  moonlight-http --primary-url http://127.0.0.1:3001 --candidate-url http://127.0.0.1:3002\n  moonlight-http --config moonlight.conf --response-timing return-selected"
)]
pub(crate) struct ProxyArgs {
    #[arg(
        long,
        value_name = "PATH",
        help_heading = "Config",
        conflicts_with = "no_config",
        help = "TOML config file to read instead of ./moonlight.conf"
    )]
    pub(crate) config: Option<PathBuf>,

    #[arg(long, help_heading = "Config", help = "Do not read ./moonlight.conf")]
    pub(crate) no_config: bool,

    #[arg(
        long,
        value_name = "ADDR",
        help_heading = "HTTP",
        help = "HTTP proxy bind address"
    )]
    bind_addr: Option<SocketAddr>,

    #[arg(
        long,
        value_name = "URL",
        help_heading = "Targets",
        help = "Primary reference target base URL"
    )]
    primary_url: Option<String>,

    #[arg(
        long,
        value_name = "URL",
        help_heading = "Targets",
        help = "Candidate target base URL"
    )]
    candidate_url: Option<String>,

    #[arg(
        long,
        value_name = "URL",
        help_heading = "Targets",
        help = "Secondary reference target base URL"
    )]
    secondary_url: Option<String>,

    #[arg(
        long,
        action = ArgAction::SetTrue,
        conflicts_with = "disable_secondary",
        help_heading = "Targets",
        help = "Enable the secondary reference target"
    )]
    enable_secondary: bool,

    #[arg(
        long,
        action = ArgAction::SetTrue,
        conflicts_with = "enable_secondary",
        help_heading = "Targets",
        help = "Disable the secondary reference target"
    )]
    disable_secondary: bool,

    #[arg(
        long,
        value_name = "TARGET",
        value_parser = ["primary", "candidate"],
        help_heading = "Response",
        help = "Target response returned to callers"
    )]
    return_target: Option<String>,

    #[arg(
        long,
        value_name = "MODE",
        value_parser = ["none", "primary"],
        help_heading = "Response",
        help = "Fallback behavior when returning the candidate response fails"
    )]
    return_fallback: Option<String>,

    #[arg(
        long,
        value_name = "MODE",
        value_parser = ["wait-all", "return-selected"],
        help_heading = "Response",
        help = "Whether to wait for all targets before returning a response"
    )]
    response_timing: Option<String>,

    #[arg(
        long,
        value_name = "BYTES",
        help_heading = "Comparison",
        help = "Maximum response body bytes to store"
    )]
    max_body_capture_bytes: Option<usize>,

    #[arg(
        long,
        value_name = "BYTES",
        help_heading = "HTTP",
        help = "Maximum request body bytes to accept"
    )]
    max_request_body_bytes: Option<usize>,

    #[arg(
        long = "redact-header",
        value_name = "HEADER",
        action = ArgAction::Append,
        help_heading = "Redaction",
        help = "Header name to redact from stored observations"
    )]
    redact_headers: Vec<String>,

    #[arg(
        long = "redact-json-path",
        value_name = "PATH",
        action = ArgAction::Append,
        help_heading = "Redaction",
        help = "Exact JSON body path to redact from stored previews and diffs"
    )]
    redact_json_paths: Vec<String>,

    #[arg(
        long = "redact-json-path-pattern",
        value_name = "PATTERN",
        action = ArgAction::Append,
        help_heading = "Redaction",
        help = "JSON body path pattern to redact from stored previews and diffs"
    )]
    redact_json_path_patterns: Vec<String>,

    #[arg(
        long = "redact-query-param",
        value_name = "PARAM",
        action = ArgAction::Append,
        help_heading = "Redaction",
        help = "Query parameter name to redact from stored request input"
    )]
    redact_query_params: Vec<String>,

    #[arg(
        long = "ignore-json-path",
        value_name = "PATH",
        action = ArgAction::Append,
        help_heading = "Comparison",
        help = "Exact JSON diff path to ignore"
    )]
    ignore_json_paths: Vec<String>,

    #[arg(
        long = "ignore-json-path-pattern",
        value_name = "PATTERN",
        action = ArgAction::Append,
        help_heading = "Comparison",
        help = "JSON diff path pattern to ignore; supports * and [*]"
    )]
    ignore_json_path_patterns: Vec<String>,

    #[arg(
        long = "ignore-header",
        value_name = "HEADER",
        action = ArgAction::Append,
        help_heading = "Comparison",
        help = "Header name to ignore during comparison"
    )]
    ignore_headers: Vec<String>,

    #[arg(
        long,
        action = ArgAction::SetTrue,
        help_heading = "Comparison",
        help = "Ignore stderr differences"
    )]
    ignore_stderr: bool,

    #[arg(
        long,
        value_name = "MS",
        help_heading = "Comparison",
        help = "Per-target timeout in milliseconds; 0 uses the default"
    )]
    target_timeout_ms: Option<u64>,

    #[arg(
        long,
        value_name = "PATH",
        help_heading = "Storage",
        help = "JSONL storage file"
    )]
    storage_path: Option<PathBuf>,

    #[arg(
        long = "cors-origin",
        value_name = "ORIGIN",
        action = ArgAction::Append,
        help_heading = "HTTP",
        help = "Allowed admin API CORS origin"
    )]
    cors_origins: Vec<String>,

    #[arg(
        long,
        value_name = "TOKEN",
        help_heading = "HTTP",
        help = "Admin API bearer token"
    )]
    admin_token: Option<String>,

    #[arg(
        long,
        value_name = "N",
        help_heading = "Storage",
        help = "Maximum active JSONL runs to retain"
    )]
    retention_max_runs: Option<usize>,

    #[arg(
        long,
        value_name = "BYTES",
        help_heading = "Storage",
        help = "Maximum active JSONL bytes to retain"
    )]
    retention_max_bytes: Option<u64>,
}

impl ProxyArgs {
    pub(crate) fn apply_to(self, mut config: AppConfig) -> anyhow::Result<AppConfig> {
        if let Some(bind_addr) = self.bind_addr {
            config.bind_addr = bind_addr;
        }
        if let Some(primary_url) = self.primary_url {
            config.primary_url = normalize_base_url(&primary_url);
        }
        if let Some(candidate_url) = self.candidate_url {
            config.candidate_url = normalize_base_url(&candidate_url);
        }
        if let Some(secondary_url) = self.secondary_url {
            config.secondary_url = normalize_base_url(&secondary_url);
            config.enable_secondary = true;
        }
        if self.enable_secondary {
            config.enable_secondary = true;
        }
        if self.disable_secondary {
            config.enable_secondary = false;
        }
        if let Some(return_target) = self.return_target {
            config.return_target = return_target.parse::<ReturnTarget>()?;
        }
        if let Some(return_fallback) = self.return_fallback {
            config.return_fallback = return_fallback.parse::<ReturnFallback>()?;
        }
        if let Some(response_timing) = self.response_timing {
            config.response_timing = response_timing.parse::<ResponseTiming>()?;
        }
        if let Some(max_body_capture_bytes) = self.max_body_capture_bytes {
            config.max_body_capture_bytes = max_body_capture_bytes;
        }
        if let Some(max_request_body_bytes) = self.max_request_body_bytes {
            config.max_request_body_bytes = max_request_body_bytes;
        }
        extend(&mut config.redact_headers, &self.redact_headers);
        extend(&mut config.redact_json_paths, &self.redact_json_paths);
        extend(
            &mut config.redact_json_path_patterns,
            &self.redact_json_path_patterns,
        );
        extend(&mut config.redact_query_params, &self.redact_query_params);
        extend(&mut config.ignore_json_paths, &self.ignore_json_paths);
        extend(
            &mut config.ignore_json_path_patterns,
            &self.ignore_json_path_patterns,
        );
        extend(&mut config.ignore_headers, &self.ignore_headers);
        if self.ignore_stderr {
            config.ignore_stderr = true;
        }
        if let Some(target_timeout_ms) = self.target_timeout_ms {
            config.target_timeout_ms = normalize_timeout(target_timeout_ms);
        }
        if let Some(storage_path) = self.storage_path {
            config.storage_path = storage_path;
        }
        extend(&mut config.cors_origins, &self.cors_origins);
        if let Some(admin_token) = self.admin_token {
            config.admin_token = moonlight_core::config::nonempty(&admin_token);
        }
        if let Some(retention_max_runs) = self.retention_max_runs {
            config.retention_max_runs = Some(retention_max_runs);
        }
        if let Some(retention_max_bytes) = self.retention_max_bytes {
            config.retention_max_bytes = Some(retention_max_bytes);
        }

        config.validate_http()?;
        Ok(config)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn parse(args: &[&str]) -> ProxyArgs {
        let argv = std::iter::once("moonlight-http").chain(args.iter().copied());
        ProxyArgs::try_parse_from(argv).unwrap()
    }

    #[test]
    fn primary_and_candidate_urls_are_required() {
        let error = parse(&[]).apply_to(AppConfig::defaults()).unwrap_err();

        assert!(error.to_string().contains("primary URL is required"));
    }

    #[test]
    fn target_urls_and_secondary_flag_merge_into_defaults() {
        let config = parse(&[
            "--primary-url",
            "http://primary/",
            "--candidate-url",
            "http://candidate/",
            "--secondary-url",
            "http://secondary/",
        ])
        .apply_to(AppConfig::defaults())
        .unwrap();

        assert_eq!(config.primary_url, "http://primary");
        assert_eq!(config.candidate_url, "http://candidate");
        assert_eq!(config.secondary_url, "http://secondary");
        assert!(config.enable_secondary);
    }

    #[test]
    fn response_timing_uses_kebab_case_values() {
        let config = parse(&[
            "--primary-url",
            "http://primary",
            "--candidate-url",
            "http://candidate",
            "--response-timing",
            "return-selected",
        ])
        .apply_to(AppConfig::defaults())
        .unwrap();

        assert_eq!(config.response_timing, ResponseTiming::ReturnSelected);

        let argv = ["moonlight-http", "--response-timing", "return_selected"];
        assert!(ProxyArgs::try_parse_from(argv).is_err());
    }

    #[test]
    fn ignore_flags_extend_defaults() {
        let config = parse(&[
            "--primary-url",
            "http://primary",
            "--candidate-url",
            "http://candidate",
            "--ignore-header",
            "x-generated",
            "--ignore-json-path",
            "$.volatile",
        ])
        .apply_to(AppConfig::defaults())
        .unwrap();

        assert!(config.ignore_headers.contains(&"date".to_string()));
        assert!(config.ignore_headers.contains(&"x-generated".to_string()));
        assert!(config.ignore_json_paths.contains(&"$.id".to_string()));
        assert!(config.ignore_json_paths.contains(&"$.volatile".to_string()));
    }

    #[test]
    fn old_ignored_flags_are_rejected() {
        let argv = ["moonlight-http", "--ignored-header", "date"];

        assert!(ProxyArgs::try_parse_from(argv).is_err());
    }
}