Skip to main content

relay_core_api/
har.rs

1use crate::flow::{Flow, Layer};
2use serde_json::{Value, json};
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
14        .headers
15        .iter()
16        .map(|(k, v)| json!({ "name": k, "value": v }))
17        .collect();
18    let req_query: Vec<Value> = request
19        .query
20        .iter()
21        .map(|(k, v)| json!({ "name": k, "value": v }))
22        .collect();
23    let req_cookies: Vec<Value> = request
24        .cookies
25        .iter()
26        .map(|c| json!({ "name": c.name, "value": c.value }))
27        .collect();
28
29    let req_content_type = request
30        .headers
31        .iter()
32        .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
33        .map(|(_, v)| v.clone());
34
35    let mut req_json = json!({
36        "method": request.method,
37        "url": request.url.to_string(),
38        "httpVersion": request.version,
39        "headers": req_headers,
40        "queryString": req_query,
41        "cookies": req_cookies,
42        "headersSize": har_headers_size(&request.headers, &request.method, request.url.path(), request.url.query(), &request.version),
43        "bodySize": request.body.as_ref().map(|b| b.size).unwrap_or(0),
44    });
45    if let Some(body) = &request.body
46        && !body.content.is_empty()
47    {
48        req_json["postData"] = json!({
49            "mimeType": req_content_type.unwrap_or_default(),
50            "text": body.content,
51        });
52    }
53
54    let resp_headers: Vec<Value> = response
55        .map(|r| {
56            r.headers
57                .iter()
58                .map(|(k, v)| json!({ "name": k, "value": v }))
59                .collect()
60        })
61        .unwrap_or_default();
62    let resp_cookies: Vec<Value> = response
63        .map(|r| {
64            r.cookies
65                .iter()
66                .map(|c| json!({ "name": c.name, "value": c.value }))
67                .collect()
68        })
69        .unwrap_or_default();
70
71    let mut resp_json = json!({});
72    let mut timings = json!({ "send": 0, "wait": 0, "receive": 0, "connect": -1, "ssl": -1, "dns": -1, "blocked": -1 });
73
74    if let Some(resp) = response {
75        let resp_content_type = resp
76            .headers
77            .iter()
78            .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
79            .map(|(_, v)| v.clone())
80            .unwrap_or_default();
81        let redirect_url = resp
82            .headers
83            .iter()
84            .find(|(k, _)| k.eq_ignore_ascii_case("location"))
85            .map(|(_, v)| v.clone())
86            .unwrap_or_default();
87
88        resp_json = json!({
89            "status": resp.status,
90            "statusText": resp.status_text,
91            "httpVersion": resp.version,
92            "headers": resp_headers,
93            "cookies": resp_cookies,
94            "content": {
95                "size": resp.body.as_ref().map(|b| b.size).unwrap_or(0),
96                "mimeType": resp_content_type,
97                "text": resp.body.as_ref().map(|b| b.content.as_str()).unwrap_or(""),
98            },
99            "redirectURL": redirect_url,
100            "headersSize": har_headers_size(&resp.headers, "", "", None, &resp.version),
101            "bodySize": resp.body.as_ref().map(|b| b.size).unwrap_or(0),
102        });
103
104        timings["wait"] = json!(resp.timing.time_to_first_byte.unwrap_or(0));
105        let ttlbs = resp.timing.time_to_last_byte.unwrap_or(0);
106        let wait = resp.timing.time_to_first_byte.unwrap_or(0);
107        timings["receive"] = json!(ttlbs.saturating_sub(wait));
108        if let Some(c) = resp.timing.connect_time_ms {
109            timings["connect"] = json!(c);
110        }
111        if let Some(s) = resp.timing.ssl_time_ms {
112            timings["ssl"] = json!(s);
113        }
114    }
115
116    let total_time = response
117        .map(|r| r.timing.time_to_last_byte.unwrap_or(0))
118        .unwrap_or(0);
119
120    json!({
121        "startedDateTime": flow.start_time.to_rfc3339(),
122        "time": total_time,
123        "request": req_json,
124        "response": resp_json,
125        "timings": timings,
126        "cache": {},
127        "_relaycore": {
128            "flow_id": flow.id.to_string(),
129            "client_ip": flow.network.client_ip,
130            "server_ip": flow.network.server_ip,
131            "tags": flow.tags,
132        }
133    })
134}
135
136fn har_headers_size(
137    headers: &[(String, String)],
138    method: &str,
139    path: &str,
140    query: Option<&str>,
141    version: &str,
142) -> u64 {
143    let start_line = if method.is_empty() {
144        version.len() + 1 + 3 + 1 + 3 + 2
145    } else {
146        let q = query.map(|q| q.len() + 1).unwrap_or(0);
147        method.len() + 1 + path.len() + q + 1 + version.len() + 2
148    };
149    let headers_bytes: usize = headers.iter().map(|(k, v)| k.len() + 2 + v.len() + 2).sum();
150    (start_line + headers_bytes + 2) as u64
151}