Skip to main content

relay_core_api/
har.rs

1use crate::flow::{Flow, Layer};
2use serde_json::{json, Value};
3
4/// Convert a Flow to a HAR 1.2 entry.
5/// Used by both the HTTP API and MCP probe for consistent output.
6pub fn flow_to_har_entry(flow: &Flow) -> Value {
7    let (request, response) = match &flow.layer {
8        Layer::Http(http) => (&http.request, http.response.as_ref()),
9        Layer::WebSocket(ws) => (&ws.handshake_request, Some(&ws.handshake_response)),
10        _ => return json!({ "request": {}, "response": {}, "timings": {} }),
11    };
12
13    let req_headers: Vec<Value> = request.headers.iter()
14        .map(|(k, v)| json!({ "name": k, "value": v })).collect();
15    let req_query: Vec<Value> = request.query.iter()
16        .map(|(k, v)| json!({ "name": k, "value": v })).collect();
17    let req_cookies: Vec<Value> = request.cookies.iter()
18        .map(|c| json!({ "name": c.name, "value": c.value })).collect();
19
20    let req_content_type = request.headers.iter()
21        .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
22        .map(|(_, v)| v.clone());
23
24    let mut req_json = json!({
25        "method": request.method,
26        "url": request.url.to_string(),
27        "httpVersion": request.version,
28        "headers": req_headers,
29        "queryString": req_query,
30        "cookies": req_cookies,
31        "headersSize": har_headers_size(&request.headers, &request.method, request.url.path(), request.url.query(), &request.version),
32        "bodySize": request.body.as_ref().map(|b| b.size).unwrap_or(0),
33    });
34    if let Some(body) = &request.body
35        && !body.content.is_empty() {
36            req_json["postData"] = json!({
37                "mimeType": req_content_type.unwrap_or_default(),
38                "text": body.content,
39            });
40    }
41
42    let resp_headers: Vec<Value> = response.map(|r| r.headers.iter()
43        .map(|(k, v)| json!({ "name": k, "value": v })).collect()).unwrap_or_default();
44    let resp_cookies: Vec<Value> = response.map(|r| r.cookies.iter()
45        .map(|c| json!({ "name": c.name, "value": c.value })).collect()).unwrap_or_default();
46
47    let mut resp_json = json!({});
48    let mut timings = json!({ "send": 0, "wait": 0, "receive": 0, "connect": -1, "ssl": -1, "dns": -1, "blocked": -1 });
49
50    if let Some(resp) = response {
51        let resp_content_type = resp.headers.iter()
52            .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
53            .map(|(_, v)| v.clone())
54            .unwrap_or_default();
55        let redirect_url = resp.headers.iter()
56            .find(|(k, _)| k.eq_ignore_ascii_case("location"))
57            .map(|(_, v)| v.clone())
58            .unwrap_or_default();
59
60        resp_json = json!({
61            "status": resp.status,
62            "statusText": resp.status_text,
63            "httpVersion": resp.version,
64            "headers": resp_headers,
65            "cookies": resp_cookies,
66            "content": {
67                "size": resp.body.as_ref().map(|b| b.size).unwrap_or(0),
68                "mimeType": resp_content_type,
69                "text": resp.body.as_ref().map(|b| b.content.as_str()).unwrap_or(""),
70            },
71            "redirectURL": redirect_url,
72            "headersSize": har_headers_size(&resp.headers, "", "", None, &resp.version),
73            "bodySize": resp.body.as_ref().map(|b| b.size).unwrap_or(0),
74        });
75
76        timings["wait"] = json!(resp.timing.time_to_first_byte.unwrap_or(0));
77        let ttlbs = resp.timing.time_to_last_byte.unwrap_or(0);
78        let wait = resp.timing.time_to_first_byte.unwrap_or(0);
79        timings["receive"] = json!(ttlbs.saturating_sub(wait));
80        if let Some(c) = resp.timing.connect_time_ms { timings["connect"] = json!(c); }
81        if let Some(s) = resp.timing.ssl_time_ms { timings["ssl"] = json!(s); }
82    }
83
84    let total_time = response.map(|r| r.timing.time_to_last_byte.unwrap_or(0))
85        .unwrap_or(0);
86
87    json!({
88        "startedDateTime": flow.start_time.to_rfc3339(),
89        "time": total_time,
90        "request": req_json,
91        "response": resp_json,
92        "timings": timings,
93        "cache": {},
94        "_relaycore": {
95            "flow_id": flow.id.to_string(),
96            "client_ip": flow.network.client_ip,
97            "server_ip": flow.network.server_ip,
98            "tags": flow.tags,
99        }
100    })
101}
102
103fn har_headers_size(headers: &[(String, String)], method: &str, path: &str, query: Option<&str>, version: &str) -> u64 {
104    let start_line = if method.is_empty() {
105        version.len() + 1 + 3 + 1 + 3 + 2
106    } else {
107        let q = query.map(|q| q.len() + 1).unwrap_or(0);
108        method.len() + 1 + path.len() + q + 1 + version.len() + 2
109    };
110    let headers_bytes: usize = headers.iter().map(|(k, v)| k.len() + 2 + v.len() + 2).sum();
111    (start_line + headers_bytes + 2) as u64
112}