Skip to main content

tatara_lisp_script/stdlib/
http.rs

1//! HTTP client.
2//!
3//!   (http-get URL)                        → body string
4//!   (http-get URL HEADERS)                → body string
5//!   (http-get-json URL HEADERS)           → parsed JSON (nested Value tree)
6//!   (http-post-json URL HEADERS BODY)     → parsed JSON response
7//!
8//! HEADERS is a list of (KEY . VALUE) cons cells or `(KEY VALUE)` lists.
9//! Tatara-lisp's `(cons)` produces a 2-element list, so both shapes work.
10//! A typical Cloudflare call:
11//!
12//!   (http-get-json
13//!     "https://api.cloudflare.com/client/v4/zones?name=lilitu.io"
14//!     (list (list "Authorization" (string-append "Bearer " token))
15//!           (list "Content-Type" "application/json")))
16
17use std::sync::Arc;
18
19use tatara_lisp_eval::{Arity, EvalError, Interpreter, Value};
20
21use crate::script_ctx::ScriptCtx;
22use crate::stdlib::env::str_arg;
23use crate::stdlib::json::json_to_value;
24
25pub fn install(interp: &mut Interpreter<ScriptCtx>) {
26    interp.register_fn(
27        "http-get",
28        Arity::Range(1, 2),
29        |args: &[Value], ctx: &mut ScriptCtx, sp| {
30            let url = str_arg(&args[0], "http-get", sp)?;
31            let headers = headers_from_value(args.get(1), sp)?;
32            let body = do_get(ctx, &url, &headers, sp)?;
33            Ok(Value::Str(Arc::from(body)))
34        },
35    );
36
37    interp.register_fn(
38        "http-get-json",
39        Arity::Range(1, 2),
40        |args: &[Value], ctx: &mut ScriptCtx, sp| {
41            let url = str_arg(&args[0], "http-get-json", sp)?;
42            let headers = headers_from_value(args.get(1), sp)?;
43            let body = do_get(ctx, &url, &headers, sp)?;
44            let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
45                EvalError::native_fn(
46                    "http-get-json",
47                    format!("response not JSON: {e} (body={body:.200})"),
48                    sp,
49                )
50            })?;
51            Ok(json_to_value(&parsed))
52        },
53    );
54
55    interp.register_fn(
56        "http-post-json",
57        Arity::Exact(3),
58        |args: &[Value], ctx: &mut ScriptCtx, sp| {
59            let url = str_arg(&args[0], "http-post-json", sp)?;
60            let headers = headers_from_value(Some(&args[1]), sp)?;
61            let body_json = crate::stdlib::json::value_to_json(&args[2]);
62            let agent = ctx.http().clone();
63            let mut req = agent.post(&*url);
64            for (k, v) in &headers {
65                req = req.header(&**k, &**v);
66            }
67            let resp_body = req
68                .send(
69                    serde_json::to_string(&body_json)
70                        .map_err(|e| EvalError::native_fn("http-post-json", e.to_string(), sp))?,
71                )
72                .map_err(|e| EvalError::native_fn("http-post-json", e.to_string(), sp))?
73                .into_body()
74                .read_to_string()
75                .map_err(|e| EvalError::native_fn("http-post-json", e.to_string(), sp))?;
76            let parsed: serde_json::Value = serde_json::from_str(&resp_body).map_err(|e| {
77                EvalError::native_fn("http-post-json", format!("response not JSON: {e}"), sp)
78            })?;
79            Ok(json_to_value(&parsed))
80        },
81    );
82}
83
84fn do_get(
85    ctx: &mut ScriptCtx,
86    url: &str,
87    headers: &[(Arc<str>, Arc<str>)],
88    sp: tatara_lisp::Span,
89) -> Result<String, EvalError> {
90    let agent = ctx.http().clone();
91    let mut req = agent.get(url);
92    for (k, v) in headers {
93        req = req.header(&**k, &**v);
94    }
95    let resp = req
96        .call()
97        .map_err(|e| EvalError::native_fn("http-get", format!("{url}: {e}"), sp))?;
98    resp.into_body()
99        .read_to_string()
100        .map_err(|e| EvalError::native_fn("http-get", format!("{url}: {e}"), sp))
101}
102
103/// Accept headers as a list of 2-element lists: `((k1 v1) (k2 v2) ...)`.
104/// Empty / nil means no headers.
105fn headers_from_value(
106    v: Option<&Value>,
107    sp: tatara_lisp::Span,
108) -> Result<Vec<(Arc<str>, Arc<str>)>, EvalError> {
109    let Some(v) = v else { return Ok(vec![]) };
110    match v {
111        Value::Nil => Ok(vec![]),
112        Value::List(entries) => {
113            let mut out = Vec::with_capacity(entries.len());
114            for entry in entries.iter() {
115                let Value::List(pair) = entry else {
116                    return Err(EvalError::native_fn(
117                        "http",
118                        "header entry must be a (KEY VALUE) list",
119                        sp,
120                    ));
121                };
122                if pair.len() != 2 {
123                    return Err(EvalError::native_fn(
124                        "http",
125                        format!(
126                            "header entry must be (KEY VALUE), got {} elements",
127                            pair.len()
128                        ),
129                        sp,
130                    ));
131                }
132                let k = match &pair[0] {
133                    Value::Str(s) => s.clone(),
134                    Value::Symbol(s) | Value::Keyword(s) => s.clone(),
135                    other => {
136                        return Err(EvalError::native_fn(
137                            "http",
138                            format!(
139                                "header key must be string/symbol/keyword, got {}",
140                                other.type_name()
141                            ),
142                            sp,
143                        ))
144                    }
145                };
146                let v = match &pair[1] {
147                    Value::Str(s) => s.clone(),
148                    other => {
149                        return Err(EvalError::native_fn(
150                            "http",
151                            format!("header value must be string, got {}", other.type_name()),
152                            sp,
153                        ))
154                    }
155                };
156                out.push((k, v));
157            }
158            Ok(out)
159        }
160        other => Err(EvalError::native_fn(
161            "http",
162            format!("headers must be a list, got {}", other.type_name()),
163            sp,
164        )),
165    }
166}