viewpoint_core/network/har_replay/
mod.rs

1//! HAR replay functionality.
2//!
3//! This module provides the ability to replay network requests from HAR files,
4//! mocking API responses during test execution.
5
6use std::collections::HashMap;
7use std::path::Path;
8use std::sync::Arc;
9
10use tokio::sync::RwLock;
11use tracing::{debug, trace, warn};
12
13use crate::error::NetworkError;
14use crate::network::{Route, UrlPattern};
15
16use super::har::{Har, HarEntry};
17
18/// Options for HAR replay.
19#[derive(Debug, Clone)]
20pub struct HarReplayOptions {
21    /// URL pattern to filter which requests use HAR replay.
22    /// Only URLs matching this pattern will be replayed from the HAR.
23    pub url_filter: Option<UrlPattern>,
24    /// If true, requests that don't match any HAR entry will fail.
25    /// If false, unmatched requests continue normally.
26    pub strict: bool,
27    /// If true, unmatched requests will be recorded to update the HAR file.
28    pub update: bool,
29    /// How to handle content in update mode.
30    pub update_content: UpdateContentMode,
31    /// How to handle timings in update mode.
32    pub update_timings: TimingMode,
33    /// If true, simulate the original timing delays from the HAR.
34    pub use_original_timing: bool,
35}
36
37impl Default for HarReplayOptions {
38    fn default() -> Self {
39        Self {
40            url_filter: None,
41            strict: false,
42            update: false,
43            update_content: UpdateContentMode::Embed,
44            update_timings: TimingMode::Placeholder,
45            use_original_timing: false,
46        }
47    }
48}
49
50impl HarReplayOptions {
51    /// Create new default options.
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Set URL filter pattern.
57    #[must_use]
58    pub fn url<M: Into<UrlPattern>>(mut self, pattern: M) -> Self {
59        self.url_filter = Some(pattern.into());
60        self
61    }
62
63    /// Enable strict mode (fail if no match found).
64    #[must_use]
65    pub fn strict(mut self, strict: bool) -> Self {
66        self.strict = strict;
67        self
68    }
69
70    /// Enable update mode (record missing entries).
71    #[must_use]
72    pub fn update(mut self, update: bool) -> Self {
73        self.update = update;
74        self
75    }
76
77    /// Set how to handle content in update mode.
78    #[must_use]
79    pub fn update_content(mut self, mode: UpdateContentMode) -> Self {
80        self.update_content = mode;
81        self
82    }
83
84    /// Set how to handle timings in update mode.
85    #[must_use]
86    pub fn update_timings(mut self, mode: TimingMode) -> Self {
87        self.update_timings = mode;
88        self
89    }
90
91    /// Enable timing simulation from HAR entries.
92    #[must_use]
93    pub fn use_original_timing(mut self, use_timing: bool) -> Self {
94        self.use_original_timing = use_timing;
95        self
96    }
97}
98
99/// How to handle response content in update mode.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum UpdateContentMode {
102    /// Embed content directly in the HAR file.
103    Embed,
104    /// Store large content as separate files.
105    Attach,
106    /// Don't record response body.
107    Omit,
108}
109
110/// How to handle timing information.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum TimingMode {
113    /// Use placeholder values (0 or -1) for consistent git diffs.
114    Placeholder,
115    /// Record actual timing values.
116    Actual,
117}
118
119/// HAR replay handler that matches requests against HAR entries.
120pub struct HarReplayHandler {
121    /// The loaded HAR data.
122    har: Har,
123    /// Options for replay behavior.
124    options: HarReplayOptions,
125    /// Path to the HAR file (for update mode).
126    har_path: Option<std::path::PathBuf>,
127    /// New entries recorded during update mode.
128    new_entries: Arc<RwLock<Vec<HarEntry>>>,
129}
130
131impl HarReplayHandler {
132    /// Create a new HAR replay handler from a file path.
133    pub async fn from_file(path: impl AsRef<Path>) -> Result<Self, NetworkError> {
134        let path = path.as_ref();
135        debug!("Loading HAR from: {}", path.display());
136
137        let content = tokio::fs::read_to_string(path)
138            .await
139            .map_err(|e| NetworkError::IoError(format!("Failed to read HAR file: {e}")))?;
140
141        let har: Har = serde_json::from_str(&content)
142            .map_err(|e| NetworkError::HarError(format!("Failed to parse HAR: {e}")))?;
143
144        debug!("Loaded HAR with {} entries", har.log.entries.len());
145
146        Ok(Self {
147            har,
148            options: HarReplayOptions::default(),
149            har_path: Some(path.to_path_buf()),
150            new_entries: Arc::new(RwLock::new(Vec::new())),
151        })
152    }
153
154    /// Create a new HAR replay handler from a HAR struct.
155    pub fn from_har(har: Har) -> Self {
156        Self {
157            har,
158            options: HarReplayOptions::default(),
159            har_path: None,
160            new_entries: Arc::new(RwLock::new(Vec::new())),
161        }
162    }
163
164    /// Set options for the handler.
165    pub fn with_options(mut self, options: HarReplayOptions) -> Self {
166        self.options = options;
167        self
168    }
169
170    /// Find a matching HAR entry for the given request.
171    pub fn find_entry(
172        &self,
173        url: &str,
174        method: &str,
175        post_data: Option<&str>,
176    ) -> Option<&HarEntry> {
177        // First, check if URL matches the filter
178        if let Some(ref filter) = self.options.url_filter {
179            if !filter.matches(url) {
180                trace!("URL {} doesn't match filter, skipping HAR lookup", url);
181                return None;
182            }
183        }
184
185        // Find matching entries
186        for entry in &self.har.log.entries {
187            if self.entry_matches(entry, url, method, post_data) {
188                debug!("Found HAR match for {} {}", method, url);
189                return Some(entry);
190            }
191        }
192
193        debug!("No HAR match found for {} {}", method, url);
194        None
195    }
196
197    /// Check if a HAR entry matches the request.
198    fn entry_matches(
199        &self,
200        entry: &HarEntry,
201        url: &str,
202        method: &str,
203        post_data: Option<&str>,
204    ) -> bool {
205        // Match URL
206        if !self.url_matches(&entry.request.url, url) {
207            return false;
208        }
209
210        // Match method
211        if entry.request.method.to_uppercase() != method.to_uppercase() {
212            return false;
213        }
214
215        // Match POST data if present
216        if let Some(request_post_data) = post_data {
217            if let Some(ref har_post_data) = entry.request.post_data {
218                if !self.post_data_matches(&har_post_data.text, request_post_data) {
219                    return false;
220                }
221            }
222        }
223
224        true
225    }
226
227    /// Check if URLs match (handles query string variations).
228    fn url_matches(&self, har_url: &str, request_url: &str) -> bool {
229        // Parse both URLs
230        let har_parsed = url::Url::parse(har_url);
231        let request_parsed = url::Url::parse(request_url);
232
233        match (har_parsed, request_parsed) {
234            (Ok(har), Ok(req)) => {
235                // Compare scheme, host, and path
236                if har.scheme() != req.scheme() {
237                    return false;
238                }
239                if har.host_str() != req.host_str() {
240                    return false;
241                }
242                if har.path() != req.path() {
243                    return false;
244                }
245
246                // For query parameters, check that all HAR params are present
247                // (request may have additional params)
248                let har_params: HashMap<_, _> = har.query_pairs().collect();
249                let req_params: HashMap<_, _> = req.query_pairs().collect();
250
251                for (key, value) in &har_params {
252                    if req_params.get(key) != Some(value) {
253                        return false;
254                    }
255                }
256
257                true
258            }
259            _ => {
260                // Fallback to exact string match
261                har_url == request_url
262            }
263        }
264    }
265
266    /// Check if POST data matches.
267    fn post_data_matches(&self, har_post_data: &str, request_post_data: &str) -> bool {
268        // Try parsing as JSON for semantic comparison
269        let har_json: Result<serde_json::Value, _> = serde_json::from_str(har_post_data);
270        let req_json: Result<serde_json::Value, _> = serde_json::from_str(request_post_data);
271
272        match (har_json, req_json) {
273            (Ok(har), Ok(req)) => har == req,
274            _ => {
275                // Fallback to string comparison
276                har_post_data == request_post_data
277            }
278        }
279    }
280
281    /// Build a response from a HAR entry.
282    pub fn build_response(&self, entry: &HarEntry) -> HarResponseData {
283        let response = &entry.response;
284
285        HarResponseData {
286            status: response.status as u16,
287            status_text: response.status_text.clone(),
288            headers: response
289                .headers
290                .iter()
291                .map(|h| (h.name.clone(), h.value.clone()))
292                .collect(),
293            body: response.content.text.clone(),
294            timing_ms: if self.options.use_original_timing {
295                Some(entry.time as u64)
296            } else {
297                None
298            },
299        }
300    }
301
302    /// Get the options.
303    pub fn options(&self) -> &HarReplayOptions {
304        &self.options
305    }
306
307    /// Get the HAR data.
308    pub fn har(&self) -> &Har {
309        &self.har
310    }
311
312    /// Record a new entry (for update mode).
313    pub async fn record_entry(&self, entry: HarEntry) {
314        let mut entries = self.new_entries.write().await;
315        entries.push(entry);
316    }
317
318    /// Save updated HAR file (for update mode).
319    pub async fn save_updates(&self) -> Result<(), NetworkError> {
320        if !self.options.update {
321            return Ok(());
322        }
323
324        let path = self
325            .har_path
326            .as_ref()
327            .ok_or_else(|| NetworkError::HarError("No HAR path set for updates".to_string()))?;
328
329        let new_entries = self.new_entries.read().await;
330        if new_entries.is_empty() {
331            return Ok(());
332        }
333
334        // Create updated HAR
335        let mut updated_har = self.har.clone();
336        for entry in new_entries.iter() {
337            updated_har.log.entries.push(entry.clone());
338        }
339
340        // Write to file
341        let content = serde_json::to_string_pretty(&updated_har)
342            .map_err(|e| NetworkError::HarError(format!("Failed to serialize HAR: {e}")))?;
343
344        tokio::fs::write(path, content)
345            .await
346            .map_err(|e| NetworkError::IoError(format!("Failed to write HAR: {e}")))?;
347
348        debug!("Saved {} new entries to HAR", new_entries.len());
349        Ok(())
350    }
351}
352
353/// Response data extracted from a HAR entry.
354#[derive(Debug, Clone)]
355pub struct HarResponseData {
356    /// HTTP status code.
357    pub status: u16,
358    /// HTTP status text.
359    pub status_text: String,
360    /// Response headers.
361    pub headers: Vec<(String, String)>,
362    /// Response body (if available).
363    pub body: Option<String>,
364    /// Timing to simulate (in milliseconds).
365    pub timing_ms: Option<u64>,
366}
367
368/// Create a route handler that replays from HAR.
369pub fn create_har_route_handler(
370    handler: Arc<HarReplayHandler>,
371) -> impl Fn(
372    Route,
373) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), NetworkError>> + Send>>
374+ Send
375+ Sync
376+ Clone
377+ 'static {
378    move |route: Route| {
379        let handler = handler.clone();
380        Box::pin(async move {
381            let request = route.request();
382            let url = request.url();
383            let method = request.method();
384            let post_data = request.post_data();
385
386            // Find matching entry
387            if let Some(entry) = handler.find_entry(url, method, post_data) {
388                let response_data = handler.build_response(entry);
389
390                // Simulate timing if requested
391                if let Some(timing_ms) = response_data.timing_ms {
392                    tokio::time::sleep(std::time::Duration::from_millis(timing_ms)).await;
393                }
394
395                // Build and send response
396                let mut builder = route.fulfill().status(response_data.status);
397
398                // Add headers
399                for (name, value) in response_data.headers {
400                    builder = builder.header(&name, &value);
401                }
402
403                // Add body
404                if let Some(body) = response_data.body {
405                    builder = builder.body(body);
406                }
407
408                builder.send().await
409            } else if handler.options().strict {
410                // Strict mode: fail on no match
411                warn!("HAR strict mode: no match for {} {}", method, url);
412                route.abort().await
413            } else {
414                // Non-strict mode: continue request normally
415                route.continue_().await
416            }
417        })
418    }
419}
420
421#[cfg(test)]
422mod tests;