browsing 0.1.7

Browser automation: navigate, click, extract, screenshot. Standalone browser control via CDP.
Documentation
//! Network request interception via CDP Fetch domain

use crate::browser::views::{InterceptPattern, InterceptedRequest, MockResponse};
use crate::error::Result;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;

/// Manages network request interception
#[derive(Debug, Clone)]
pub struct NetworkInterceptor {
    client: Arc<crate::browser::cdp::CdpClient>,
    pending: Arc<Mutex<HashMap<String, InterceptedRequest>>>,
}

impl NetworkInterceptor {
    /// Create a new network interceptor
    pub fn new(client: Arc<crate::browser::cdp::CdpClient>) -> Self {
        Self {
            client,
            pending: Arc::new(Mutex::new(HashMap::new())),
        }
    }

    /// Enable request interception with optional URL patterns.
    /// If no patterns are provided, all requests are intercepted.
    pub async fn enable(&self, patterns: Option<Vec<InterceptPattern>>) -> Result<()> {
        let patterns = patterns.unwrap_or_else(|| {
            vec![InterceptPattern {
                url_pattern: "*".to_string(),
                resource_type: None,
                interception_stage: Some("Request".to_string()),
            }]
        });

        let cdp_patterns: Vec<serde_json::Value> = patterns
            .into_iter()
            .map(|p| {
                let mut obj = serde_json::json!({
                    "urlPattern": p.url_pattern,
                });
                if let Some(rt) = p.resource_type {
                    obj["resourceType"] = serde_json::json!(rt);
                }
                if let Some(stage) = p.interception_stage {
                    obj["interceptionStage"] = serde_json::json!(stage);
                }
                obj
            })
            .collect();

        self.client
            .send_command("Fetch.enable", serde_json::json!({ "patterns": cdp_patterns }))
            .await?;

        let pending = Arc::clone(&self.pending);
        let mut rx = self.client.subscribe_events().await?;

        tokio::spawn(async move {
            while let Ok(event) = rx.recv().await {
                if event.method == "Fetch.requestPaused"
                    && let Some(req_id) = event.params.get("requestId").and_then(|v| v.as_str()) {
                        let request = event.params.get("request");
                        let url = request
                            .and_then(|r| r.get("url"))
                            .and_then(|v| v.as_str())
                            .unwrap_or("")
                            .to_string();
                        let method = request
                            .and_then(|r| r.get("method"))
                            .and_then(|v| v.as_str())
                            .unwrap_or("GET")
                            .to_string();
                        let headers = parse_headers(request.and_then(|r| r.get("headers")));
                        let post_data = event
                            .params
                            .get("request")
                            .and_then(|r| r.get("postData"))
                            .and_then(|v| v.as_str())
                            .map(|s| s.to_string());
                        let resource_type = event
                            .params
                            .get("resourceType")
                            .and_then(|v| v.as_str())
                            .unwrap_or("Other")
                            .to_string();

                        let intercepted = InterceptedRequest {
                            request_id: req_id.to_string(),
                            url,
                            method,
                            headers,
                            post_data,
                            resource_type,
                        };
                        pending.lock().await.insert(req_id.to_string(), intercepted);
                    }
            }
        });

        Ok(())
    }

    /// Disable request interception
    pub async fn disable(&self) -> Result<()> {
        self.client
            .send_command("Fetch.disable", serde_json::json!({}))
            .await?;
        self.pending.lock().await.clear();
        Ok(())
    }

    /// Continue an intercepted request (possibly with modifications).
    /// If `None`, the request proceeds unchanged.
    pub async fn continue_request(
        &self,
        request_id: &str,
        url: Option<&str>,
        method: Option<&str>,
        headers: Option<Vec<(String, String)>>,
        post_data: Option<&str>,
    ) -> Result<()> {
        let mut params = serde_json::json!({ "requestId": request_id });
        if let Some(u) = url {
            params["url"] = serde_json::json!(u);
        }
        if let Some(m) = method {
            params["method"] = serde_json::json!(m);
        }
        if let Some(h) = headers {
            let headers_json: Vec<serde_json::Value> = h
                .into_iter()
                .map(|(name, value)| {
                    serde_json::json!({
                        "name": name,
                        "value": value,
                    })
                })
                .collect();
            params["headers"] = serde_json::json!(headers_json);
        }
        if let Some(pd) = post_data {
            params["postData"] = serde_json::json!(pd);
        }

        self.client
            .send_command("Fetch.continueRequest", params)
            .await?;
        self.pending.lock().await.remove(request_id);
        Ok(())
    }

    /// Fulfill an intercepted request with a mock response
    pub async fn fulfill_request(&self, request_id: &str, response: &MockResponse) -> Result<()> {
        let headers_json: Vec<serde_json::Value> = response
            .headers
            .iter()
            .map(|h| {
                serde_json::json!({
                    "name": h.name,
                    "value": h.value,
                })
            })
            .collect();

        self.client
            .send_command(
                "Fetch.fulfillRequest",
                serde_json::json!({
                    "requestId": request_id,
                    "responseCode": response.status,
                    "responsePhrase": response.status_text,
                    "responseHeaders": headers_json,
                    "body": response.body,
                    "base64Encoded": response.base64_encoded,
                }),
            )
            .await?;
        self.pending.lock().await.remove(request_id);
        Ok(())
    }

    /// Fail an intercepted request
    pub async fn fail_request(&self, request_id: &str, error_reason: &str) -> Result<()> {
        self.client
            .send_command(
                "Fetch.failRequest",
                serde_json::json!({
                    "requestId": request_id,
                    "errorReason": error_reason,
                }),
            )
            .await?;
        self.pending.lock().await.remove(request_id);
        Ok(())
    }

    /// Get all currently intercepted (paused) requests
    pub async fn get_intercepted_requests(&self) -> Vec<InterceptedRequest> {
        self.pending.lock().await.values().cloned().collect()
    }

    /// Get a single intercepted request by ID
    pub async fn get_request(&self, request_id: &str) -> Option<InterceptedRequest> {
        self.pending.lock().await.get(request_id).cloned()
    }
}

fn parse_headers(headers_val: Option<&serde_json::Value>) -> Vec<crate::browser::views::HarHeader> {
    let mut result = Vec::new();
    if let Some(obj) = headers_val.and_then(|v| v.as_object()) {
        for (name, value) in obj {
            result.push(crate::browser::views::HarHeader {
                name: name.clone(),
                value: value.as_str().unwrap_or("").to_string(),
            });
        }
    }
    result
}

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

    #[test]
    fn test_intercept_pattern_creation() {
        let p = InterceptPattern {
            url_pattern: "*://*.example.com/*".to_string(),
            resource_type: Some("XHR".to_string()),
            interception_stage: Some("Request".to_string()),
        };
        assert_eq!(p.url_pattern, "*://*.example.com/*");
    }

    #[test]
    fn test_intercepted_request_creation() {
        let req = InterceptedRequest {
            request_id: "req-1".to_string(),
            url: "https://example.com/api".to_string(),
            method: "POST".to_string(),
            headers: vec![],
            post_data: Some("{}".to_string()),
            resource_type: "XHR".to_string(),
        };
        assert_eq!(req.method, "POST");
        assert_eq!(req.resource_type, "XHR");
    }

    #[test]
    fn test_mock_response_creation() {
        let resp = MockResponse {
            status: 200,
            status_text: "OK".to_string(),
            headers: vec![crate::browser::views::HarHeader {
                name: "Content-Type".to_string(),
                value: "application/json".to_string(),
            }],
            body: "{}".to_string(),
            base64_encoded: false,
        };
        assert_eq!(resp.status, 200);
        assert!(!resp.base64_encoded);
    }

    #[test]
    fn test_mock_response_serialization() {
        let resp = MockResponse {
            status: 404,
            status_text: "Not Found".to_string(),
            headers: vec![],
            body: "not found".to_string(),
            base64_encoded: false,
        };
        let json = serde_json::to_string(&resp).unwrap();
        assert!(json.contains("404"));
        assert!(json.contains("Not Found"));
    }
}