irongit
jv/src/har.rs
197 lines5.8 KBRust
1use serde_json::Value;
2use std::collections::HashMap;
3
4/// Extracts JSON response bodies from a HAR file, returning (filename, parsed_json) pairs.
5pub fn extract_har_files(har: &Value) -> Vec<(String, Value)> {
6 let entries = match har.pointer("/log/entries") {
7 Some(Value::Array(arr)) => arr,
8 _ => return vec![],
9 };
10
11 let mut name_counts: HashMap<String, usize> = HashMap::new();
12 let mut results = Vec::new();
13
14 for entry in entries {
15 // Filter: response status must be 2xx
16 let status = entry
17 .pointer("/response/status")
18 .and_then(|v| v.as_u64())
19 .unwrap_or(0);
20 if status < 200 || status >= 300 {
21 continue;
22 }
23
24 // Filter: mimeType must contain "json"
25 let mime = entry
26 .pointer("/response/content/mimeType")
27 .and_then(|v| v.as_str())
28 .unwrap_or("");
29 if !mime.contains("json") {
30 continue;
31 }
32
33 // Filter: must not be base64 encoded
34 let encoding = entry
35 .pointer("/response/content/encoding")
36 .and_then(|v| v.as_str())
37 .unwrap_or("");
38 if encoding == "base64" {
39 continue;
40 }
41
42 // Parse response body
43 let text = match entry.pointer("/response/content/text").and_then(|v| v.as_str()) {
44 Some(t) => t,
45 None => continue,
46 };
47 let parsed: Value = match serde_json::from_str(text) {
48 Ok(v) => v,
49 Err(_) => continue,
50 };
51
52 // Build filename
53 let url = entry
54 .pointer("/request/url")
55 .and_then(|v| v.as_str())
56 .unwrap_or("");
57 let method = entry
58 .pointer("/request/method")
59 .and_then(|v| v.as_str())
60 .unwrap_or("GET");
61
62 let path_seg = extract_path_segment(url);
63 let values = collect_distinguishing_values(entry, method, url);
64
65 let base_name = if values.is_empty() {
66 path_seg.clone()
67 } else {
68 format!("{}_{}", path_seg, values.join("_"))
69 };
70
71 let count = name_counts.entry(base_name.clone()).or_insert(0);
72 *count += 1;
73 let filename = if *count == 1 {
74 format!("{}.json", base_name)
75 } else {
76 format!("{}_{}.json", base_name, count)
77 };
78
79 results.push((filename, parsed));
80 }
81
82 results
83}
84
85/// Extracts the last meaningful URL path segment, stripping common API prefixes
86/// and numeric-only segments.
87pub fn extract_path_segment(url: &str) -> String {
88 // Strip query string and fragment
89 let path_part = url.split('?').next().unwrap_or(url);
90 let path_part = path_part.split('#').next().unwrap_or(path_part);
91
92 // Extract path from full URL (strip scheme + host)
93 let path = if let Some(idx) = path_part.find("://") {
94 let after_scheme = &path_part[idx + 3..];
95 after_scheme.find('/').map_or("", |i| &after_scheme[i..])
96 } else {
97 path_part
98 };
99
100 let segments: Vec<&str> = path
101 .split('/')
102 .filter(|s| !s.is_empty())
103 .filter(|s| {
104 let lower = s.to_lowercase();
105 lower != "api" && lower != "rest" && !is_version_segment(s)
106 })
107 .collect();
108
109 // Walk backwards to find a non-numeric segment
110 for seg in segments.iter().rev() {
111 if !seg.chars().all(|c| c.is_ascii_digit()) {
112 return seg.to_string();
113 }
114 }
115
116 // Fallback: use last segment even if numeric, or "response"
117 segments
118 .last()
119 .map(|s| s.to_string())
120 .unwrap_or_else(|| "response".to_string())
121}
122
123/// Collects short, useful param values from the request for naming purposes.
124fn collect_distinguishing_values(entry: &Value, method: &str, url: &str) -> Vec<String> {
125 let mut values = Vec::new();
126
127 // Extract query string values
128 if let Some(query) = url.split('?').nth(1) {
129 for pair in query.split('&') {
130 let val = pair.split('=').nth(1).unwrap_or("");
131 if is_useful_value(val) {
132 values.push(val.to_string());
133 }
134 }
135 }
136
137 // For POST, also look at body fields
138 if method.eq_ignore_ascii_case("POST") {
139 if let Some(text) = entry
140 .pointer("/request/postData/text")
141 .and_then(|v| v.as_str())
142 {
143 if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(text) {
144 for (_key, val) in &map {
145 if let Some(s) = val.as_str() {
146 if is_useful_value(s) {
147 values.push(s.to_string());
148 }
149 }
150 }
151 }
152 }
153 }
154
155 values
156}
157
158/// Returns true for version path segments like v1, v2, v10, etc.
159fn is_version_segment(s: &str) -> bool {
160 let lower = s.to_lowercase();
161 if let Some(rest) = lower.strip_prefix('v') {
162 !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
163 } else {
164 false
165 }
166}
167
168/// Returns true if the value is short, not all digits, and not hex-like.
169fn is_useful_value(s: &str) -> bool {
170 if s.is_empty() || s.len() > 15 {
171 return false;
172 }
173 if s.chars().all(|c| c.is_ascii_digit()) {
174 return false;
175 }
176 if is_hex_like(s) {
177 return false;
178 }
179 true
180}
181
182/// Returns true if the string looks like a UUID or is predominantly hex digits.
183pub fn is_hex_like(s: &str) -> bool {
184 // UUID pattern: 8-4-4-4-12 hex digits
185 let stripped = s.replace('-', "");
186 if stripped.len() == 32 && stripped.chars().all(|c| c.is_ascii_hexdigit()) {
187 return true;
188 }
189 // Long strings with >50% hex digits
190 if s.len() > 15 {
191 let hex_count = s.chars().filter(|c| c.is_ascii_hexdigit()).count();
192 if hex_count as f64 / s.len() as f64 > 0.5 {
193 return true;
194 }
195 }
196 false
197}