Skip to main content

assay/lua/builtins/http/
mod.rs

1use super::json::lua_table_to_json;
2use mlua::{Lua, Table, UserData, Value};
3use rand::RngExt;
4#[cfg(feature = "server")]
5mod server;
6#[cfg(feature = "server")]
7pub use server::LuaAxumRouter;
8
9struct HttpClient(reqwest::Client);
10impl UserData for HttpClient {}
11
12/// Registers `http.client(opts)` and the `http._client_request` shim that
13/// dispatches a call made on one of those client handles.
14fn register_client_handles(lua: &Lua, http_table: &Table) -> mlua::Result<()> {
15    let client_fn = lua.create_async_function(|lua, opts: Option<Table>| async move {
16        let mut builder = reqwest::Client::builder();
17
18        let timeout_secs: f64 = opts
19            .as_ref()
20            .and_then(|t| t.get::<f64>("timeout").ok())
21            .unwrap_or(30.0);
22        builder = builder.timeout(std::time::Duration::from_secs_f64(timeout_secs));
23
24        let follow_redirects: bool = opts
25            .as_ref()
26            .and_then(|t| t.get::<bool>("follow_redirects").ok())
27            .unwrap_or(true);
28        if !follow_redirects {
29            builder = builder.redirect(reqwest::redirect::Policy::none());
30        }
31
32        if let Some(ref opts_table) = opts {
33            if let Ok(ca_path) = opts_table.get::<String>("ca_cert_file") {
34                let pem = std::fs::read(&ca_path).map_err(|e| {
35                    mlua::Error::runtime(format!(
36                        "http.client: failed to read CA cert file {ca_path:?}: {e}"
37                    ))
38                })?;
39                let cert = reqwest::Certificate::from_pem(&pem).map_err(|e| {
40                    mlua::Error::runtime(format!("http.client: invalid PEM in {ca_path:?}: {e}"))
41                })?;
42                builder = builder.add_root_certificate(cert);
43            }
44            if let Ok(ca_pem) = opts_table.get::<String>("ca_cert") {
45                let cert = reqwest::Certificate::from_pem(ca_pem.as_bytes()).map_err(|e| {
46                    mlua::Error::runtime(format!("http.client: invalid CA cert PEM: {e}"))
47                })?;
48                builder = builder.add_root_certificate(cert);
49            }
50        }
51
52        let client = builder.build().map_err(|e| {
53            mlua::Error::runtime(format!("http.client: failed to build client: {e}"))
54        })?;
55
56        let ud = lua.create_any_userdata(HttpClient(client))?;
57
58        let wrapper: Table = lua
59            .load(
60                r#"
61                local ud = ...
62                local obj = { _ud = ud }
63                setmetatable(obj, {
64                    __index = {
65                        get = function(self, url, opts)
66                            return http._client_request(self._ud, "get", url, opts)
67                        end,
68                        post = function(self, url, body, opts)
69                            return http._client_request(self._ud, "post", url, body, opts)
70                        end,
71                        put = function(self, url, body, opts)
72                            return http._client_request(self._ud, "put", url, body, opts)
73                        end,
74                        patch = function(self, url, body, opts)
75                            return http._client_request(self._ud, "patch", url, body, opts)
76                        end,
77                        delete = function(self, url, opts)
78                            return http._client_request(self._ud, "delete", url, opts)
79                        end,
80                    }
81                })
82                return obj
83            "#,
84            )
85            .call(ud)?;
86
87        Ok(Value::Table(wrapper))
88    })?;
89    http_table.set("client", client_fn)?;
90
91    let client_request_fn =
92        lua.create_async_function(|lua, args: mlua::MultiValue| async move {
93            let mut args_iter = args.into_iter();
94
95            let client = match args_iter.next() {
96                Some(Value::UserData(ud)) => {
97                    let hc = ud.borrow::<HttpClient>().map_err(|_| {
98                        mlua::Error::runtime(
99                            "http._client_request: first arg must be an http client",
100                        )
101                    })?;
102                    hc.0.clone()
103                }
104                _ => {
105                    return Err(mlua::Error::runtime(
106                        "http._client_request: first arg must be an http client",
107                    ));
108                }
109            };
110
111            let method_name: String = match args_iter.next() {
112                Some(Value::String(s)) => s.to_str()?.to_string(),
113                _ => {
114                    return Err(mlua::Error::runtime(
115                        "http._client_request: second arg must be method name",
116                    ));
117                }
118            };
119
120            let remaining: mlua::MultiValue = args_iter.collect();
121            execute_http_request(&lua, &client, &method_name, remaining).await
122        })?;
123    http_table.set("_client_request", client_request_fn)?;
124    Ok(())
125}
126
127/// Registers `http.download(url, path, opts?)`.
128fn register_download(lua: &Lua, client: reqwest::Client, http_table: &Table) -> mlua::Result<()> {
129    let download_fn = lua.create_async_function(move |_, args: mlua::MultiValue| {
130        let client = client.clone();
131        async move {
132            use futures_util::StreamExt;
133            use tokio::io::AsyncWriteExt;
134
135            let mut args_iter = args.into_iter();
136            let url: String = match args_iter.next() {
137                Some(mlua::Value::String(s)) => s.to_str()?.to_string(),
138                _ => {
139                    return Err(mlua::Error::runtime(
140                        "http.download: first arg must be url string",
141                    ));
142                }
143            };
144            let path: String = match args_iter.next() {
145                Some(mlua::Value::String(s)) => s.to_str()?.to_string(),
146                _ => {
147                    return Err(mlua::Error::runtime(
148                        "http.download: second arg must be dest path string",
149                    ));
150                }
151            };
152            // Optional opts table: { headers = {...}, timeout = secs }
153            let opts: Option<mlua::Table> = match args_iter.next() {
154                Some(mlua::Value::Table(t)) => Some(t),
155                _ => None,
156            };
157
158            // Build request
159            let mut req = client.get(&url);
160            if let Some(ref t) = opts {
161                if let Ok(h) = t.get::<mlua::Table>("headers") {
162                    for pair in h.pairs::<String, String>() {
163                        let (k, v) = pair?;
164                        req = req.header(&k, &v);
165                    }
166                }
167                if let Ok(secs) = t.get::<f64>("timeout")
168                    && secs.is_finite()
169                    && secs > 0.0
170                {
171                    req = req.timeout(std::time::Duration::from_secs_f64(secs));
172                }
173            }
174
175            // Optional max_size cap. Defaults to 1 GiB so a malicious URL
176            // can't fill the disk. Caller can pass max_size = 0 to disable.
177            const DEFAULT_MAX_SIZE: i64 = 1024 * 1024 * 1024;
178            let max_size: i64 = opts
179                .as_ref()
180                .and_then(|t| t.get::<i64>("max_size").ok())
181                .unwrap_or(DEFAULT_MAX_SIZE);
182
183            // Ensure parent dir
184            if let Some(parent) = std::path::Path::new(&path).parent()
185                && !parent.as_os_str().is_empty()
186            {
187                tokio::fs::create_dir_all(parent).await.map_err(|e| {
188                    mlua::Error::runtime(format!("http.download: mkdir parent: {e}"))
189                })?;
190            }
191
192            // Open temp file at <path>.tmp.<random>. Random suffix instead of
193            // PID — a co-located unprivileged process can pre-create symlinks
194            // at predictable PID-based paths.
195            let tmp = format!("{path}.tmp.{:016x}", rand::rng().random::<u64>());
196            let mut file = tokio::fs::File::create(&tmp).await.map_err(|e| {
197                mlua::Error::runtime(format!("http.download: create temp {tmp:?}: {e}"))
198            })?;
199
200            // Cleanup helper closure result
201            let do_download = async {
202                let resp = req
203                    .send()
204                    .await
205                    .map_err(|e| mlua::Error::runtime(format!("http.download: request: {e}")))?;
206                if !resp.status().is_success() {
207                    return Err(mlua::Error::runtime(format!(
208                        "http.download: HTTP {} for {url}",
209                        resp.status()
210                    )));
211                }
212                let mut total: i64 = 0;
213                let mut stream = resp.bytes_stream();
214                while let Some(chunk) = stream.next().await {
215                    let bytes = chunk
216                        .map_err(|e| mlua::Error::runtime(format!("http.download: stream: {e}")))?;
217                    total += bytes.len() as i64;
218                    if max_size > 0 && total > max_size {
219                        return Err(mlua::Error::runtime(format!(
220                            "http.download: response exceeds max_size ({total} > {max_size} bytes) for {url}"
221                        )));
222                    }
223                    file.write_all(&bytes)
224                        .await
225                        .map_err(|e| mlua::Error::runtime(format!("http.download: write: {e}")))?;
226                }
227                file.flush()
228                    .await
229                    .map_err(|e| mlua::Error::runtime(format!("http.download: flush: {e}")))?;
230                file.sync_all()
231                    .await
232                    .map_err(|e| mlua::Error::runtime(format!("http.download: fsync: {e}")))?;
233                drop(file); // close before rename on Windows; harmless on Linux
234                Ok(total)
235            };
236
237            match do_download.await {
238                Ok(total) => {
239                    tokio::fs::rename(&tmp, &path).await.map_err(|e| {
240                        mlua::Error::runtime(format!(
241                            "http.download: rename {tmp:?} -> {path:?}: {e}"
242                        ))
243                    })?;
244                    Ok(total)
245                }
246                Err(e) => {
247                    let _ = tokio::fs::remove_file(&tmp).await;
248                    Err(e)
249                }
250            }
251        }
252    })?;
253    http_table.set("download", download_fn)?;
254    Ok(())
255}
256
257pub fn register_http(lua: &Lua, client: reqwest::Client) -> mlua::Result<()> {
258    let http_table = lua.create_table()?;
259
260    for method in ["get", "post", "put", "patch", "delete"] {
261        let method_client = client.clone();
262        let method_name = method.to_string();
263
264        let func = lua.create_async_function(move |lua, args: mlua::MultiValue| {
265            let client = method_client.clone();
266            let method_name = method_name.clone();
267            async move { execute_http_request(&lua, &client, &method_name, args).await }
268        })?;
269        http_table.set(method, func)?;
270    }
271
272    register_client_handles(lua, &http_table)?;
273
274    #[cfg(feature = "server")]
275    server::register_serve(lua, &http_table)?;
276
277    // http.download(url, path, opts?) -> bytes_written
278    // Streams the response body to disk via a temp file, then atomic-renames into place.
279    // Creates parent directories as needed. On any failure (4xx/5xx, IO error, network),
280    // the temp file is removed and the error propagates — no partial file at `path`.
281    register_download(lua, client, &http_table)?;
282
283    lua.globals().set("http", http_table)?;
284    Ok(())
285}
286
287/// Parses the Lua call shape into `(url, body, auto_json, opts)`.
288///
289/// `get`/`delete` have no body slot in their shorthand, so a body for those
290/// arrives via `opts.body` and is folded in here.
291fn parse_request_args(
292    method_name: &str,
293    args: mlua::MultiValue,
294) -> mlua::Result<(String, String, bool, Option<Table>)> {
295    let has_body = method_name != "get" && method_name != "delete";
296
297    let mut args_iter = args.into_iter();
298    let url: String = match args_iter.next() {
299        Some(Value::String(s)) => s.to_str()?.to_string(),
300        _ => {
301            return Err(mlua::Error::runtime(format!(
302                "http.{method_name}: first argument must be a URL string"
303            )));
304        }
305    };
306
307    let (mut body_str, mut auto_json, opts) = if has_body {
308        let (body, is_json) = match args_iter.next() {
309            Some(Value::String(s)) => (s.to_str()?.to_string(), false),
310            Some(Value::Table(t)) => {
311                let json_val = lua_table_to_json(&t)?;
312                let serialized = serde_json::to_string(&json_val).map_err(|e| {
313                    mlua::Error::runtime(format!("http.{method_name}: JSON encode failed: {e}"))
314                })?;
315                (serialized, true)
316            }
317            Some(Value::Nil) | None => (String::new(), false),
318            _ => {
319                return Err(mlua::Error::runtime(format!(
320                    "http.{method_name}: second argument must be a string, table, or nil"
321                )));
322            }
323        };
324        let opts = match args_iter.next() {
325            Some(Value::Table(t)) => Some(t),
326            Some(Value::Nil) | None => None,
327            _ => {
328                return Err(mlua::Error::runtime(format!(
329                    "http.{method_name}: third argument must be a table or nil"
330                )));
331            }
332        };
333        (body, is_json, opts)
334    } else {
335        let opts = match args_iter.next() {
336            Some(Value::Table(t)) => Some(t),
337            Some(Value::Nil) | None => None,
338            _ => {
339                return Err(mlua::Error::runtime(format!(
340                    "http.{method_name}: second argument must be a table or nil"
341                )));
342            }
343        };
344        (String::new(), false, opts)
345    };
346
347    // RFC 7231 permits a body on DELETE; some assay-* admin endpoints
348    // (e.g. `DELETE /admin/auth/zanzibar/tuples`) require a JSON body
349    // to identify which row to remove. The Lua DELETE shorthand only
350    // accepts `(url, opts)`, so we surface a body via `opts.body`
351    // (string OR table for auto-JSON). `Content-Type: application/json`
352    // is set automatically when a table is passed, mirroring `http.post`.
353    if !has_body
354        && let Some(ref opts_table) = opts
355        && let Ok(body_val) = opts_table.get::<Value>("body")
356    {
357        match body_val {
358            Value::String(s) => body_str = s.to_str()?.to_string(),
359            Value::Table(t) => {
360                let json_val = lua_table_to_json(&t)?;
361                let serialized = serde_json::to_string(&json_val).map_err(|e| {
362                    mlua::Error::runtime(format!("http.{method_name}: JSON encode failed: {e}"))
363                })?;
364                body_str = serialized;
365                auto_json = true;
366            }
367            Value::Nil => {}
368            _ => {
369                return Err(mlua::Error::runtime(format!(
370                    "http.{method_name}: opts.body must be a string, table, or nil"
371                )));
372            }
373        }
374    }
375
376    Ok((url, body_str, auto_json, opts))
377}
378
379fn build_request(
380    client: &reqwest::Client,
381    method_name: &str,
382    url: &str,
383    body_str: String,
384    auto_json: bool,
385    opts: Option<&Table>,
386) -> mlua::Result<reqwest::RequestBuilder> {
387    let mut req = match method_name {
388        "get" => client.get(url),
389        "post" => client.post(url),
390        "put" => client.put(url),
391        "patch" => client.patch(url),
392        "delete" => client.delete(url),
393        _ => {
394            return Err(mlua::Error::runtime(format!(
395                "http: unsupported method: {method_name}"
396            )));
397        }
398    };
399
400    if !body_str.is_empty() {
401        req = req.body(body_str);
402    }
403    if auto_json {
404        req = req.header("Content-Type", "application/json");
405    }
406    // Caller headers replace the runtime's rather than adding a second value.
407    // `RequestBuilder::header` appends, so a module naming `Content-Type` —
408    // the obvious thing to do, and what `assay.openstack` did — sent it twice
409    // and Keystone rejected the request. `headers` replaces per name.
410    if let Some(opts_table) = opts
411        && let Ok(headers_table) = opts_table.get::<Table>("headers")
412    {
413        let mut caller_headers = reqwest::header::HeaderMap::new();
414        for pair in headers_table.pairs::<String, String>() {
415            let (k, v) = pair?;
416            let name = reqwest::header::HeaderName::try_from(k.as_str()).map_err(|e| {
417                mlua::Error::runtime(format!(
418                    "http.{method_name}: invalid header name {k:?}: {e}"
419                ))
420            })?;
421            let value = reqwest::header::HeaderValue::try_from(v.as_str()).map_err(|e| {
422                mlua::Error::runtime(format!(
423                    "http.{method_name}: invalid value for header {k:?}: {e}"
424                ))
425            })?;
426            caller_headers.insert(name, value);
427        }
428        req = req.headers(caller_headers);
429    }
430
431    Ok(req)
432}
433
434fn headers_to_lua(lua: &Lua, headers: &reqwest::header::HeaderMap) -> mlua::Result<Table> {
435    let headers_out = lua.create_table()?;
436    for (name, value) in headers {
437        if let Ok(v) = value.to_str() {
438            headers_out.set(name.as_str().to_string(), v.to_string())?;
439        }
440    }
441    Ok(headers_out)
442}
443
444/// Drives an `text/event-stream` body, invoking `callback` per event until the
445/// stream ends or the callback answers `"close"`.
446async fn stream_sse_events(
447    lua: &Lua,
448    method_name: &str,
449    resp: reqwest::Response,
450    callback: mlua::Function,
451    result: Table,
452) -> mlua::Result<Value> {
453    {
454        let mut stream = resp.bytes_stream();
455        let mut buffer = String::new();
456
457        use futures_util::StreamExt;
458        while let Some(chunk) = stream.next().await {
459            let chunk = chunk.map_err(|e| {
460                mlua::Error::runtime(format!("http.{method_name}: SSE stream error: {e}"))
461            })?;
462            buffer.push_str(&String::from_utf8_lossy(&chunk));
463
464            // Parse complete SSE events (delimited by double newline)
465            while let Some(pos) = buffer.find("\n\n") {
466                let event_text = buffer[..pos].to_string();
467                buffer = buffer[pos + 2..].to_string();
468
469                if event_text.trim().is_empty() {
470                    continue;
471                }
472
473                let event_table = lua.create_table()?;
474                for line in event_text.lines() {
475                    if let Some(value) = line.strip_prefix("event: ") {
476                        event_table.set("event", value.to_string())?;
477                    } else if let Some(value) = line.strip_prefix("data: ") {
478                        event_table.set("data", value.to_string())?;
479                    } else if let Some(value) = line.strip_prefix("id: ") {
480                        event_table.set("id", value.to_string())?;
481                    } else if let Some(value) = line.strip_prefix("retry: ")
482                        && let Ok(ms) = value.parse::<i64>()
483                    {
484                        event_table.set("retry", ms)?;
485                    }
486                }
487
488                let action: Value = callback.call_async(Value::Table(event_table)).await?;
489                // If callback returns "close", stop streaming
490                if let Value::String(s) = &action
491                    && s.to_str()? == "close"
492                {
493                    return Ok(Value::Table(result));
494                }
495            }
496        }
497
498        Ok(Value::Table(result))
499    }
500}
501
502async fn execute_http_request(
503    lua: &Lua,
504    client: &reqwest::Client,
505    method_name: &str,
506    args: mlua::MultiValue,
507) -> mlua::Result<Value> {
508    let (url, body_str, auto_json, opts) = parse_request_args(method_name, args)?;
509    let req = build_request(
510        client,
511        method_name,
512        &url,
513        body_str,
514        auto_json,
515        opts.as_ref(),
516    )?;
517
518    let resp = req
519        .send()
520        .await
521        .map_err(|e| mlua::Error::runtime(format!("http.{method_name} failed: {e}")))?;
522    let status = resp.status().as_u16();
523    let resp_headers = resp.headers().clone();
524
525    let result = lua.create_table()?;
526    result.set("status", status)?;
527    result.set("headers", headers_to_lua(lua, &resp_headers)?)?;
528
529    let is_sse = resp_headers
530        .get("content-type")
531        .and_then(|v| v.to_str().ok())
532        .is_some_and(|ct| ct.contains("text/event-stream"));
533    let on_event_callback = opts
534        .as_ref()
535        .and_then(|o| o.get::<mlua::Function>("on_event").ok());
536
537    if let (true, Some(callback)) = (is_sse, on_event_callback) {
538        return stream_sse_events(lua, method_name, resp, callback, result).await;
539    }
540
541    // Buffer the full body as raw bytes (not `.text()`) so binary payloads —
542    // gzip/xz/zstd, images, tarballs — round-trip cleanly. Lua strings in mlua
543    // are byte buffers, so text-decoding callers are unaffected.
544    let body_bytes = resp.bytes().await.map_err(|e| {
545        mlua::Error::runtime(format!("http.{method_name}: reading body failed: {e}"))
546    })?;
547    result.set("body", lua.create_string(&body_bytes)?)?;
548
549    Ok(Value::Table(result))
550}