relay-core-api 0.1.2

[Internal] Shared data contracts for relay-core. Use `relay-core-runtime` instead.
Documentation
use chrono::Utc;
use relay_core_api::flow::{
    BodyData, Direction, Flow, FlowUpdate, HttpLayer, HttpRequest, Layer, NetworkInfo,
    TransportProtocol, WebSocketMessage,
};
use relay_core_api::policy::{
    ProxyPolicy, ProxyPolicyPatch, QuicMode, RedactionPolicyPatch, TransparentLogLevel,
};
use relay_core_api::rule::{Action, Filter, Rule, RuleStage, RuleTermination, StringMatcher};
use serde_json::json;
use url::Url;
use uuid::Uuid;

fn sample_flow() -> Flow {
    Flow {
        id: Uuid::new_v4(),
        start_time: Utc::now(),
        end_time: None,
        network: NetworkInfo {
            client_ip: "127.0.0.1".to_string(),
            client_port: 12345,
            server_ip: "1.1.1.1".to_string(),
            server_port: 80,
            protocol: TransportProtocol::TCP,
            tls: false,
            tls_version: None,
            sni: None,
        },
        layer: Layer::Http(HttpLayer {
            request: HttpRequest {
                method: "GET".to_string(),
                url: Url::parse("http://example.com/api").expect("invalid url"),
                version: "HTTP/1.1".to_string(),
                headers: vec![("accept".to_string(), "application/json".to_string())],
                cookies: vec![],
                query: vec![("q".to_string(), "1".to_string())],
                body: None,
            },
            response: None,
            error: None,
        }),
        tags: vec!["test".to_string()],
        meta: std::collections::HashMap::from([("internal".to_string(), "yes".to_string())]),
    }
}

#[test]
fn test_flow_roundtrip_and_meta_skip() {
    let flow = sample_flow();
    let serialized = serde_json::to_value(&flow).expect("serialize flow failed");

    assert!(
        serialized.get("meta").is_none(),
        "meta should be skipped in serialization"
    );

    let parsed: Flow = serde_json::from_value(serialized).expect("deserialize flow failed");
    match parsed.layer {
        Layer::Http(http) => {
            assert_eq!(http.request.method, "GET");
            assert_eq!(http.request.url.as_str(), "http://example.com/api");
        }
        _ => panic!("expected http layer"),
    }
}

#[test]
fn test_flow_update_tagged_serialization() {
    let update = FlowUpdate::HttpBody {
        flow_id: "flow-1".to_string(),
        direction: relay_core_api::flow::Direction::ClientToServer,
        body: BodyData {
            encoding: "utf-8".to_string(),
            content: "hello".to_string(),
            size: 5,
        },
    };

    let value = serde_json::to_value(update).expect("serialize FlowUpdate failed");
    assert_eq!(value["type"], "HttpBody");
    assert_eq!(value["data"]["flow_id"], "flow-1");
}

#[test]
fn test_flow_update_websocket_message_roundtrip() {
    let msg = WebSocketMessage {
        id: Uuid::new_v4(),
        timestamp: Utc::now(),
        direction: Direction::ServerToClient,
        content: BodyData {
            encoding: "utf-8".to_string(),
            content: "pong".to_string(),
            size: 4,
        },
        opcode: "Text".to_string(),
    };
    let update = FlowUpdate::WebSocketMessage {
        flow_id: "flow-ws-1".to_string(),
        message: msg.clone(),
    };

    let value = serde_json::to_value(&update).expect("serialize ws update failed");
    assert_eq!(value["type"], "WebSocketMessage");
    assert_eq!(value["data"]["flow_id"], "flow-ws-1");
    assert_eq!(value["data"]["message"]["content"]["content"], "pong");

    let parsed: FlowUpdate = serde_json::from_value(value).expect("deserialize ws update failed");
    match parsed {
        FlowUpdate::WebSocketMessage { flow_id, message } => {
            assert_eq!(flow_id, "flow-ws-1");
            assert_eq!(message.direction, Direction::ServerToClient);
            assert_eq!(message.content.content, "pong");
            assert_eq!(message.opcode, "Text");
            assert_eq!(message.content.size, msg.content.size);
        }
        other => panic!("expected websocket message update, got {:?}", other),
    }
}

#[test]
fn test_proxy_policy_defaults_via_deserialize() {
    let policy: ProxyPolicy = serde_json::from_value(json!({})).expect("deserialize policy failed");

    assert!(policy.strict_http_semantics);
    assert!(!policy.enable_retry);
    assert_eq!(policy.max_retries, 3);
    assert_eq!(policy.request_timeout_ms, 30_000);
    assert_eq!(policy.max_body_size, 10 * 1024 * 1024);
    assert_eq!(policy.transparent_log_level, TransparentLogLevel::Info);
    assert_eq!(policy.quic_mode, QuicMode::Downgrade);
    assert!(!policy.redaction.enabled);
    assert!(!policy.redaction.redact_bodies);
    assert!(policy.redaction.sensitive_header_names.contains(&"authorization".to_string()));
    assert!(policy.redaction.sensitive_query_keys.contains(&"token".to_string()));
}

#[test]
fn test_proxy_policy_default_matches_empty_deserialize() {
    let from_default = ProxyPolicy::default();
    let from_deser: ProxyPolicy =
        serde_json::from_value(json!({})).expect("deserialize policy failed");

    assert_eq!(from_default.strict_http_semantics, from_deser.strict_http_semantics);
    assert_eq!(from_default.allow_fallback_method, from_deser.allow_fallback_method);
    assert_eq!(from_default.allow_fallback_status, from_deser.allow_fallback_status);
    assert_eq!(from_default.enable_retry, from_deser.enable_retry);
    assert_eq!(from_default.retry_idempotent_only, from_deser.retry_idempotent_only);
    assert_eq!(from_default.max_retries, from_deser.max_retries);
    assert_eq!(from_default.sandbox_root, from_deser.sandbox_root);
    assert_eq!(
        from_default.max_local_file_bytes,
        from_deser.max_local_file_bytes
    );
    assert_eq!(from_default.max_body_size, from_deser.max_body_size);
    assert_eq!(from_default.request_timeout_ms, from_deser.request_timeout_ms);
    assert_eq!(from_default.transparent_enabled, from_deser.transparent_enabled);
    assert_eq!(
        from_default.transparent_require_original_dst,
        from_deser.transparent_require_original_dst
    );
    assert_eq!(
        from_default.transparent_allow_host_fallback,
        from_deser.transparent_allow_host_fallback
    );
    assert_eq!(
        from_default.transparent_reject_loopback_target,
        from_deser.transparent_reject_loopback_target
    );
    assert_eq!(
        from_default.transparent_log_level,
        from_deser.transparent_log_level
    );
    assert_eq!(from_default.quic_mode, from_deser.quic_mode);
    assert_eq!(
        from_default.quic_downgrade_clear_cache,
        from_deser.quic_downgrade_clear_cache
    );
    assert_eq!(from_default.redaction, from_deser.redaction);
}

#[test]
fn test_proxy_policy_patch_deserialize_and_apply_redaction() {
    let patch: ProxyPolicyPatch = serde_json::from_value(json!({
        "redaction": {
            "enabled": true,
            "redact_bodies": true,
            "sensitive_query_keys": ["token", "secret"]
        }
    }))
    .expect("deserialize policy patch failed");

    let mut policy = ProxyPolicy::default();
    policy.apply_patch(patch);

    assert!(policy.redaction.enabled);
    assert!(policy.redaction.redact_bodies);
    assert_eq!(
        policy.redaction.sensitive_query_keys,
        vec!["token".to_string(), "secret".to_string()]
    );
}

#[test]
fn test_redaction_patch_partial_update_keeps_unspecified_fields() {
    let mut policy = ProxyPolicy::default();
    let original_headers = policy.redaction.sensitive_header_names.clone();
    policy.redaction.enabled = true;
    policy.redaction.redact_bodies = false;

    policy.apply_patch(ProxyPolicyPatch {
        redaction: Some(RedactionPolicyPatch {
            redact_bodies: Some(true),
            ..Default::default()
        }),
    });

    assert!(policy.redaction.enabled);
    assert!(policy.redaction.redact_bodies);
    assert_eq!(policy.redaction.sensitive_header_names, original_headers);
}

#[test]
fn test_rule_priority_defaults_to_zero_when_missing() {
    let value = json!({
        "id": "rule-default-priority",
        "name": "Rule Default Priority",
        "active": true,
        "stage": "RequestHeaders",
        "termination": "Continue",
        "filter": {"type": "All"},
        "actions": []
    });
    let rule: Rule = serde_json::from_value(value).expect("deserialize rule failed");
    assert_eq!(rule.priority, 0, "priority should default to zero");
}

#[test]
fn test_rule_roundtrip_with_map_remote_action() {
    let rule = Rule {
        id: "rule-map-remote".to_string(),
        name: "Rule Map Remote".to_string(),
        active: true,
        stage: RuleStage::RequestHeaders,
        priority: 88,
        termination: RuleTermination::Stop,
        filter: Filter::Url(StringMatcher::Contains("example.com".to_string())),
        actions: vec![Action::MapRemote {
            url: "https://mirror.example.com:9443".to_string(),
            preserve_host: false,
        }],
        constraints: None,
    };

    let value = serde_json::to_value(&rule).expect("serialize rule failed");
    let parsed: Rule = serde_json::from_value(value).expect("deserialize rule failed");
    assert_eq!(parsed.id, "rule-map-remote");
    assert_eq!(parsed.priority, 88);
    match parsed.actions.first() {
        Some(Action::MapRemote { url, preserve_host }) => {
            assert_eq!(url, "https://mirror.example.com:9443");
            assert!(!preserve_host);
        }
        other => panic!("expected map remote action, got {:?}", other),
    }
}

#[test]
fn test_flow_custom_layer_deserializes_to_generic_protocol_layer() {
    let mut value = serde_json::to_value(sample_flow()).expect("serialize flow failed");
    value["layer"] = json!({
        "type": "Custom",
        "data": {
            "protocol": "mqtt",
            "data": {
                "topic": "hello/world",
                "qos": 1
            }
        }
    });

    let parsed: Flow = serde_json::from_value(value).expect("deserialize flow failed");
    match parsed.layer {
        Layer::Custom(layer) => {
            assert_eq!(layer.protocol_name(), "mqtt");
            assert_eq!(layer.to_json()["topic"], "hello/world");
            assert_eq!(layer.to_json()["qos"], 1);
        }
        _ => panic!("expected custom layer"),
    }
}