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.
419///
420/// This is a thin wrapper over [`crate::route::pattern_matches`] so that the
421/// `opts.url` filter behaves identically to the registered route patterns,
422/// including the full Playwright URL glob syntax (`**`, `*`, `?`, `[abc]`).
423/// See `route::pattern_matches` for the exact semantics.
424pub(crate) fn glob_match(pattern: &str, url: &str) -> bool {
425    crate::route::pattern_matches(pattern, url)
426}
427
428// ---------------------------------------------------------------------------
429// Recording (deferred / partial)
430// ---------------------------------------------------------------------------
431
432/// A minimal HAR recorder: builds a [`Har`] in memory and can write it to disk.
433///
434/// This exists so that `route_from_har`'s `update` path has a place to land,
435/// but **recording is not wired into the live request stream** of pages in this
436/// pass. Constructing a recorder and calling [`HarRecorder::record_entry`]
437/// works, but nothing feeds it live traffic automatically. See the report.
438#[derive(Debug, Clone, Default)]
439pub struct HarRecorder {
440    entries: Vec<HarEntry>,
441}
442
443impl HarRecorder {
444    pub fn new() -> Self {
445        Self::default()
446    }
447
448    /// Append a pre-built entry. (Not yet driven automatically by page traffic.)
449    pub fn record_entry(&mut self, entry: HarEntry) {
450        self.entries.push(entry);
451    }
452
453    /// Number of recorded entries so far.
454    pub fn len(&self) -> usize {
455        self.entries.len()
456    }
457
458    /// Whether any entries have been recorded.
459    pub fn is_empty(&self) -> bool {
460        self.entries.is_empty()
461    }
462
463    /// Build the in-memory [`Har`] document.
464    pub fn to_har(&self) -> Har {
465        Har {
466            log: HarLog {
467                version: Some("1.2".to_string()),
468                creator: Some(HarCreator {
469                    name: Some("playwright-cdp".to_string()),
470                    version: Some(env!("CARGO_PKG_VERSION").to_string()),
471                }),
472                browser: None,
473                pages: Vec::new(),
474                entries: self.entries.clone(),
475            },
476        }
477    }
478
479    /// Serialize the recorded HAR to a JSON string (pretty, matching the HAR
480    /// convention of human-readable files).
481    pub fn to_json(&self) -> Result<String> {
482        serde_json::to_string_pretty(&self.to_har())
483            .map_err(|e| Error::ProtocolError(format!("failed to serialize HAR: {}", e)))
484    }
485
486    /// Write the recorded HAR to `path`.
487    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
488        let json = self.to_json()?;
489        std::fs::write(path.as_ref(), json).map_err(|e| {
490            Error::InvalidArgument(format!(
491                "failed to write HAR file {}: {}",
492                path.as_ref().display(),
493                e
494            ))
495        })
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn parses_minimal_har() {
505        let json = r#"{
506            "log": {
507                "version": "1.2",
508                "entries": [
509                    {
510                        "request": { "method": "GET", "url": "https://example.com/api" },
511                        "response": {
512                            "status": 200,
513                            "statusText": "OK",
514                            "headers": [ { "name": "Content-Type", "value": "application/json" } ],
515                            "content": { "text": "{\"ok\":true}", "mimeType": "application/json" }
516                        }
517                    }
518                ]
519            }
520        }"#;
521        let har: Har = serde_json::from_str(json).unwrap();
522        assert_eq!(har.log.entries.len(), 1);
523
524        let routes = routes_from_har_memory(&har, None).unwrap();
525        assert_eq!(routes.len(), 1);
526        assert_eq!(routes[0].method, "GET");
527        assert_eq!(routes[0].pattern, "https://example.com/api");
528        assert_eq!(routes[0].status, 200);
529        assert_eq!(routes[0].body, "{\"ok\":true}");
530        assert_eq!(routes[0].headers.get("content-type").unwrap(), "application/json");
531    }
532
533    #[test]
534    fn decodes_base64_body() {
535        let json = r#"{
536            "log": { "entries": [ {
537                "request": { "method": "GET", "url": "https://example.com/img" },
538                "response": {
539                    "status": 200,
540                    "content": { "text": "aGVsbG8=", "encoding": "base64", "mimeType": "text/plain" }
541                }
542            } ] }
543        }"#;
544        let har: Har = serde_json::from_str(json).unwrap();
545        let routes = routes_from_har_memory(&har, None).unwrap();
546        assert_eq!(routes[0].body, "hello");
547    }
548
549    #[test]
550    fn url_filter_skips_non_matching() {
551        let json = r#"{
552            "log": { "entries": [
553                { "request": { "method": "GET", "url": "https://a.example.com/x" },
554                  "response": { "status": 200, "content": {} } },
555                { "request": { "method": "GET", "url": "https://b.example.com/y" },
556                  "response": { "status": 200, "content": {} } }
557            ] }
558        }"#;
559        let har: Har = serde_json::from_str(json).unwrap();
560        let opts = RouteFromHarOptions::default().url("*/x");
561        let routes = routes_from_har_memory(&har, Some(&opts)).unwrap();
562        assert_eq!(routes.len(), 1);
563        assert_eq!(routes[0].pattern, "https://a.example.com/x");
564    }
565
566    #[test]
567    fn empty_and_missing_urls_are_skipped() {
568        let json = r#"{
569            "log": { "entries": [
570                { "request": { "method": "GET", "url": "" },
571                  "response": { "status": 200, "content": {} } },
572                { "request": { "method": "GET", "url": "https://ok.example.com" },
573                  "response": { "status": 200, "content": {} } }
574            ] }
575        }"#;
576        let har: Har = serde_json::from_str(json).unwrap();
577        let routes = routes_from_har_memory(&har, None).unwrap();
578        assert_eq!(routes.len(), 1);
579    }
580
581    // Helper: run the same parsing path as `routes_from_har` but from an
582    // already-deserialized `Har` (the file-reading half is trivial).
583    fn routes_from_har_memory(
584        har: &Har,
585        opts: Option<&RouteFromHarOptions>,
586    ) -> Result<Vec<HarRoute>> {
587        let opts = opts.cloned().unwrap_or_default();
588        let filter = opts.url.as_deref();
589        let mut out = Vec::new();
590        for entry in &har.log.entries {
591            let url = entry.request.url.trim();
592            if url.is_empty() {
593                continue;
594            }
595            if let Some(glob) = filter {
596                if !glob_match(glob, url) {
597                    continue;
598                }
599            }
600            let headers = collect_headers(&entry.response.headers);
601            let (body, b64) = decode_body(&entry.response.content);
602            let body_string = if b64 {
603                String::from_utf8(body).unwrap_or_default()
604            } else {
605                String::from_utf8(body).unwrap_or_default()
606            };
607            out.push(HarRoute {
608                method: entry.request.method.to_ascii_uppercase(),
609                pattern: url.to_string(),
610                status: if entry.response.status == 0 { 200 } else { entry.response.status },
611                headers,
612                body: body_string,
613            });
614        }
615        Ok(out)
616    }
617}