Skip to main content

playwright_cdp/
har.rs

1//! HAR recording/replay (`route_from_har`).
2//!
3//! This module parses HAR 1.2 files (the HTTP Archive format) and produces a set
4//! of canned route responses. The [`BrowserContext::route_from_har`] call turns
5//! each HAR entry into a context-level route handler that, on a matching request,
6//! serves the recorded response via [`Route::fulfill`](crate::Route::fulfill) and
7//! lets non-matching requests fall through to the network.
8//!
9//! # Scope
10//! **Replay is fully implemented.** Recording (capturing live traffic into a new
11//! HAR file) is deferred — `HarRecorder` exists as a thin writer that can append
12//! entries, but wiring it into the live request stream of every page is not yet
13//! done. See the report notes.
14//!
15//! The parsing structs lean heavily on `#[serde(default)]` because real-world
16//! HAR files are messy: fields are frequently missing or carry unexpected types.
17
18use crate::error::{Error, Result};
19use crate::route::RouteFulfillOptions;
20use crate::types::Headers;
21use crate::Route;
22use base64::Engine;
23use serde::{Deserialize, Serialize};
24use std::path::Path;
25
26// ---------------------------------------------------------------------------
27// HAR 1.2 parsing structs
28// ---------------------------------------------------------------------------
29
30/// The top-level HAR document: `{ "log": { ... } }`.
31#[derive(Debug, Clone, Deserialize, Serialize)]
32pub struct Har {
33    #[serde(default)]
34    pub log: HarLog,
35}
36
37/// The `log` object. Only `entries` is consulted for replay; `creator` /
38/// `browser` / `pages` are tolerated but ignored.
39#[derive(Debug, Clone, Default, Deserialize, Serialize)]
40pub struct HarLog {
41    #[serde(default)]
42    pub version: Option<String>,
43    #[serde(default)]
44    pub creator: Option<HarCreator>,
45    #[serde(default)]
46    pub browser: Option<HarCreator>,
47    #[serde(default)]
48    pub pages: Vec<HarPage>,
49    #[serde(default)]
50    pub entries: Vec<HarEntry>,
51}
52
53/// `log.creator` / `log.browser`.
54#[derive(Debug, Clone, Default, Deserialize, Serialize)]
55pub struct HarCreator {
56    #[serde(default)]
57    pub name: Option<String>,
58    #[serde(default)]
59    pub version: Option<String>,
60}
61
62/// `log.pages[*]` (page-level timing metadata). Parsed for tolerance only.
63#[derive(Debug, Clone, Default, Deserialize, Serialize)]
64pub struct HarPage {
65    #[serde(default, rename = "startedDateTime")]
66    pub started_date_time: Option<String>,
67    #[serde(default)]
68    pub id: Option<String>,
69    #[serde(default)]
70    pub title: Option<String>,
71    #[serde(default, rename = "pageTimings")]
72    pub page_timings: Option<Value>,
73}
74
75/// One request/response pair.
76#[derive(Debug, Clone, Default, Deserialize, Serialize)]
77pub struct HarEntry {
78    #[serde(default, rename = "startedDateTime")]
79    pub started_date_time: Option<String>,
80    #[serde(default)]
81    pub time: Option<f64>,
82    #[serde(default)]
83    pub request: HarRequest,
84    #[serde(default)]
85    pub response: HarResponse,
86    #[serde(default, rename = "cache")]
87    pub cache: Option<Value>,
88    #[serde(default, rename = "timings")]
89    pub timings: Option<Value>,
90    #[serde(default)]
91    pub server_ip_address: Option<String>,
92    #[serde(default)]
93    pub connection: Option<String>,
94    /// Optional `pageref` linking this entry to a `log.pages[*].id`.
95    #[serde(default)]
96    pub pageref: Option<String>,
97}
98
99/// Placeholder import so `Value` is referenced even if unused fields are
100/// stripped later (keeps the struct forward-compatible with full HAR 1.2).
101#[allow(unused_imports)]
102use serde_json::Value;
103
104/// A recorded request.
105#[derive(Debug, Clone, Default, Deserialize, Serialize)]
106pub struct HarRequest {
107    #[serde(default)]
108    pub method: String,
109    #[serde(default)]
110    pub url: String,
111    #[serde(default)]
112    pub http_version: Option<String>,
113    /// HAR stores headers as `[{ "name", "value" }, ...]`.
114    #[serde(default)]
115    pub headers: Vec<HarHeader>,
116    #[serde(default)]
117    pub cookies: Vec<HarCookie>,
118    #[serde(default, rename = "queryString")]
119    pub query_string: Vec<HarQuery>,
120    #[serde(default, rename = "postData")]
121    pub post_data: Option<HarPostData>,
122    #[serde(default, rename = "headersSize")]
123    pub headers_size: Option<i64>,
124    #[serde(default, rename = "bodySize")]
125    pub body_size: Option<i64>,
126}
127
128/// A recorded response.
129#[derive(Debug, Clone, Default, Deserialize, Serialize)]
130pub struct HarResponse {
131    #[serde(default)]
132    pub status: u16,
133    #[serde(default, rename = "statusText")]
134    pub status_text: String,
135    #[serde(default)]
136    pub http_version: Option<String>,
137    #[serde(default)]
138    pub cookies: Vec<HarCookie>,
139    #[serde(default)]
140    pub headers: Vec<HarHeader>,
141    #[serde(default)]
142    pub content: HarContent,
143    #[serde(default, rename = "redirectURL")]
144    pub redirect_url: Option<String>,
145    #[serde(default, rename = "headersSize")]
146    pub headers_size: Option<i64>,
147    #[serde(default, rename = "bodySize")]
148    pub body_size: Option<i64>,
149}
150
151/// Response body payload + mime.
152#[derive(Debug, Clone, Default, Deserialize, Serialize)]
153pub struct HarContent {
154    #[serde(default)]
155    pub size: Option<i64>,
156    /// When the body is binary, HAR commonly base64-encodes it and sets
157    /// `encoding: "base64"`. Plain text has `encoding` absent/empty.
158    #[serde(default)]
159    pub text: Option<String>,
160    #[serde(default, rename = "mimeType")]
161    pub mime_type: Option<String>,
162    #[serde(default)]
163    pub encoding: Option<String>,
164    #[serde(default)]
165    pub compression: Option<i64>,
166}
167
168/// `{ "name": "...", "value": "..." }` header.
169#[derive(Debug, Clone, Default, Deserialize, Serialize)]
170pub struct HarHeader {
171    #[serde(default)]
172    pub name: String,
173    #[serde(default)]
174    pub value: String,
175}
176
177/// `{ "name": "...", "value": "..." }` cookie (comment/domain/etc tolerated).
178#[derive(Debug, Clone, Default, Deserialize, Serialize)]
179pub struct HarCookie {
180    #[serde(default)]
181    pub name: String,
182    #[serde(default)]
183    pub value: String,
184    #[serde(default)]
185    pub path: Option<String>,
186    #[serde(default)]
187    pub domain: Option<String>,
188    #[serde(default)]
189    pub expires: Option<String>,
190    #[serde(default, rename = "httpOnly")]
191    pub http_only: Option<bool>,
192    #[serde(default)]
193    pub secure: Option<bool>,
194    #[serde(default, rename = "sameSite")]
195    pub same_site: Option<String>,
196}
197
198/// `{ "name": "...", "value": "..." }` query parameter.
199#[derive(Debug, Clone, Default, Deserialize, Serialize)]
200pub struct HarQuery {
201    #[serde(default)]
202    pub name: String,
203    #[serde(default)]
204    pub value: String,
205}
206
207/// `request.postData`.
208#[derive(Debug, Clone, Default, Deserialize, Serialize)]
209pub struct HarPostData {
210    #[serde(default, rename = "mimeType")]
211    pub mime_type: Option<String>,
212    #[serde(default)]
213    pub text: Option<String>,
214    #[serde(default)]
215    pub params: Vec<HarQuery>,
216}
217
218// ---------------------------------------------------------------------------
219// Replay
220// ---------------------------------------------------------------------------
221
222/// A single HAR-derived route: the request signature to match against and the
223/// canned response to serve.
224///
225/// Constructed by [`routes_from_har`]; consumed by
226/// [`BrowserContext::route_from_har`](crate::BrowserContext::route_from_har) /
227/// [`Page::route_from_har`](crate::Page::route_from_har), which turn each one
228/// into a real route handler.
229#[derive(Debug, Clone)]
230pub struct HarRoute {
231    /// HTTP method (`GET`, `POST`, …), upper-cased for case-insensitive match.
232    pub method: String,
233    /// URL glob (`*`-wildcarded, same semantics as `route(pattern, …)`).
234    pub pattern: String,
235    /// HTTP status to fulfill with.
236    pub status: u16,
237    /// Response headers (lower-cased keys).
238    pub headers: Headers,
239    /// Response body (already base64-decoded to a UTF-8 string when the HAR
240    /// recorded it as base64; raw bytes that are not valid UTF-8 are passed
241    /// through lossily — HAR replay is text-oriented in this crate).
242    pub body: String,
243}
244
245impl HarRoute {
246    /// Build the [`RouteFulfillOptions`] that serves this canned response.
247    pub fn fulfill_options(&self) -> RouteFulfillOptions {
248        RouteFulfillOptions {
249            status: Some(self.status),
250            headers: Some(self.headers.clone()),
251            content_type: None,
252            body: Some(self.body.clone()),
253        }
254    }
255
256    /// Serve this route's canned response. Returns the fulfill result so the
257    /// caller can short-circuit (a fulfilled request must not also `continue_`).
258    pub async fn fulfill(&self, route: &Route) -> Result<()> {
259        route.fulfill(self.fulfill_options()).await
260    }
261}
262
263/// Options for `route_from_har`, mirroring Playwright's
264/// `RouteFromHarOptions`.
265#[derive(Debug, Clone, Default)]
266#[non_exhaustive]
267pub struct RouteFromHarOptions {
268    /// If `Some(true)`, do not fulfill from the HAR — instead let requests hit
269    /// the network and (eventually) update the HAR. Recording is not yet wired,
270    /// so when `update` is true the routes still fall through to the network but
271    /// the file is not rewritten. See the report notes.
272    pub update: Option<bool>,
273    /// If set, only register HAR entries whose URL matches this glob; entries
274    /// that do not match are ignored (their requests fall through to network).
275    pub url: Option<String>,
276    /// If `Some(true)`, match requests case-insensitively on the URL. Defaults
277    /// to false (Playwright matches the recorded URL literally).
278    pub url_match_case_insensitive: Option<bool>,
279}
280
281impl RouteFromHarOptions {
282    pub fn new() -> Self {
283        Self::default()
284    }
285    pub fn update(mut self, v: bool) -> Self {
286        self.update = Some(v);
287        self
288    }
289    pub fn url(mut self, v: impl Into<String>) -> Self {
290        self.url = Some(v.into());
291        self
292    }
293    pub fn url_match_case_insensitive(mut self, v: bool) -> Self {
294        self.url_match_case_insensitive = Some(v);
295        self
296    }
297}
298
299/// Parse a HAR file from disk and produce one [`HarRoute`] per usable entry.
300///
301/// "Usable" means the entry has a request URL and a response status. Entries
302/// whose recorded URL is empty, or which do not match the optional `opts.url`
303/// glob filter, are skipped. The returned patterns are the raw recorded URLs;
304/// the context applies them with the same `*`-glob semantics as `route(...)`.
305pub fn routes_from_har<P: AsRef<Path>>(
306    path: P,
307    opts: Option<&RouteFromHarOptions>,
308) -> Result<Vec<HarRoute>> {
309    let opts = opts.cloned().unwrap_or_default();
310    let data = std::fs::read(path.as_ref()).map_err(|e| {
311        Error::InvalidArgument(format!(
312            "failed to read HAR file {}: {}",
313            path.as_ref().display(),
314            e
315        ))
316    })?;
317    let har: Har = serde_json::from_slice(&data).map_err(|e| {
318        Error::InvalidArgument(format!("failed to parse HAR file: {}", e))
319    })?;
320
321    let filter = opts.url.as_deref();
322    let mut out = Vec::with_capacity(har.log.entries.len());
323    for entry in &har.log.entries {
324        let url = entry.request.url.trim();
325        if url.is_empty() {
326            continue;
327        }
328        // Optional URL-glob filter on the recorded URL itself.
329        if let Some(glob) = filter {
330            if !glob_match(glob, url) {
331                continue;
332            }
333        }
334
335        let headers = collect_headers(&entry.response.headers);
336        let (body, encoding_is_base64) = decode_body(&entry.response.content);
337
338        // Prefer the recorded Content-Type header; fall back to the content
339        // mime_type field. We surface it only when present so we do not
340        // clobber the caller's headers with an empty string.
341        let content_type = entry
342            .response
343            .content
344            .mime_type
345            .clone()
346            .or_else(|| headers.get("content-type").cloned());
347
348        let mut headers = headers;
349        if let Some(ct) = content_type {
350            headers
351                .entry("content-type".to_string())
352                .or_insert(ct);
353        }
354
355        let body_string = if encoding_is_base64 {
356            // Best-effort decode to UTF-8. Binary payloads that are not valid
357            // UTF-8 are passed through lossily; route_from_har is text-oriented.
358            String::from_utf8(body).unwrap_or_else(|e| {
359                String::from_utf8_lossy(&e.into_bytes()).into_owned()
360            })
361        } else {
362            // Already text in the HAR.
363            match String::from_utf8(body) {
364                Ok(s) => s,
365                Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
366            }
367        };
368
369        out.push(HarRoute {
370            method: entry.request.method.to_ascii_uppercase(),
371            pattern: url.to_string(),
372            status: if entry.response.status == 0 {
373                200
374            } else {
375                entry.response.status
376            },
377            headers,
378            body: body_string,
379        });
380    }
381    Ok(out)
382}
383
384/// Flatten `[{ "name", "value" }]` into a `Headers` map with lower-cased keys.
385fn collect_headers(headers: &[HarHeader]) -> Headers {
386    let mut out = Headers::new();
387    for h in headers {
388        if h.name.is_empty() {
389            continue;
390        }
391        out.insert(h.name.to_lowercase(), h.value.clone());
392    }
393    out
394}
395
396/// Decode a HAR response body to raw bytes. Returns `(bytes, was_base64)`.
397fn decode_body(content: &HarContent) -> (Vec<u8>, bool) {
398    let text = match &content.text {
399        Some(t) if !t.is_empty() => t.as_str(),
400        _ => return (Vec::new(), false),
401    };
402    let is_base64 = content
403        .encoding
404        .as_deref()
405        .map(|e| e.eq_ignore_ascii_case("base64"))
406        .unwrap_or(false);
407    if is_base64 {
408        match base64::engine::general_purpose::STANDARD.decode(text) {
409            Ok(bytes) => (bytes, true),
410            // Malformed base64: fall back to raw text rather than dropping the body.
411            Err(_) => (text.as_bytes().to_vec(), false),
412        }
413    } else {
414        (text.as_bytes().to_vec(), false)
415    }
416}
417
418/// Glob match compatible with the route pattern semantics: `*` matches any
419/// sequence. `*foo*` is a substring test; `foo*` a prefix; `*foo` a suffix;
420/// otherwise an exact/contains match. Mirrors `route::pattern_matches` so the
421/// `opts.url` filter behaves the same way the registered patterns will.
422pub(crate) fn glob_match(pattern: &str, url: &str) -> bool {
423    if pattern == url {
424        return true;
425    }
426    if pattern.len() >= 2 && pattern.starts_with('*') && pattern.ends_with('*') {
427        return url.contains(&pattern[1..pattern.len() - 1]);
428    }
429    if let Some(rest) = pattern.strip_prefix('*') {
430        return url.ends_with(rest);
431    }
432    if let Some(rest) = pattern.strip_suffix('*') {
433        return url.starts_with(rest);
434    }
435    url.contains(pattern)
436}
437
438// ---------------------------------------------------------------------------
439// Recording (deferred / partial)
440// ---------------------------------------------------------------------------
441
442/// A minimal HAR recorder: builds a [`Har`] in memory and can write it to disk.
443///
444/// This exists so that `route_from_har`'s `update` path has a place to land,
445/// but **recording is not wired into the live request stream** of pages in this
446/// pass. Constructing a recorder and calling [`HarRecorder::record_entry`]
447/// works, but nothing feeds it live traffic automatically. See the report.
448#[derive(Debug, Clone, Default)]
449pub struct HarRecorder {
450    entries: Vec<HarEntry>,
451}
452
453impl HarRecorder {
454    pub fn new() -> Self {
455        Self::default()
456    }
457
458    /// Append a pre-built entry. (Not yet driven automatically by page traffic.)
459    pub fn record_entry(&mut self, entry: HarEntry) {
460        self.entries.push(entry);
461    }
462
463    /// Number of recorded entries so far.
464    pub fn len(&self) -> usize {
465        self.entries.len()
466    }
467
468    /// Whether any entries have been recorded.
469    pub fn is_empty(&self) -> bool {
470        self.entries.is_empty()
471    }
472
473    /// Build the in-memory [`Har`] document.
474    pub fn to_har(&self) -> Har {
475        Har {
476            log: HarLog {
477                version: Some("1.2".to_string()),
478                creator: Some(HarCreator {
479                    name: Some("playwright-cdp".to_string()),
480                    version: Some(env!("CARGO_PKG_VERSION").to_string()),
481                }),
482                browser: None,
483                pages: Vec::new(),
484                entries: self.entries.clone(),
485            },
486        }
487    }
488
489    /// Serialize the recorded HAR to a JSON string (pretty, matching the HAR
490    /// convention of human-readable files).
491    pub fn to_json(&self) -> Result<String> {
492        serde_json::to_string_pretty(&self.to_har())
493            .map_err(|e| Error::ProtocolError(format!("failed to serialize HAR: {}", e)))
494    }
495
496    /// Write the recorded HAR to `path`.
497    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
498        let json = self.to_json()?;
499        std::fs::write(path.as_ref(), json).map_err(|e| {
500            Error::InvalidArgument(format!(
501                "failed to write HAR file {}: {}",
502                path.as_ref().display(),
503                e
504            ))
505        })
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn parses_minimal_har() {
515        let json = r#"{
516            "log": {
517                "version": "1.2",
518                "entries": [
519                    {
520                        "request": { "method": "GET", "url": "https://example.com/api" },
521                        "response": {
522                            "status": 200,
523                            "statusText": "OK",
524                            "headers": [ { "name": "Content-Type", "value": "application/json" } ],
525                            "content": { "text": "{\"ok\":true}", "mimeType": "application/json" }
526                        }
527                    }
528                ]
529            }
530        }"#;
531        let har: Har = serde_json::from_str(json).unwrap();
532        assert_eq!(har.log.entries.len(), 1);
533
534        let routes = routes_from_har_memory(&har, None).unwrap();
535        assert_eq!(routes.len(), 1);
536        assert_eq!(routes[0].method, "GET");
537        assert_eq!(routes[0].pattern, "https://example.com/api");
538        assert_eq!(routes[0].status, 200);
539        assert_eq!(routes[0].body, "{\"ok\":true}");
540        assert_eq!(routes[0].headers.get("content-type").unwrap(), "application/json");
541    }
542
543    #[test]
544    fn decodes_base64_body() {
545        let json = r#"{
546            "log": { "entries": [ {
547                "request": { "method": "GET", "url": "https://example.com/img" },
548                "response": {
549                    "status": 200,
550                    "content": { "text": "aGVsbG8=", "encoding": "base64", "mimeType": "text/plain" }
551                }
552            } ] }
553        }"#;
554        let har: Har = serde_json::from_str(json).unwrap();
555        let routes = routes_from_har_memory(&har, None).unwrap();
556        assert_eq!(routes[0].body, "hello");
557    }
558
559    #[test]
560    fn url_filter_skips_non_matching() {
561        let json = r#"{
562            "log": { "entries": [
563                { "request": { "method": "GET", "url": "https://a.example.com/x" },
564                  "response": { "status": 200, "content": {} } },
565                { "request": { "method": "GET", "url": "https://b.example.com/y" },
566                  "response": { "status": 200, "content": {} } }
567            ] }
568        }"#;
569        let har: Har = serde_json::from_str(json).unwrap();
570        let opts = RouteFromHarOptions::default().url("*/x");
571        let routes = routes_from_har_memory(&har, Some(&opts)).unwrap();
572        assert_eq!(routes.len(), 1);
573        assert_eq!(routes[0].pattern, "https://a.example.com/x");
574    }
575
576    #[test]
577    fn empty_and_missing_urls_are_skipped() {
578        let json = r#"{
579            "log": { "entries": [
580                { "request": { "method": "GET", "url": "" },
581                  "response": { "status": 200, "content": {} } },
582                { "request": { "method": "GET", "url": "https://ok.example.com" },
583                  "response": { "status": 200, "content": {} } }
584            ] }
585        }"#;
586        let har: Har = serde_json::from_str(json).unwrap();
587        let routes = routes_from_har_memory(&har, None).unwrap();
588        assert_eq!(routes.len(), 1);
589    }
590
591    // Helper: run the same parsing path as `routes_from_har` but from an
592    // already-deserialized `Har` (the file-reading half is trivial).
593    fn routes_from_har_memory(
594        har: &Har,
595        opts: Option<&RouteFromHarOptions>,
596    ) -> Result<Vec<HarRoute>> {
597        let opts = opts.cloned().unwrap_or_default();
598        let filter = opts.url.as_deref();
599        let mut out = Vec::new();
600        for entry in &har.log.entries {
601            let url = entry.request.url.trim();
602            if url.is_empty() {
603                continue;
604            }
605            if let Some(glob) = filter {
606                if !glob_match(glob, url) {
607                    continue;
608                }
609            }
610            let headers = collect_headers(&entry.response.headers);
611            let (body, b64) = decode_body(&entry.response.content);
612            let body_string = if b64 {
613                String::from_utf8(body).unwrap_or_default()
614            } else {
615                String::from_utf8(body).unwrap_or_default()
616            };
617            out.push(HarRoute {
618                method: entry.request.method.to_ascii_uppercase(),
619                pattern: url.to_string(),
620                status: if entry.response.status == 0 { 200 } else { entry.response.status },
621                headers,
622                body: body_string,
623            });
624        }
625        Ok(out)
626    }
627}