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 {
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#[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 pub fn record_entry(&mut self, entry: HarEntry) {
460 self.entries.push(entry);
461 }
462
463 pub fn len(&self) -> usize {
465 self.entries.len()
466 }
467
468 pub fn is_empty(&self) -> bool {
470 self.entries.is_empty()
471 }
472
473 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 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 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 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}