1use 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#[derive(Debug, Clone, Deserialize, Serialize)]
32pub struct Har {
33 #[serde(default)]
34 pub log: HarLog,
35}
36
37#[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#[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#[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#[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 #[serde(default)]
96 pub pageref: Option<String>,
97}
98
99#[allow(unused_imports)]
102use serde_json::Value;
103
104#[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 #[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#[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#[derive(Debug, Clone, Default, Deserialize, Serialize)]
153pub struct HarContent {
154 #[serde(default)]
155 pub size: Option<i64>,
156 #[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#[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#[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#[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#[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#[derive(Debug, Clone)]
230pub struct HarRoute {
231 pub method: String,
233 pub pattern: String,
235 pub status: u16,
237 pub headers: Headers,
239 pub body: String,
243}
244
245impl HarRoute {
246 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 pub async fn fulfill(&self, route: &Route) -> Result<()> {
259 route.fulfill(self.fulfill_options()).await
260 }
261}
262
263#[derive(Debug, Clone, Default)]
266#[non_exhaustive]
267pub struct RouteFromHarOptions {
268 pub update: Option<bool>,
273 pub url: Option<String>,
276 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
299pub 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 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 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 String::from_utf8(body).unwrap_or_else(|e| {
359 String::from_utf8_lossy(&e.into_bytes()).into_owned()
360 })
361 } else {
362 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
384fn 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
396fn 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 Err(_) => (text.as_bytes().to_vec(), false),
412 }
413 } else {
414 (text.as_bytes().to_vec(), false)
415 }
416}
417
418pub(crate) fn glob_match(pattern: &str, url: &str) -> bool {
425 crate::route::pattern_matches(pattern, url)
426}
427
428#[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 pub fn record_entry(&mut self, entry: HarEntry) {
450 self.entries.push(entry);
451 }
452
453 pub fn len(&self) -> usize {
455 self.entries.len()
456 }
457
458 pub fn is_empty(&self) -> bool {
460 self.entries.is_empty()
461 }
462
463 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 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 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 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}