Skip to main content

source_map_tauri/frontend/
http.rs

1use std::path::Path;
2
3use regex::Regex;
4use serde_json::{json, Map, Value};
5
6use crate::{
7    config::{normalize_path, ResolvedConfig},
8    ids::document_id,
9    model::ArtifactDoc,
10    security::apply_artifact_security,
11};
12
13#[derive(Debug)]
14struct ExportedFunction<'a> {
15    name: &'a str,
16    line: u32,
17    body: &'a str,
18}
19
20fn line_number(text: &str, offset: usize) -> u32 {
21    text[..offset].bytes().filter(|byte| *byte == b'\n').count() as u32 + 1
22}
23
24fn base_artifact(
25    config: &ResolvedConfig,
26    path: &Path,
27    kind: &str,
28    name: &str,
29    line: u32,
30) -> ArtifactDoc {
31    let source_path = normalize_path(&config.root, path);
32    let mut doc = ArtifactDoc {
33        id: document_id(
34            &config.repo,
35            kind,
36            Some(&source_path),
37            Some(line),
38            Some(name),
39        ),
40        repo: config.repo.clone(),
41        kind: kind.to_owned(),
42        side: Some("frontend".to_owned()),
43        language: crate::frontend::language_for_path(path),
44        name: Some(name.to_owned()),
45        display_name: Some(name.to_owned()),
46        source_path: Some(source_path),
47        line_start: Some(line),
48        line_end: Some(line),
49        column_start: None,
50        column_end: None,
51        package_name: None,
52        comments: Vec::new(),
53        tags: Vec::new(),
54        related_symbols: Vec::new(),
55        related_tests: Vec::new(),
56        risk_level: "low".to_owned(),
57        risk_reasons: Vec::new(),
58        contains_phi: false,
59        has_related_tests: false,
60        updated_at: chrono::Utc::now().to_rfc3339(),
61        data: Map::new(),
62    };
63    apply_artifact_security(&mut doc);
64    doc
65}
66
67pub fn extract_http_artifacts(
68    config: &ResolvedConfig,
69    path: &Path,
70    text: &str,
71) -> Vec<ArtifactDoc> {
72    let mut artifacts = Vec::new();
73
74    for exported in exported_functions(text) {
75        if let Some((transport_name, method, normalized_path)) =
76            wrapper_transport_call(exported.body)
77        {
78            let mut doc = base_artifact(
79                config,
80                path,
81                "frontend_api_wrapper",
82                exported.name,
83                exported.line,
84            );
85            doc.display_name = Some(format!("{} wrapper", exported.name));
86            doc.tags = vec!["api wrapper".to_owned(), "http".to_owned()];
87            doc.data.insert(
88                "transport_name".to_owned(),
89                Value::String(transport_name.to_owned()),
90            );
91            doc.data
92                .insert("http_method".to_owned(), Value::String(method.to_owned()));
93            doc.data.insert(
94                "normalized_path".to_owned(),
95                Value::String(normalized_path.clone()),
96            );
97            doc.data.insert(
98                "endpoint_key".to_owned(),
99                Value::String(format!("{method} {normalized_path}")),
100            );
101            apply_artifact_security(&mut doc);
102            artifacts.push(doc);
103        } else if let Some((method, normalized_path)) = direct_http_call(exported.body) {
104            let mut doc = base_artifact(
105                config,
106                path,
107                "frontend_api_wrapper",
108                exported.name,
109                exported.line,
110            );
111            doc.display_name = Some(format!("{} wrapper", exported.name));
112            doc.tags = vec!["api wrapper".to_owned(), "http".to_owned()];
113            doc.data.insert(
114                "transport_name".to_owned(),
115                Value::String("tauriFetch".to_owned()),
116            );
117            doc.data
118                .insert("http_method".to_owned(), Value::String(method.to_owned()));
119            doc.data.insert(
120                "normalized_path".to_owned(),
121                Value::String(normalized_path.clone()),
122            );
123            doc.data.insert(
124                "endpoint_key".to_owned(),
125                Value::String(format!("{method} {normalized_path}")),
126            );
127            apply_artifact_security(&mut doc);
128            artifacts.push(doc);
129        }
130
131        if let Some((method, client_name, path_param, url_pattern)) =
132            transport_definition(exported.body)
133        {
134            let mut doc = base_artifact(
135                config,
136                path,
137                "frontend_transport",
138                exported.name,
139                exported.line,
140            );
141            doc.display_name = Some(format!("{} transport", exported.name));
142            doc.tags = vec!["transport".to_owned(), "http".to_owned()];
143            doc.data
144                .insert("http_method".to_owned(), Value::String(method.to_owned()));
145            doc.data.insert(
146                "http_client".to_owned(),
147                Value::String(client_name.to_owned()),
148            );
149            doc.data.insert(
150                "path_param".to_owned(),
151                Value::String(path_param.to_owned()),
152            );
153            doc.data.insert(
154                "url_pattern".to_owned(),
155                Value::String(url_pattern.to_owned()),
156            );
157            doc.data.insert(
158                "transport_signature".to_owned(),
159                json!({
160                    "client": client_name,
161                    "method": method,
162                    "path_param": path_param,
163                }),
164            );
165            apply_artifact_security(&mut doc);
166            artifacts.push(doc);
167        }
168    }
169
170    artifacts
171}
172
173fn exported_functions(text: &str) -> Vec<ExportedFunction<'_>> {
174    let function_re = Regex::new(r"(?m)^\s*export\s+function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(")
175        .expect("valid regex");
176    let const_re =
177        Regex::new(r"(?m)^\s*export\s+const\s+([A-Za-z_][A-Za-z0-9_]*)\s*=").expect("valid regex");
178
179    let mut items = Vec::new();
180    for regex in [&function_re, &const_re] {
181        for capture in regex.captures_iter(text) {
182            let whole = capture.get(0).expect("match");
183            let Some(name) = capture.get(1).map(|value| value.as_str()) else {
184                continue;
185            };
186            let Some(body_start) = text[whole.start()..]
187                .find('{')
188                .map(|offset| whole.start() + offset)
189            else {
190                continue;
191            };
192            let Some(body_end) = find_matching_brace(text, body_start) else {
193                continue;
194            };
195            items.push(ExportedFunction {
196                name,
197                line: line_number(text, whole.start()),
198                body: &text[body_start + 1..body_end],
199            });
200        }
201    }
202
203    items.sort_by_key(|item| item.line);
204    items
205}
206
207fn find_matching_brace(text: &str, open_index: usize) -> Option<usize> {
208    let bytes = text.as_bytes();
209    let mut depth = 0_u32;
210    let mut index = open_index;
211    let mut in_single = false;
212    let mut in_double = false;
213    let mut in_template = false;
214    let mut line_comment = false;
215    let mut block_comment = false;
216    let mut escaped = false;
217
218    while index < bytes.len() {
219        let byte = bytes[index];
220        let next = bytes.get(index + 1).copied();
221
222        if line_comment {
223            if byte == b'\n' {
224                line_comment = false;
225            }
226            index += 1;
227            continue;
228        }
229
230        if block_comment {
231            if byte == b'*' && next == Some(b'/') {
232                block_comment = false;
233                index += 2;
234            } else {
235                index += 1;
236            }
237            continue;
238        }
239
240        if escaped {
241            escaped = false;
242            index += 1;
243            continue;
244        }
245
246        match byte {
247            b'\\' if in_single || in_double || in_template => {
248                escaped = true;
249                index += 1;
250            }
251            b'\'' if !in_double && !in_template => {
252                in_single = !in_single;
253                index += 1;
254            }
255            b'"' if !in_single && !in_template => {
256                in_double = !in_double;
257                index += 1;
258            }
259            b'`' if !in_single && !in_double => {
260                in_template = !in_template;
261                index += 1;
262            }
263            b'/' if !in_single && !in_double && !in_template && next == Some(b'/') => {
264                line_comment = true;
265                index += 2;
266            }
267            b'/' if !in_single && !in_double && !in_template && next == Some(b'*') => {
268                block_comment = true;
269                index += 2;
270            }
271            b'{' if !in_single && !in_double => {
272                depth += 1;
273                index += 1;
274            }
275            b'}' if !in_single && !in_double => {
276                depth = depth.saturating_sub(1);
277                if depth == 0 {
278                    return Some(index);
279                }
280                index += 1;
281            }
282            _ => {
283                index += 1;
284            }
285        }
286    }
287
288    None
289}
290
291fn wrapper_transport_call(body: &str) -> Option<(&'static str, &'static str, String)> {
292    let call_re = Regex::new(
293        r#"\b(usePostApi|usePostMutation|usePostUploadMutation|postApi)\s*\(\s*["']([^"']+)["']"#,
294    )
295    .expect("valid regex");
296    let capture = call_re.captures(body)?;
297    let transport_name = capture.get(1)?.as_str();
298    let raw_path = capture.get(2)?.as_str();
299    let method = match transport_name {
300        "usePostApi" | "usePostMutation" | "usePostUploadMutation" | "postApi" => "POST",
301        _ => return None,
302    };
303    Some((
304        transport_name_static(transport_name)?,
305        method,
306        normalize_http_path(raw_path),
307    ))
308}
309
310fn transport_definition(
311    body: &str,
312) -> Option<(&'static str, &'static str, &'static str, &'static str)> {
313    let pattern_re = Regex::new(
314        r#"(?s)\btauriFetch\s*\(\s*`[^`]*\$\{API_URL\}/\$\{([A-Za-z_][A-Za-z0-9_]*)\}[^`]*`\s*,\s*\{.*?method\s*:\s*["']([A-Z]+)["']"#,
315    )
316    .expect("valid regex");
317    let capture = pattern_re.captures(body)?;
318    let path_param = capture.get(1)?.as_str();
319    let method = capture.get(2)?.as_str();
320    if path_param != "path" || method != "POST" {
321        return None;
322    }
323    Some(("POST", "tauriFetch", "path", "${API_URL}/${path}"))
324}
325
326fn direct_http_call(body: &str) -> Option<(&'static str, String)> {
327    let direct_re = Regex::new(
328        r#"(?s)\btauriFetch\s*\(\s*`[^`]*\$\{API_URL\}/([^`$]+)`\s*,\s*\{.*?method\s*:\s*["']([A-Z]+)["']"#,
329    )
330    .expect("valid regex");
331    let capture = direct_re.captures(body)?;
332    let raw_path = capture.get(1)?.as_str().trim();
333    let method = capture.get(2)?.as_str();
334    if method != "POST" {
335        return None;
336    }
337    Some((method_static(method)?, normalize_http_path(raw_path)))
338}
339
340fn normalize_http_path(path: &str) -> String {
341    let trimmed = path.trim();
342    if trimmed.starts_with('/') {
343        trimmed.to_owned()
344    } else {
345        format!("/{trimmed}")
346    }
347}
348
349fn transport_name_static(name: &str) -> Option<&'static str> {
350    match name {
351        "usePostApi" => Some("usePostApi"),
352        "usePostMutation" => Some("usePostMutation"),
353        "usePostUploadMutation" => Some("usePostUploadMutation"),
354        "postApi" => Some("postApi"),
355        _ => None,
356    }
357}
358
359fn method_static(method: &str) -> Option<&'static str> {
360    match method {
361        "POST" => Some("POST"),
362        "GET" => Some("GET"),
363        "PUT" => Some("PUT"),
364        "PATCH" => Some("PATCH"),
365        "DELETE" => Some("DELETE"),
366        _ => None,
367    }
368}