Skip to main content

aver/services/
http.rs

1/// Http service — HTTP client built on `ureq`.
2///
3/// Exposes six methods mirroring the HTTP verb set:
4///   GET / HEAD / DELETE  — `Http.get(url)`, `Http.head(url)`, `Http.delete(url)`
5///   POST / PUT / PATCH   — `Http.post(url, body, contentType, headers)`, etc.
6///
7/// All methods require `! [Http]`. Responses are wrapped in `Ok(HttpResponse)`
8/// for any completed HTTP exchange (including 4xx/5xx). Transport failures return
9/// `Err(String)`. Response bodies are capped at 10 MB.
10use std::collections::HashMap;
11
12use crate::value::{RuntimeError, Value, list_from_vec, list_slice};
13
14pub fn register(global: &mut HashMap<String, Value>) {
15    let mut members = HashMap::new();
16    for method in &["get", "head", "delete", "post", "put", "patch"] {
17        members.insert(
18            method.to_string(),
19            Value::Builtin(format!("Http.{}", method)),
20        );
21    }
22    global.insert(
23        "Http".to_string(),
24        Value::Namespace {
25            name: "Http".to_string(),
26            members,
27        },
28    );
29}
30
31pub fn effects(name: &str) -> &'static [&'static str] {
32    match name {
33        "Http.get" | "Http.head" | "Http.delete" | "Http.post" | "Http.put" | "Http.patch" => {
34            &["Http"]
35        }
36        _ => &[],
37    }
38}
39
40/// Returns `Some(result)` when `name` is owned by this service, `None` otherwise.
41pub fn call(name: &str, args: &[Value]) -> Option<Result<Value, RuntimeError>> {
42    match name {
43        "Http.get" | "Http.head" | "Http.delete" => Some(call_simple(name, &args)),
44        "Http.post" | "Http.put" | "Http.patch" => Some(call_with_body(name, &args)),
45        _ => None,
46    }
47}
48
49// ─── Private helpers ──────────────────────────────────────────────────────────
50
51fn call_simple(name: &str, args: &[Value]) -> Result<Value, RuntimeError> {
52    if args.len() != 1 {
53        return Err(RuntimeError::Error(format!(
54            "Http.{}() takes 1 argument (url), got {}",
55            name.trim_start_matches("Http."),
56            args.len()
57        )));
58    }
59    let url = str_arg(&args[0], "Http: url must be a String")?;
60    let method = name.trim_start_matches("Http.").to_uppercase();
61    let result = ureq::request(&method, &url)
62        .timeout(std::time::Duration::from_secs(10))
63        .call();
64    response_value(result)
65}
66
67fn call_with_body(name: &str, args: &[Value]) -> Result<Value, RuntimeError> {
68    if args.len() != 4 {
69        return Err(RuntimeError::Error(format!(
70            "Http.{}() takes 4 arguments (url, body, contentType, headers), got {}",
71            name.trim_start_matches("Http."),
72            args.len()
73        )));
74    }
75    let url = str_arg(&args[0], "Http: url must be a String")?;
76    let body = str_arg(&args[1], "Http: body must be a String")?;
77    let content_type = str_arg(&args[2], "Http: contentType must be a String")?;
78    let extra_headers = parse_request_headers(&args[3])?;
79
80    let method = name.trim_start_matches("Http.").to_uppercase();
81    let mut req = ureq::request(&method, &url)
82        .timeout(std::time::Duration::from_secs(10))
83        .set("Content-Type", &content_type);
84    for (k, v) in &extra_headers {
85        req = req.set(k, v);
86    }
87    response_value(req.send_string(&body))
88}
89
90fn str_arg(val: &Value, msg: &str) -> Result<String, RuntimeError> {
91    match val {
92        Value::Str(s) => Ok(s.clone()),
93        _ => Err(RuntimeError::Error(msg.to_string())),
94    }
95}
96
97fn parse_request_headers(val: &Value) -> Result<Vec<(String, String)>, RuntimeError> {
98    let items = list_slice(val)
99        .ok_or_else(|| RuntimeError::Error("Http: headers must be a List".to_string()))?;
100    let mut out = Vec::new();
101    for item in items {
102        let fields = match item {
103            Value::Record { fields, .. } => fields,
104            _ => {
105                return Err(RuntimeError::Error(
106                    "Http: each header must be a record with 'name' and 'value' String fields"
107                        .to_string(),
108                ));
109            }
110        };
111        let get = |key: &str| -> Result<String, RuntimeError> {
112            fields
113                .iter()
114                .find(|(k, _)| k == key)
115                .and_then(|(_, v)| {
116                    if let Value::Str(s) = v {
117                        Some(s.clone())
118                    } else {
119                        None
120                    }
121                })
122                .ok_or_else(|| {
123                    RuntimeError::Error(format!(
124                        "Http: header record must have a '{}' String field",
125                        key
126                    ))
127                })
128        };
129        out.push((get("name")?, get("value")?));
130    }
131    Ok(out)
132}
133
134fn response_value(result: Result<ureq::Response, ureq::Error>) -> Result<Value, RuntimeError> {
135    match result {
136        Ok(resp) => build_response(resp),
137        Err(ureq::Error::Status(_, resp)) => build_response(resp),
138        Err(ureq::Error::Transport(e)) => Ok(Value::Err(Box::new(Value::Str(e.to_string())))),
139    }
140}
141
142fn build_response(resp: ureq::Response) -> Result<Value, RuntimeError> {
143    use std::io::Read;
144    let status = resp.status() as i64;
145    let header_names = resp.headers_names();
146    let headers: Vec<Value> = header_names
147        .iter()
148        .map(|name| {
149            let value = resp.header(name).unwrap_or("").to_string();
150            Value::Record {
151                type_name: "Header".to_string(),
152                fields: vec![
153                    ("name".to_string(), Value::Str(name.clone())),
154                    ("value".to_string(), Value::Str(value)),
155                ],
156            }
157        })
158        .collect();
159
160    const BODY_LIMIT: u64 = 10 * 1024 * 1024; // 10 MB
161    let mut buf = Vec::new();
162    let bytes_read = resp
163        .into_reader()
164        .take(BODY_LIMIT + 1)
165        .read_to_end(&mut buf)
166        .map_err(|e| RuntimeError::Error(format!("Http: failed to read response body: {}", e)))?;
167    if bytes_read as u64 > BODY_LIMIT {
168        return Ok(Value::Err(Box::new(Value::Str(
169            "Http: response body exceeds 10 MB limit".to_string(),
170        ))));
171    }
172    let body = String::from_utf8_lossy(&buf).into_owned();
173    Ok(Value::Ok(Box::new(Value::Record {
174        type_name: "HttpResponse".to_string(),
175        fields: vec![
176            ("status".to_string(), Value::Int(status)),
177            ("body".to_string(), Value::Str(body)),
178            ("headers".to_string(), list_from_vec(headers)),
179        ],
180    })))
181}