Skip to main content

shape_runtime/stdlib/
http.rs

1//! Native `http` module for making HTTP requests.
2//!
3//! Exports: http.get, http.post, http.put, http.delete
4//!
5//! All functions are async. Uses reqwest under the hood.
6//! Policy gated: requires NetConnect permission.
7
8use crate::module_exports::{ModuleExports, ModuleFunction, ModuleParam};
9use shape_value::ValueWord;
10use std::sync::Arc;
11
12/// Build an HttpResponse ValueWord HashMap from the response parts.
13/// Fields: status (number), headers (HashMap), body (string), ok (bool)
14fn build_response(status: u16, headers: Vec<(String, String)>, body: String) -> ValueWord {
15    // headers as HashMap
16    let mut h_keys = Vec::with_capacity(headers.len());
17    let mut h_values = Vec::with_capacity(headers.len());
18    for (hk, hv) in headers.iter() {
19        h_keys.push(ValueWord::from_string(Arc::new(hk.clone())));
20        h_values.push(ValueWord::from_string(Arc::new(hv.clone())));
21    }
22
23    let keys = vec![
24        ValueWord::from_string(Arc::new("status".to_string())),
25        ValueWord::from_string(Arc::new("headers".to_string())),
26        ValueWord::from_string(Arc::new("body".to_string())),
27        ValueWord::from_string(Arc::new("ok".to_string())),
28    ];
29    let values = vec![
30        ValueWord::from_f64(status as f64),
31        ValueWord::from_hashmap_pairs(h_keys, h_values),
32        ValueWord::from_string(Arc::new(body)),
33        ValueWord::from_bool((200..300).contains(&status)),
34    ];
35    ValueWord::from_hashmap_pairs(keys, values)
36}
37
38/// Extract optional headers from an options HashMap argument.
39fn extract_headers(options: &ValueWord) -> Vec<(String, String)> {
40    if let Some((keys, values, _)) = options.as_hashmap() {
41        // Look for a "headers" key
42        for (i, k) in keys.iter().enumerate() {
43            if k.as_str() == Some("headers") {
44                if let Some((hk, hv, _)) = values[i].as_hashmap() {
45                    return hk
46                        .iter()
47                        .zip(hv.iter())
48                        .filter_map(|(k, v)| {
49                            Some((k.as_str()?.to_string(), v.as_str()?.to_string()))
50                        })
51                        .collect();
52                }
53            }
54        }
55    }
56    Vec::new()
57}
58
59/// Extract optional timeout from an options HashMap argument (in milliseconds).
60fn extract_timeout(options: &ValueWord) -> Option<std::time::Duration> {
61    if let Some((keys, values, _)) = options.as_hashmap() {
62        for (i, k) in keys.iter().enumerate() {
63            if k.as_str() == Some("timeout") {
64                if let Some(ms) = values[i].as_number_coerce() {
65                    if ms > 0.0 {
66                        return Some(std::time::Duration::from_millis(ms as u64));
67                    }
68                }
69            }
70        }
71    }
72    None
73}
74
75/// Create the `http` module with async HTTP request functions.
76pub fn create_http_module() -> ModuleExports {
77    let mut module = ModuleExports::new("std::core::http");
78    module.description = "HTTP client for making web requests".to_string();
79
80    let url_param = ModuleParam {
81        name: "url".to_string(),
82        type_name: "string".to_string(),
83        required: true,
84        description: "URL to request".to_string(),
85        ..Default::default()
86    };
87
88    let options_param = ModuleParam {
89        name: "options".to_string(),
90        type_name: "object".to_string(),
91        required: false,
92        description: "Request options: { headers?: HashMap, timeout?: number }".to_string(),
93        ..Default::default()
94    };
95
96    let body_param = ModuleParam {
97        name: "body".to_string(),
98        type_name: "any".to_string(),
99        required: false,
100        description: "Request body (string or value to serialize as JSON)".to_string(),
101        ..Default::default()
102    };
103
104    // http.get(url: string, options?: object) -> Result<HttpResponse>
105    module.add_async_function_with_schema(
106        "get",
107        |args: Vec<ValueWord>| async move {
108            let url = args
109                .first()
110                .and_then(|a| a.as_str())
111                .map(|s| s.to_string())
112                .ok_or_else(|| "http.get() requires a URL string".to_string())?;
113
114            let mut builder = reqwest::Client::new().get(&url);
115
116            if let Some(options) = args.get(1) {
117                for (k, v) in extract_headers(options) {
118                    builder = builder.header(&k, &v);
119                }
120                if let Some(timeout) = extract_timeout(options) {
121                    builder = builder.timeout(timeout);
122                }
123            }
124
125            let resp = builder
126                .send()
127                .await
128                .map_err(|e| format!("http.get() failed: {}", e))?;
129
130            let status = resp.status().as_u16();
131            let headers: Vec<(String, String)> = resp
132                .headers()
133                .iter()
134                .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
135                .collect();
136            let body = resp
137                .text()
138                .await
139                .map_err(|e| format!("http.get() body read failed: {}", e))?;
140
141            Ok(ValueWord::from_ok(build_response(status, headers, body)))
142        },
143        ModuleFunction {
144            description: "Perform an HTTP GET request".to_string(),
145            params: vec![url_param.clone(), options_param.clone()],
146            return_type: Some("Result<HttpResponse>".to_string()),
147        },
148    );
149
150    // http.post(url: string, body?: any, options?: object) -> Result<HttpResponse>
151    module.add_async_function_with_schema(
152        "post",
153        |args: Vec<ValueWord>| async move {
154            let url = args
155                .first()
156                .and_then(|a| a.as_str())
157                .map(|s| s.to_string())
158                .ok_or_else(|| "http.post() requires a URL string".to_string())?;
159
160            let mut builder = reqwest::Client::new().post(&url);
161
162            // Body
163            if let Some(body_arg) = args.get(1) {
164                if !body_arg.is_none() && !body_arg.is_unit() {
165                    if let Some(s) = body_arg.as_str() {
166                        builder = builder.body(s.to_string());
167                    } else {
168                        let json = body_arg.to_json_value();
169                        builder = builder
170                            .header("Content-Type", "application/json")
171                            .body(serde_json::to_string(&json).unwrap_or_default());
172                    }
173                }
174            }
175
176            // Options
177            if let Some(options) = args.get(2) {
178                for (k, v) in extract_headers(options) {
179                    builder = builder.header(&k, &v);
180                }
181                if let Some(timeout) = extract_timeout(options) {
182                    builder = builder.timeout(timeout);
183                }
184            }
185
186            let resp = builder
187                .send()
188                .await
189                .map_err(|e| format!("http.post() failed: {}", e))?;
190
191            let status = resp.status().as_u16();
192            let headers: Vec<(String, String)> = resp
193                .headers()
194                .iter()
195                .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
196                .collect();
197            let body = resp
198                .text()
199                .await
200                .map_err(|e| format!("http.post() body read failed: {}", e))?;
201
202            Ok(ValueWord::from_ok(build_response(status, headers, body)))
203        },
204        ModuleFunction {
205            description: "Perform an HTTP POST request".to_string(),
206            params: vec![url_param.clone(), body_param.clone(), options_param.clone()],
207            return_type: Some("Result<HttpResponse>".to_string()),
208        },
209    );
210
211    // http.put(url: string, body?: any, options?: object) -> Result<HttpResponse>
212    module.add_async_function_with_schema(
213        "put",
214        |args: Vec<ValueWord>| async move {
215            let url = args
216                .first()
217                .and_then(|a| a.as_str())
218                .map(|s| s.to_string())
219                .ok_or_else(|| "http.put() requires a URL string".to_string())?;
220
221            let mut builder = reqwest::Client::new().put(&url);
222
223            if let Some(body_arg) = args.get(1) {
224                if !body_arg.is_none() && !body_arg.is_unit() {
225                    if let Some(s) = body_arg.as_str() {
226                        builder = builder.body(s.to_string());
227                    } else {
228                        let json = body_arg.to_json_value();
229                        builder = builder
230                            .header("Content-Type", "application/json")
231                            .body(serde_json::to_string(&json).unwrap_or_default());
232                    }
233                }
234            }
235
236            if let Some(options) = args.get(2) {
237                for (k, v) in extract_headers(options) {
238                    builder = builder.header(&k, &v);
239                }
240                if let Some(timeout) = extract_timeout(options) {
241                    builder = builder.timeout(timeout);
242                }
243            }
244
245            let resp = builder
246                .send()
247                .await
248                .map_err(|e| format!("http.put() failed: {}", e))?;
249
250            let status = resp.status().as_u16();
251            let headers: Vec<(String, String)> = resp
252                .headers()
253                .iter()
254                .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
255                .collect();
256            let body = resp
257                .text()
258                .await
259                .map_err(|e| format!("http.put() body read failed: {}", e))?;
260
261            Ok(ValueWord::from_ok(build_response(status, headers, body)))
262        },
263        ModuleFunction {
264            description: "Perform an HTTP PUT request".to_string(),
265            params: vec![url_param.clone(), body_param, options_param.clone()],
266            return_type: Some("Result<HttpResponse>".to_string()),
267        },
268    );
269
270    // http.delete(url: string, options?: object) -> Result<HttpResponse>
271    module.add_async_function_with_schema(
272        "delete",
273        |args: Vec<ValueWord>| async move {
274            let url = args
275                .first()
276                .and_then(|a| a.as_str())
277                .map(|s| s.to_string())
278                .ok_or_else(|| "http.delete() requires a URL string".to_string())?;
279
280            let mut builder = reqwest::Client::new().delete(&url);
281
282            if let Some(options) = args.get(1) {
283                for (k, v) in extract_headers(options) {
284                    builder = builder.header(&k, &v);
285                }
286                if let Some(timeout) = extract_timeout(options) {
287                    builder = builder.timeout(timeout);
288                }
289            }
290
291            let resp = builder
292                .send()
293                .await
294                .map_err(|e| format!("http.delete() failed: {}", e))?;
295
296            let status = resp.status().as_u16();
297            let headers: Vec<(String, String)> = resp
298                .headers()
299                .iter()
300                .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
301                .collect();
302            let body = resp
303                .text()
304                .await
305                .map_err(|e| format!("http.delete() body read failed: {}", e))?;
306
307            Ok(ValueWord::from_ok(build_response(status, headers, body)))
308        },
309        ModuleFunction {
310            description: "Perform an HTTP DELETE request".to_string(),
311            params: vec![url_param, options_param],
312            return_type: Some("Result<HttpResponse>".to_string()),
313        },
314    );
315
316    module
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_http_module_creation() {
325        let module = create_http_module();
326        assert_eq!(module.name, "std::core::http");
327        assert!(module.has_export("get"));
328        assert!(module.has_export("post"));
329        assert!(module.has_export("put"));
330        assert!(module.has_export("delete"));
331    }
332
333    #[test]
334    fn test_http_all_async() {
335        let module = create_http_module();
336        assert!(module.is_async("get"));
337        assert!(module.is_async("post"));
338        assert!(module.is_async("put"));
339        assert!(module.is_async("delete"));
340    }
341
342    #[test]
343    fn test_http_schemas() {
344        let module = create_http_module();
345
346        let get_schema = module.get_schema("get").unwrap();
347        assert_eq!(get_schema.params.len(), 2);
348        assert_eq!(get_schema.params[0].name, "url");
349        assert!(get_schema.params[0].required);
350        assert!(!get_schema.params[1].required);
351        assert_eq!(
352            get_schema.return_type.as_deref(),
353            Some("Result<HttpResponse>")
354        );
355
356        let post_schema = module.get_schema("post").unwrap();
357        assert_eq!(post_schema.params.len(), 3);
358        assert_eq!(post_schema.params[1].name, "body");
359
360        let delete_schema = module.get_schema("delete").unwrap();
361        assert_eq!(delete_schema.params.len(), 2);
362    }
363
364    #[test]
365    fn test_build_response() {
366        let resp = build_response(
367            200,
368            vec![("content-type".to_string(), "text/html".to_string())],
369            "hello".to_string(),
370        );
371        let (keys, values, _) = resp.as_hashmap().expect("should be hashmap");
372
373        // Find status
374        let status_idx = keys
375            .iter()
376            .position(|k| k.as_str() == Some("status"))
377            .unwrap();
378        assert_eq!(values[status_idx].as_f64(), Some(200.0));
379
380        // Find ok
381        let ok_idx = keys.iter().position(|k| k.as_str() == Some("ok")).unwrap();
382        assert_eq!(values[ok_idx].as_bool(), Some(true));
383
384        // Find body
385        let body_idx = keys
386            .iter()
387            .position(|k| k.as_str() == Some("body"))
388            .unwrap();
389        assert_eq!(values[body_idx].as_str(), Some("hello"));
390
391        // Find headers
392        let headers_idx = keys
393            .iter()
394            .position(|k| k.as_str() == Some("headers"))
395            .unwrap();
396        assert!(values[headers_idx].as_hashmap().is_some());
397    }
398
399    #[test]
400    fn test_build_response_error_status() {
401        let resp = build_response(404, vec![], "not found".to_string());
402        let (keys, values, _) = resp.as_hashmap().expect("should be hashmap");
403
404        let ok_idx = keys.iter().position(|k| k.as_str() == Some("ok")).unwrap();
405        assert_eq!(values[ok_idx].as_bool(), Some(false));
406    }
407
408    #[test]
409    fn test_extract_headers_from_options() {
410        let hk = vec![ValueWord::from_string(Arc::new(
411            "Authorization".to_string(),
412        ))];
413        let hv = vec![ValueWord::from_string(Arc::new(
414            "Bearer token123".to_string(),
415        ))];
416        let headers_map = ValueWord::from_hashmap_pairs(hk, hv);
417
418        let ok = vec![ValueWord::from_string(Arc::new("headers".to_string()))];
419        let ov = vec![headers_map];
420        let options = ValueWord::from_hashmap_pairs(ok, ov);
421
422        let extracted = extract_headers(&options);
423        assert_eq!(extracted.len(), 1);
424        assert_eq!(extracted[0].0, "Authorization");
425        assert_eq!(extracted[0].1, "Bearer token123");
426    }
427
428    #[test]
429    fn test_extract_timeout_from_options() {
430        let ok = vec![ValueWord::from_string(Arc::new("timeout".to_string()))];
431        let ov = vec![ValueWord::from_f64(5000.0)];
432        let options = ValueWord::from_hashmap_pairs(ok, ov);
433
434        let timeout = extract_timeout(&options);
435        assert_eq!(timeout, Some(std::time::Duration::from_millis(5000)));
436    }
437
438    #[test]
439    fn test_extract_timeout_none() {
440        let options = ValueWord::empty_hashmap();
441        assert_eq!(extract_timeout(&options), None);
442    }
443}