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(&self, url: &str, method: &str, post_data: Option<&str>) -> Option<&HarEntry> {
172        // First, check if URL matches the filter
173        if let Some(ref filter) = self.options.url_filter {
174            if !filter.matches(url) {
175                trace!("URL {} doesn't match filter, skipping HAR lookup", url);
176                return None;
177            }
178        }
179
180        // Find matching entries
181        for entry in &self.har.log.entries {
182            if self.entry_matches(entry, url, method, post_data) {
183                debug!("Found HAR match for {} {}", method, url);
184                return Some(entry);
185            }
186        }
187
188        debug!("No HAR match found for {} {}", method, url);
189        None
190    }
191
192    /// Check if a HAR entry matches the request.
193    fn entry_matches(&self, entry: &HarEntry, url: &str, method: &str, post_data: Option<&str>) -> bool {
194        // Match URL
195        if !self.url_matches(&entry.request.url, url) {
196            return false;
197        }
198
199        // Match method
200        if entry.request.method.to_uppercase() != method.to_uppercase() {
201            return false;
202        }
203
204        // Match POST data if present
205        if let Some(request_post_data) = post_data {
206            if let Some(ref har_post_data) = entry.request.post_data {
207                if !self.post_data_matches(&har_post_data.text, request_post_data) {
208                    return false;
209                }
210            }
211        }
212
213        true
214    }
215
216    /// Check if URLs match (handles query string variations).
217    fn url_matches(&self, har_url: &str, request_url: &str) -> bool {
218        // Parse both URLs
219        let har_parsed = url::Url::parse(har_url);
220        let request_parsed = url::Url::parse(request_url);
221
222        match (har_parsed, request_parsed) {
223            (Ok(har), Ok(req)) => {
224                // Compare scheme, host, and path
225                if har.scheme() != req.scheme() {
226                    return false;
227                }
228                if har.host_str() != req.host_str() {
229                    return false;
230                }
231                if har.path() != req.path() {
232                    return false;
233                }
234
235                // For query parameters, check that all HAR params are present
236                // (request may have additional params)
237                let har_params: HashMap<_, _> = har.query_pairs().collect();
238                let req_params: HashMap<_, _> = req.query_pairs().collect();
239
240                for (key, value) in &har_params {
241                    if req_params.get(key) != Some(value) {
242                        return false;
243                    }
244                }
245
246                true
247            }
248            _ => {
249                // Fallback to exact string match
250                har_url == request_url
251            }
252        }
253    }
254
255    /// Check if POST data matches.
256    fn post_data_matches(&self, har_post_data: &str, request_post_data: &str) -> bool {
257        // Try parsing as JSON for semantic comparison
258        let har_json: Result<serde_json::Value, _> = serde_json::from_str(har_post_data);
259        let req_json: Result<serde_json::Value, _> = serde_json::from_str(request_post_data);
260
261        match (har_json, req_json) {
262            (Ok(har), Ok(req)) => har == req,
263            _ => {
264                // Fallback to string comparison
265                har_post_data == request_post_data
266            }
267        }
268    }
269
270    /// Build a response from a HAR entry.
271    pub fn build_response(&self, entry: &HarEntry) -> HarResponseData {
272        let response = &entry.response;
273
274        HarResponseData {
275            status: response.status as u16,
276            status_text: response.status_text.clone(),
277            headers: response
278                .headers
279                .iter()
280                .map(|h| (h.name.clone(), h.value.clone()))
281                .collect(),
282            body: response.content.text.clone(),
283            timing_ms: if self.options.use_original_timing {
284                Some(entry.time as u64)
285            } else {
286                None
287            },
288        }
289    }
290
291    /// Get the options.
292    pub fn options(&self) -> &HarReplayOptions {
293        &self.options
294    }
295
296    /// Get the HAR data.
297    pub fn har(&self) -> &Har {
298        &self.har
299    }
300
301    /// Record a new entry (for update mode).
302    pub async fn record_entry(&self, entry: HarEntry) {
303        let mut entries = self.new_entries.write().await;
304        entries.push(entry);
305    }
306
307    /// Save updated HAR file (for update mode).
308    pub async fn save_updates(&self) -> Result<(), NetworkError> {
309        if !self.options.update {
310            return Ok(());
311        }
312
313        let path = self.har_path.as_ref().ok_or_else(|| {
314            NetworkError::HarError("No HAR path set for updates".to_string())
315        })?;
316
317        let new_entries = self.new_entries.read().await;
318        if new_entries.is_empty() {
319            return Ok(());
320        }
321
322        // Create updated HAR
323        let mut updated_har = self.har.clone();
324        for entry in new_entries.iter() {
325            updated_har.log.entries.push(entry.clone());
326        }
327
328        // Write to file
329        let content = serde_json::to_string_pretty(&updated_har)
330            .map_err(|e| NetworkError::HarError(format!("Failed to serialize HAR: {e}")))?;
331
332        tokio::fs::write(path, content)
333            .await
334            .map_err(|e| NetworkError::IoError(format!("Failed to write HAR: {e}")))?;
335
336        debug!("Saved {} new entries to HAR", new_entries.len());
337        Ok(())
338    }
339}
340
341/// Response data extracted from a HAR entry.
342#[derive(Debug, Clone)]
343pub struct HarResponseData {
344    /// HTTP status code.
345    pub status: u16,
346    /// HTTP status text.
347    pub status_text: String,
348    /// Response headers.
349    pub headers: Vec<(String, String)>,
350    /// Response body (if available).
351    pub body: Option<String>,
352    /// Timing to simulate (in milliseconds).
353    pub timing_ms: Option<u64>,
354}
355
356/// Create a route handler that replays from HAR.
357pub fn create_har_route_handler(
358    handler: Arc<HarReplayHandler>,
359) -> impl Fn(Route) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), NetworkError>> + Send>> + Send + Sync + Clone + 'static
360{
361    move |route: Route| {
362        let handler = handler.clone();
363        Box::pin(async move {
364            let request = route.request();
365            let url = request.url();
366            let method = request.method();
367            let post_data = request.post_data();
368
369            // Find matching entry
370            if let Some(entry) = handler.find_entry(url, method, post_data) {
371                let response_data = handler.build_response(entry);
372
373                // Simulate timing if requested
374                if let Some(timing_ms) = response_data.timing_ms {
375                    tokio::time::sleep(std::time::Duration::from_millis(timing_ms)).await;
376                }
377
378                // Build and send response
379                let mut builder = route.fulfill().status(response_data.status);
380
381                // Add headers
382                for (name, value) in response_data.headers {
383                    builder = builder.header(&name, &value);
384                }
385
386                // Add body
387                if let Some(body) = response_data.body {
388                    builder = builder.body(body);
389                }
390
391                builder.send().await
392            } else if handler.options().strict {
393                // Strict mode: fail on no match
394                warn!("HAR strict mode: no match for {} {}", method, url);
395                route.abort().await
396            } else {
397                // Non-strict mode: continue request normally
398                route.continue_().await
399            }
400        })
401    }
402}
403
404#[cfg(test)]
405mod tests;