Skip to main content

assay/lua/builtins/
http.rs

1use super::json::lua_table_to_json;
2#[cfg(feature = "server")]
3use super::json::lua_value_to_json;
4#[cfg(feature = "server")]
5use http_body_util::Full;
6#[cfg(feature = "server")]
7use hyper::body::{Bytes, Frame, Incoming};
8#[cfg(feature = "server")]
9use hyper::server::conn::http1;
10#[cfg(feature = "server")]
11use hyper::service::service_fn;
12#[cfg(feature = "server")]
13use hyper::{Request, Response, StatusCode};
14use mlua::{Lua, Table, UserData, Value};
15use rand::RngExt;
16#[cfg(feature = "server")]
17use std::cell::RefCell;
18#[cfg(feature = "server")]
19use std::collections::HashMap;
20#[cfg(feature = "server")]
21use std::pin::Pin;
22#[cfg(feature = "server")]
23use std::rc::Rc;
24#[cfg(feature = "server")]
25use std::task::{Context, Poll};
26#[cfg(feature = "server")]
27use tokio::net::TcpListener;
28#[cfg(feature = "server")]
29use tracing::error;
30
31/// Public newtype wrapping an [`axum::Router`] so it can round-trip through
32/// the Lua VM as a [`mlua::AnyUserData`].
33///
34/// Downstream binaries build a Rust-side `axum::Router` (typically
35/// holding `assay-engine` HTTP routes), wrap it in this type, stash it in
36/// a Lua global (or pass it positionally), and the Lua-defined
37/// `http.serve_with_extra(port, routes, extra)` builtin pulls the router
38/// back out and folds its routes into the dispatcher.
39///
40/// The type is intentionally a tuple-struct with a `pub` inner so callers
41/// can construct one trivially: `LuaAxumRouter(my_router)`.
42#[cfg(feature = "server")]
43#[derive(Clone)]
44pub struct LuaAxumRouter(pub axum::Router);
45
46#[cfg(feature = "server")]
47impl mlua::UserData for LuaAxumRouter {}
48
49struct HttpClient(reqwest::Client);
50impl UserData for HttpClient {}
51
52pub fn register_http(lua: &Lua, client: reqwest::Client) -> mlua::Result<()> {
53    let http_table = lua.create_table()?;
54
55    for method in ["get", "post", "put", "patch", "delete"] {
56        let method_client = client.clone();
57        let method_name = method.to_string();
58
59        let func = lua.create_async_function(move |lua, args: mlua::MultiValue| {
60            let client = method_client.clone();
61            let method_name = method_name.clone();
62            async move { execute_http_request(&lua, &client, &method_name, args).await }
63        })?;
64        http_table.set(method, func)?;
65    }
66
67    let client_fn = lua.create_async_function(|lua, opts: Option<Table>| async move {
68        let mut builder = reqwest::Client::builder();
69
70        let timeout_secs: f64 = opts
71            .as_ref()
72            .and_then(|t| t.get::<f64>("timeout").ok())
73            .unwrap_or(30.0);
74        builder = builder.timeout(std::time::Duration::from_secs_f64(timeout_secs));
75
76        let follow_redirects: bool = opts
77            .as_ref()
78            .and_then(|t| t.get::<bool>("follow_redirects").ok())
79            .unwrap_or(true);
80        if !follow_redirects {
81            builder = builder.redirect(reqwest::redirect::Policy::none());
82        }
83
84        if let Some(ref opts_table) = opts {
85            if let Ok(ca_path) = opts_table.get::<String>("ca_cert_file") {
86                let pem = std::fs::read(&ca_path).map_err(|e| {
87                    mlua::Error::runtime(format!(
88                        "http.client: failed to read CA cert file {ca_path:?}: {e}"
89                    ))
90                })?;
91                let cert = reqwest::Certificate::from_pem(&pem).map_err(|e| {
92                    mlua::Error::runtime(format!("http.client: invalid PEM in {ca_path:?}: {e}"))
93                })?;
94                builder = builder.add_root_certificate(cert);
95            }
96            if let Ok(ca_pem) = opts_table.get::<String>("ca_cert") {
97                let cert = reqwest::Certificate::from_pem(ca_pem.as_bytes()).map_err(|e| {
98                    mlua::Error::runtime(format!("http.client: invalid CA cert PEM: {e}"))
99                })?;
100                builder = builder.add_root_certificate(cert);
101            }
102        }
103
104        let client = builder.build().map_err(|e| {
105            mlua::Error::runtime(format!("http.client: failed to build client: {e}"))
106        })?;
107
108        let ud = lua.create_any_userdata(HttpClient(client))?;
109
110        let wrapper: Table = lua
111            .load(
112                r#"
113                local ud = ...
114                local obj = { _ud = ud }
115                setmetatable(obj, {
116                    __index = {
117                        get = function(self, url, opts)
118                            return http._client_request(self._ud, "get", url, opts)
119                        end,
120                        post = function(self, url, body, opts)
121                            return http._client_request(self._ud, "post", url, body, opts)
122                        end,
123                        put = function(self, url, body, opts)
124                            return http._client_request(self._ud, "put", url, body, opts)
125                        end,
126                        patch = function(self, url, body, opts)
127                            return http._client_request(self._ud, "patch", url, body, opts)
128                        end,
129                        delete = function(self, url, opts)
130                            return http._client_request(self._ud, "delete", url, opts)
131                        end,
132                    }
133                })
134                return obj
135            "#,
136            )
137            .call(ud)?;
138
139        Ok(Value::Table(wrapper))
140    })?;
141    http_table.set("client", client_fn)?;
142
143    let client_request_fn =
144        lua.create_async_function(|lua, args: mlua::MultiValue| async move {
145            let mut args_iter = args.into_iter();
146
147            let client = match args_iter.next() {
148                Some(Value::UserData(ud)) => {
149                    let hc = ud.borrow::<HttpClient>().map_err(|_| {
150                        mlua::Error::runtime(
151                            "http._client_request: first arg must be an http client",
152                        )
153                    })?;
154                    hc.0.clone()
155                }
156                _ => {
157                    return Err(mlua::Error::runtime(
158                        "http._client_request: first arg must be an http client",
159                    ));
160                }
161            };
162
163            let method_name: String = match args_iter.next() {
164                Some(Value::String(s)) => s.to_str()?.to_string(),
165                _ => {
166                    return Err(mlua::Error::runtime(
167                        "http._client_request: second arg must be method name",
168                    ));
169                }
170            };
171
172            let remaining: mlua::MultiValue = args_iter.collect();
173            execute_http_request(&lua, &client, &method_name, remaining).await
174        })?;
175    http_table.set("_client_request", client_request_fn)?;
176
177    #[cfg(feature = "server")]
178    let serve_fn = lua.create_async_function(|lua, args: mlua::MultiValue| async move {
179        let mut args_iter = args.into_iter();
180
181        let port: u16 = match args_iter.next() {
182            Some(Value::Integer(n)) => n as u16,
183            _ => {
184                return Err::<(), _>(mlua::Error::runtime(
185                    "http.serve: first argument must be a port number",
186                ));
187            }
188        };
189
190        let routes_table = match args_iter.next() {
191            Some(Value::Table(t)) => t,
192            _ => {
193                return Err::<(), _>(mlua::Error::runtime(
194                    "http.serve: second argument must be a routes table",
195                ));
196            }
197        };
198
199        let routes = Rc::new(parse_routes(&routes_table)?);
200
201        let listener = TcpListener::bind(format!("0.0.0.0:{port}"))
202            .await
203            .map_err(|e| mlua::Error::runtime(format!("http.serve: bind failed: {e}")))?;
204
205        // Expose the actual bound port so callers using port 0 can discover it
206        let actual_port = listener
207            .local_addr()
208            .map_err(|e| {
209                mlua::Error::runtime(format!("http.serve: failed to get local addr: {e}"))
210            })?
211            .port();
212        lua.globals().set("_SERVER_PORT", actual_port)?;
213
214        loop {
215            let (stream, addr) = listener
216                .accept()
217                .await
218                .map_err(|e| mlua::Error::runtime(format!("http.serve: accept failed: {e}")))?;
219            let peer_addr = addr.to_string();
220
221            let routes = routes.clone();
222            let lua_clone = lua.clone();
223
224            tokio::task::spawn_local(async move {
225                let io = hyper_util::rt::TokioIo::new(stream);
226                let routes = routes.clone();
227                let lua = lua_clone.clone();
228                let peer_addr = peer_addr.clone();
229
230                let service = service_fn(move |req: Request<Incoming>| {
231                    let routes = routes.clone();
232                    let lua = lua.clone();
233                    let peer_addr = peer_addr.clone();
234                    async move { handle_request(&lua, &routes, None, peer_addr, req).await }
235                });
236
237                if let Err(e) = http1::Builder::new()
238                    .serve_connection(io, service)
239                    .with_upgrades()
240                    .await
241                    && !e.to_string().contains("connection closed")
242                {
243                    error!("http.serve: connection error: {e}");
244                }
245            });
246        }
247    })?;
248    #[cfg(feature = "server")]
249    http_table.set("serve", serve_fn)?;
250
251    // ── http.serve_with_extra(port, routes_table, extra_router) ───────────────
252    //
253    // Same shape as `http.serve` plus a third argument: a [`LuaAxumRouter`]
254    // userdata wrapping a Rust-built `axum::Router`. Lua-defined routes are
255    // matched first; on miss, the request is forwarded to the extra
256    // `axum::Router` (which can produce 404 itself if it doesn't match either).
257    //
258    // Precedence: Lua wins. If the same path is defined by both, the Lua
259    // handler is invoked. This is the inverse of `axum::Router::merge` (where
260    // a duplicate panics) and was chosen because the Lua side is the
261    // existing surface — the extra router is purely additive routes the
262    // host binary contributes (typically engine APIs under a non-overlapping
263    // path prefix like `/api/v1/engine/*`).
264    //
265    // The extra router is cloned per-connection (`axum::Router: Clone` is a
266    // shallow `Arc` clone — cheap).
267    #[cfg(feature = "server")]
268    let serve_with_extra_fn =
269        lua.create_async_function(|lua, args: mlua::MultiValue| async move {
270            let mut args_iter = args.into_iter();
271
272            let port: u16 = match args_iter.next() {
273                Some(Value::Integer(n)) => n as u16,
274                _ => {
275                    return Err::<(), _>(mlua::Error::runtime(
276                        "http.serve_with_extra: first argument must be a port number",
277                    ));
278                }
279            };
280
281            let routes_table = match args_iter.next() {
282                Some(Value::Table(t)) => t,
283                _ => {
284                    return Err::<(), _>(mlua::Error::runtime(
285                        "http.serve_with_extra: second argument must be a routes table",
286                    ));
287                }
288            };
289
290            let extra_router: axum::Router = match args_iter.next() {
291                Some(Value::UserData(ud)) => {
292                    let r = ud.borrow::<LuaAxumRouter>().map_err(|_| {
293                    mlua::Error::runtime(
294                        "http.serve_with_extra: third argument must be a LuaAxumRouter userdata",
295                    )
296                })?;
297                    r.0.clone()
298                }
299                _ => {
300                    return Err::<(), _>(mlua::Error::runtime(
301                        "http.serve_with_extra: third argument must be a LuaAxumRouter userdata",
302                    ));
303                }
304            };
305
306            let routes = Rc::new(parse_routes(&routes_table)?);
307
308            let listener = TcpListener::bind(format!("0.0.0.0:{port}"))
309                .await
310                .map_err(|e| {
311                    mlua::Error::runtime(format!("http.serve_with_extra: bind failed: {e}"))
312                })?;
313
314            let actual_port = listener
315                .local_addr()
316                .map_err(|e| {
317                    mlua::Error::runtime(format!(
318                        "http.serve_with_extra: failed to get local addr: {e}"
319                    ))
320                })?
321                .port();
322            lua.globals().set("_SERVER_PORT", actual_port)?;
323
324            loop {
325                let (stream, addr) = listener.accept().await.map_err(|e| {
326                    mlua::Error::runtime(format!("http.serve_with_extra: accept failed: {e}"))
327                })?;
328                let peer_addr = addr.to_string();
329
330                let routes = routes.clone();
331                let lua_clone = lua.clone();
332                let extra_router = extra_router.clone();
333
334                tokio::task::spawn_local(async move {
335                    let io = hyper_util::rt::TokioIo::new(stream);
336                    let routes = routes.clone();
337                    let lua = lua_clone.clone();
338                    let peer_addr = peer_addr.clone();
339                    let extra_router = extra_router.clone();
340
341                    let service = service_fn(move |req: Request<Incoming>| {
342                        let routes = routes.clone();
343                        let lua = lua.clone();
344                        let peer_addr = peer_addr.clone();
345                        let extra_router = extra_router.clone();
346                        async move {
347                            handle_request(&lua, &routes, Some(extra_router), peer_addr, req).await
348                        }
349                    });
350
351                    if let Err(e) = http1::Builder::new()
352                        .serve_connection(io, service)
353                        .with_upgrades()
354                        .await
355                        && !e.to_string().contains("connection closed")
356                    {
357                        error!("http.serve_with_extra: connection error: {e}");
358                    }
359                });
360            }
361        })?;
362    #[cfg(feature = "server")]
363    http_table.set("serve_with_extra", serve_with_extra_fn)?;
364
365    // http.download(url, path, opts?) -> bytes_written
366    // Streams the response body to disk via a temp file, then atomic-renames into place.
367    // Creates parent directories as needed. On any failure (4xx/5xx, IO error, network),
368    // the temp file is removed and the error propagates — no partial file at `path`.
369    let download_client = client.clone();
370    let download_fn = lua.create_async_function(move |_, args: mlua::MultiValue| {
371        let client = download_client.clone();
372        async move {
373            use futures_util::StreamExt;
374            use tokio::io::AsyncWriteExt;
375
376            let mut args_iter = args.into_iter();
377            let url: String = match args_iter.next() {
378                Some(mlua::Value::String(s)) => s.to_str()?.to_string(),
379                _ => {
380                    return Err(mlua::Error::runtime(
381                        "http.download: first arg must be url string",
382                    ));
383                }
384            };
385            let path: String = match args_iter.next() {
386                Some(mlua::Value::String(s)) => s.to_str()?.to_string(),
387                _ => {
388                    return Err(mlua::Error::runtime(
389                        "http.download: second arg must be dest path string",
390                    ));
391                }
392            };
393            // Optional opts table: { headers = {...}, timeout = secs }
394            let opts: Option<mlua::Table> = match args_iter.next() {
395                Some(mlua::Value::Table(t)) => Some(t),
396                _ => None,
397            };
398
399            // Build request
400            let mut req = client.get(&url);
401            if let Some(ref t) = opts {
402                if let Ok(h) = t.get::<mlua::Table>("headers") {
403                    for pair in h.pairs::<String, String>() {
404                        let (k, v) = pair?;
405                        req = req.header(&k, &v);
406                    }
407                }
408                if let Ok(secs) = t.get::<f64>("timeout")
409                    && secs.is_finite()
410                    && secs > 0.0
411                {
412                    req = req.timeout(std::time::Duration::from_secs_f64(secs));
413                }
414            }
415
416            // Optional max_size cap. Defaults to 1 GiB so a malicious URL
417            // can't fill the disk. Caller can pass max_size = 0 to disable.
418            const DEFAULT_MAX_SIZE: i64 = 1024 * 1024 * 1024;
419            let max_size: i64 = opts
420                .as_ref()
421                .and_then(|t| t.get::<i64>("max_size").ok())
422                .unwrap_or(DEFAULT_MAX_SIZE);
423
424            // Ensure parent dir
425            if let Some(parent) = std::path::Path::new(&path).parent()
426                && !parent.as_os_str().is_empty()
427            {
428                tokio::fs::create_dir_all(parent).await.map_err(|e| {
429                    mlua::Error::runtime(format!("http.download: mkdir parent: {e}"))
430                })?;
431            }
432
433            // Open temp file at <path>.tmp.<random>. Random suffix instead of
434            // PID — a co-located unprivileged process can pre-create symlinks
435            // at predictable PID-based paths.
436            let tmp = format!("{path}.tmp.{:016x}", rand::rng().random::<u64>());
437            let mut file = tokio::fs::File::create(&tmp).await.map_err(|e| {
438                mlua::Error::runtime(format!("http.download: create temp {tmp:?}: {e}"))
439            })?;
440
441            // Cleanup helper closure result
442            let do_download = async {
443                let resp = req
444                    .send()
445                    .await
446                    .map_err(|e| mlua::Error::runtime(format!("http.download: request: {e}")))?;
447                if !resp.status().is_success() {
448                    return Err(mlua::Error::runtime(format!(
449                        "http.download: HTTP {} for {url}",
450                        resp.status()
451                    )));
452                }
453                let mut total: i64 = 0;
454                let mut stream = resp.bytes_stream();
455                while let Some(chunk) = stream.next().await {
456                    let bytes = chunk
457                        .map_err(|e| mlua::Error::runtime(format!("http.download: stream: {e}")))?;
458                    total += bytes.len() as i64;
459                    if max_size > 0 && total > max_size {
460                        return Err(mlua::Error::runtime(format!(
461                            "http.download: response exceeds max_size ({total} > {max_size} bytes) for {url}"
462                        )));
463                    }
464                    file.write_all(&bytes)
465                        .await
466                        .map_err(|e| mlua::Error::runtime(format!("http.download: write: {e}")))?;
467                }
468                file.flush()
469                    .await
470                    .map_err(|e| mlua::Error::runtime(format!("http.download: flush: {e}")))?;
471                file.sync_all()
472                    .await
473                    .map_err(|e| mlua::Error::runtime(format!("http.download: fsync: {e}")))?;
474                drop(file); // close before rename on Windows; harmless on Linux
475                Ok(total)
476            };
477
478            match do_download.await {
479                Ok(total) => {
480                    tokio::fs::rename(&tmp, &path).await.map_err(|e| {
481                        mlua::Error::runtime(format!(
482                            "http.download: rename {tmp:?} -> {path:?}: {e}"
483                        ))
484                    })?;
485                    Ok(total)
486                }
487                Err(e) => {
488                    let _ = tokio::fs::remove_file(&tmp).await;
489                    Err(e)
490                }
491            }
492        }
493    })?;
494    http_table.set("download", download_fn)?;
495
496    lua.globals().set("http", http_table)?;
497    Ok(())
498}
499
500async fn execute_http_request(
501    lua: &Lua,
502    client: &reqwest::Client,
503    method_name: &str,
504    args: mlua::MultiValue,
505) -> mlua::Result<Value> {
506    let has_body = method_name != "get" && method_name != "delete";
507
508    let mut args_iter = args.into_iter();
509    let url: String = match args_iter.next() {
510        Some(Value::String(s)) => s.to_str()?.to_string(),
511        _ => {
512            return Err(mlua::Error::runtime(format!(
513                "http.{method_name}: first argument must be a URL string"
514            )));
515        }
516    };
517
518    let (mut body_str, mut auto_json, opts) = if has_body {
519        let (body, is_json) = match args_iter.next() {
520            Some(Value::String(s)) => (s.to_str()?.to_string(), false),
521            Some(Value::Table(t)) => {
522                let json_val = lua_table_to_json(&t)?;
523                let serialized = serde_json::to_string(&json_val).map_err(|e| {
524                    mlua::Error::runtime(format!("http.{method_name}: JSON encode failed: {e}"))
525                })?;
526                (serialized, true)
527            }
528            Some(Value::Nil) | None => (String::new(), false),
529            _ => {
530                return Err(mlua::Error::runtime(format!(
531                    "http.{method_name}: second argument must be a string, table, or nil"
532                )));
533            }
534        };
535        let opts = match args_iter.next() {
536            Some(Value::Table(t)) => Some(t),
537            Some(Value::Nil) | None => None,
538            _ => {
539                return Err(mlua::Error::runtime(format!(
540                    "http.{method_name}: third argument must be a table or nil"
541                )));
542            }
543        };
544        (body, is_json, opts)
545    } else {
546        let opts = match args_iter.next() {
547            Some(Value::Table(t)) => Some(t),
548            Some(Value::Nil) | None => None,
549            _ => {
550                return Err(mlua::Error::runtime(format!(
551                    "http.{method_name}: second argument must be a table or nil"
552                )));
553            }
554        };
555        (String::new(), false, opts)
556    };
557
558    // RFC 7231 permits a body on DELETE; some assay-* admin endpoints
559    // (e.g. `DELETE /admin/auth/zanzibar/tuples`) require a JSON body
560    // to identify which row to remove. The Lua DELETE shorthand only
561    // accepts `(url, opts)`, so we surface a body via `opts.body`
562    // (string OR table for auto-JSON). `Content-Type: application/json`
563    // is set automatically when a table is passed, mirroring `http.post`.
564    if !has_body
565        && let Some(ref opts_table) = opts
566        && let Ok(body_val) = opts_table.get::<Value>("body")
567    {
568        match body_val {
569            Value::String(s) => body_str = s.to_str()?.to_string(),
570            Value::Table(t) => {
571                let json_val = lua_table_to_json(&t)?;
572                let serialized = serde_json::to_string(&json_val).map_err(|e| {
573                    mlua::Error::runtime(format!("http.{method_name}: JSON encode failed: {e}"))
574                })?;
575                body_str = serialized;
576                auto_json = true;
577            }
578            Value::Nil => {}
579            _ => {
580                return Err(mlua::Error::runtime(format!(
581                    "http.{method_name}: opts.body must be a string, table, or nil"
582                )));
583            }
584        }
585    }
586
587    let mut req = match method_name {
588        "get" => client.get(&url),
589        "post" => client.post(&url),
590        "put" => client.put(&url),
591        "patch" => client.patch(&url),
592        "delete" => client.delete(&url),
593        _ => {
594            return Err(mlua::Error::runtime(format!(
595                "http: unsupported method: {method_name}"
596            )));
597        }
598    };
599
600    if !body_str.is_empty() {
601        req = req.body(body_str);
602    }
603    if auto_json {
604        req = req.header("Content-Type", "application/json");
605    }
606    if let Some(ref opts_table) = opts
607        && let Ok(headers_table) = opts_table.get::<Table>("headers")
608    {
609        for pair in headers_table.pairs::<String, String>() {
610            let (k, v) = pair?;
611            req = req.header(k, v);
612        }
613    }
614
615    let resp = req
616        .send()
617        .await
618        .map_err(|e| mlua::Error::runtime(format!("http.{method_name} failed: {e}")))?;
619    let status = resp.status().as_u16();
620    let resp_headers = resp.headers().clone();
621
622    // Check for SSE: Content-Type text/event-stream + on_event callback
623    let is_sse = resp_headers
624        .get("content-type")
625        .and_then(|v| v.to_str().ok())
626        .is_some_and(|ct| ct.contains("text/event-stream"));
627
628    let on_event_callback = opts
629        .as_ref()
630        .and_then(|o| o.get::<mlua::Function>("on_event").ok());
631
632    if let (true, Some(callback)) = (is_sse, on_event_callback) {
633        let result = lua.create_table()?;
634        result.set("status", status)?;
635        let headers_out = lua.create_table()?;
636        for (name, value) in &resp_headers {
637            if let Ok(v) = value.to_str() {
638                headers_out.set(name.as_str().to_string(), v.to_string())?;
639            }
640        }
641        result.set("headers", headers_out)?;
642
643        // Stream SSE events to the callback
644        let mut stream = resp.bytes_stream();
645        let mut buffer = String::new();
646
647        use futures_util::StreamExt;
648        while let Some(chunk) = stream.next().await {
649            let chunk = chunk.map_err(|e| {
650                mlua::Error::runtime(format!("http.{method_name}: SSE stream error: {e}"))
651            })?;
652            buffer.push_str(&String::from_utf8_lossy(&chunk));
653
654            // Parse complete SSE events (delimited by double newline)
655            while let Some(pos) = buffer.find("\n\n") {
656                let event_text = buffer[..pos].to_string();
657                buffer = buffer[pos + 2..].to_string();
658
659                if event_text.trim().is_empty() {
660                    continue;
661                }
662
663                let event_table = lua.create_table()?;
664                for line in event_text.lines() {
665                    if let Some(value) = line.strip_prefix("event: ") {
666                        event_table.set("event", value.to_string())?;
667                    } else if let Some(value) = line.strip_prefix("data: ") {
668                        event_table.set("data", value.to_string())?;
669                    } else if let Some(value) = line.strip_prefix("id: ") {
670                        event_table.set("id", value.to_string())?;
671                    } else if let Some(value) = line.strip_prefix("retry: ")
672                        && let Ok(ms) = value.parse::<i64>()
673                    {
674                        event_table.set("retry", ms)?;
675                    }
676                }
677
678                let action: Value = callback.call_async(Value::Table(event_table)).await?;
679                // If callback returns "close", stop streaming
680                if let Value::String(s) = &action
681                    && s.to_str()? == "close"
682                {
683                    return Ok(Value::Table(result));
684                }
685            }
686        }
687
688        return Ok(Value::Table(result));
689    }
690
691    // Standard response: buffer full body. Use raw bytes (not .text()) so binary
692    // payloads — gzip/xz/zstd, images, tarballs — round-trip cleanly. Lua strings
693    // in mlua are byte buffers, so this remains compatible with existing
694    // text-decoding callers.
695    let body_bytes = resp.bytes().await.map_err(|e| {
696        mlua::Error::runtime(format!("http.{method_name}: reading body failed: {e}"))
697    })?;
698    let body = lua.create_string(&body_bytes)?;
699
700    let result = lua.create_table()?;
701    result.set("status", status)?;
702    result.set("body", body)?;
703
704    let headers_out = lua.create_table()?;
705    for (name, value) in &resp_headers {
706        if let Ok(v) = value.to_str() {
707            headers_out.set(name.as_str().to_string(), v.to_string())?;
708        }
709    }
710    result.set("headers", headers_out)?;
711
712    Ok(Value::Table(result))
713}
714
715/// A streaming body backed by an mpsc channel, used for SSE responses.
716#[cfg(feature = "server")]
717struct SseBody {
718    rx: tokio::sync::mpsc::Receiver<Bytes>,
719}
720
721#[cfg(feature = "server")]
722impl hyper::body::Body for SseBody {
723    type Data = Bytes;
724    type Error = std::convert::Infallible;
725
726    fn poll_frame(
727        mut self: Pin<&mut Self>,
728        cx: &mut Context<'_>,
729    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
730        match self.rx.poll_recv(cx) {
731            Poll::Ready(Some(bytes)) => Poll::Ready(Some(Ok(Frame::data(bytes)))),
732            Poll::Ready(None) => Poll::Ready(None),
733            Poll::Pending => Poll::Pending,
734        }
735    }
736}
737
738/// Format a Lua table with optional `event`, `data`, `id`, `retry` fields into an SSE text block.
739#[cfg(feature = "server")]
740fn format_sse_event(event_table: &Table) -> mlua::Result<String> {
741    let mut out = String::new();
742
743    if let Ok(Some(event)) = event_table.get::<Option<String>>("event") {
744        if event.contains('\n') || event.contains('\r') {
745            return Err(mlua::Error::runtime(
746                "SSE event name must not contain newlines",
747            ));
748        }
749        out.push_str("event: ");
750        out.push_str(&event);
751        out.push('\n');
752    }
753    if let Ok(Some(data)) = event_table.get::<Option<String>>("data") {
754        // SSE spec: each line of data gets its own "data:" prefix
755        for line in data.split('\n') {
756            out.push_str("data: ");
757            out.push_str(line);
758            out.push('\n');
759        }
760    }
761    if let Ok(Some(id)) = event_table.get::<Option<String>>("id") {
762        if id.contains('\n') || id.contains('\r') {
763            return Err(mlua::Error::runtime("SSE id must not contain newlines"));
764        }
765        out.push_str("id: ");
766        out.push_str(&id);
767        out.push('\n');
768    }
769    if let Ok(Some(retry)) = event_table.get::<Option<i64>>("retry") {
770        out.push_str("retry: ");
771        out.push_str(&retry.to_string());
772        out.push('\n');
773    }
774
775    // SSE events are terminated by a blank line
776    out.push('\n');
777    Ok(out)
778}
779
780#[cfg(feature = "server")]
781fn parse_routes(routes_table: &Table) -> mlua::Result<HashMap<(String, String), mlua::Function>> {
782    let mut routes = HashMap::new();
783    for method_pair in routes_table.pairs::<String, Table>() {
784        let (method, paths_table) = method_pair?;
785        let method_upper = method.to_uppercase();
786        for path_pair in paths_table.pairs::<String, mlua::Function>() {
787            let (path, func) = path_pair?;
788            routes.insert((method_upper.clone(), path), func);
789        }
790    }
791    Ok(routes)
792}
793
794/// Unified response body type.
795///
796/// Originally `Either<Full<Bytes>, SseBody>`; widened to [`axum::body::Body`]
797/// so we can transparently forward responses produced by an external
798/// `axum::Router` (see `http.serve_with_extra`). `axum::body::Body` is a
799/// thin wrapper over `UnsyncBoxBody<Bytes, axum::Error>` and accepts any
800/// `http_body::Body<Data = Bytes>` via [`axum::body::Body::new`], so all
801/// existing body shapes (static `Full<Bytes>`, the SSE channel body) still
802/// fit; only the construction surface changes.
803#[cfg(feature = "server")]
804type ServerBody = axum::body::Body;
805
806#[cfg(feature = "server")]
807fn lookup_route<'a>(
808    routes: &'a HashMap<(String, String), mlua::Function>,
809    method: &str,
810    path: &str,
811) -> Option<&'a mlua::Function> {
812    let key = (method.to_string(), path.to_string());
813    if let Some(f) = routes.get(&key) {
814        return Some(f);
815    }
816    let mut search = path;
817    while let Some(pos) = search.rfind('/') {
818        let prefix = &search[..pos];
819        let wildcard_key = (method.to_string(), format!("{prefix}/*"));
820        if let Some(f) = routes.get(&wildcard_key) {
821            return Some(f);
822        }
823        if pos == 0 {
824            let root_key = (method.to_string(), "/*".to_string());
825            return routes.get(&root_key);
826        }
827        search = prefix;
828    }
829    None
830}
831
832#[cfg(feature = "server")]
833fn is_websocket_upgrade(headers: &[(String, String)]) -> bool {
834    headers.iter().any(|(k, v)| {
835        k.eq_ignore_ascii_case("upgrade") && v.to_ascii_lowercase().contains("websocket")
836    })
837}
838
839#[cfg(feature = "server")]
840fn validate_ws_request(headers: &[(String, String)]) -> Result<String, &'static str> {
841    let mut has_connection_upgrade = false;
842    let mut version_ok = false;
843    let mut key: Option<String> = None;
844    for (k, v) in headers {
845        match k.to_ascii_lowercase().as_str() {
846            "connection" if v.to_ascii_lowercase().contains("upgrade") => {
847                has_connection_upgrade = true;
848            }
849            "sec-websocket-version" if v.trim() == "13" => {
850                version_ok = true;
851            }
852            "sec-websocket-key" => {
853                key = Some(v.clone());
854            }
855            _ => {}
856        }
857    }
858    if !has_connection_upgrade {
859        return Err("missing Connection: Upgrade header");
860    }
861    if !version_ok {
862        return Err("Sec-WebSocket-Version must be 13");
863    }
864    key.ok_or("missing Sec-WebSocket-Key header")
865}
866
867#[cfg(feature = "server")]
868fn compute_ws_accept(key: &str) -> String {
869    use sha1::Digest;
870    const MAGIC: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
871    let mut hasher = sha1::Sha1::new();
872    hasher.update(key.as_bytes());
873    hasher.update(MAGIC);
874    let digest = hasher.finalize();
875    data_encoding::BASE64.encode(&digest)
876}
877
878/// Forward a `hyper` request into an `axum::Router` (used as a [`tower::Service`])
879/// and return its response.
880///
881/// `axum::Router` implements `Service<Request<B>>` for any `B` that is an
882/// `HttpBody<Data = Bytes>`, which `hyper::body::Incoming` is. The router's
883/// response body is `axum::body::Body`, which is exactly our unified
884/// [`ServerBody`].
885///
886/// The router's `Service::call` signature is `Infallible`, so the only way
887/// this can fail is if there's a `hyper::Error` upstream — there isn't —
888/// hence the `unreachable!()` on the error branch.
889#[cfg(feature = "server")]
890async fn forward_to_axum_router(
891    mut router: axum::Router,
892    req: Request<Incoming>,
893) -> Result<Response<ServerBody>, hyper::Error> {
894    use tower::Service;
895    // `axum::Router::poll_ready` is `Poll::Ready(Ok(()))`, so we can call
896    // straight through without driving readiness. We rely on the
897    // `Service<Request<B>>` impl (not the `Service<IncomingStream>` one)
898    // which is selected by the `Request<Incoming>` argument type.
899    match <axum::Router as Service<Request<Incoming>>>::call(&mut router, req).await {
900        Ok(resp) => Ok(resp),
901        Err(_) => unreachable!("axum::Router::call is Infallible"),
902    }
903}
904
905#[cfg(feature = "server")]
906async fn handle_request(
907    lua: &Lua,
908    routes: &HashMap<(String, String), mlua::Function>,
909    extra_router: Option<axum::Router>,
910    peer_addr: String,
911    req: Request<Incoming>,
912) -> Result<Response<ServerBody>, hyper::Error> {
913    let method = req.method().to_string();
914    let path = req.uri().path().to_string();
915    let query = req.uri().query().unwrap_or("").to_string();
916    let headers: Vec<(String, String)> = req
917        .headers()
918        .iter()
919        .filter_map(|(k, v)| v.to_str().ok().map(|v| (k.to_string(), v.to_string())))
920        .collect();
921
922    let is_ws = is_websocket_upgrade(&headers);
923
924    let handler = match lookup_route(routes, &method, &path) {
925        Some(h) => h.clone(),
926        None => {
927            // Lua dispatch missed. If an extra `axum::Router` was supplied via
928            // `http.serve_with_extra`, hand the request off to it so its routes
929            // (typically Rust-built, e.g. `assay-engine`'s `/api/v1/engine/*`)
930            // can produce the response. Otherwise, fall back to a 404.
931            if let Some(router) = extra_router {
932                return forward_to_axum_router(router, req).await;
933            }
934            return Ok(Response::builder()
935                .status(StatusCode::NOT_FOUND)
936                .header("content-type", "text/plain")
937                .body(axum::body::Body::new(Full::new(Bytes::from("not found"))))
938                .unwrap());
939        }
940    };
941
942    if is_ws {
943        let lua_resp =
944            match build_lua_request_and_call(lua, &handler, &method, &path, &query, &headers, "")
945                .await
946            {
947                Ok(t) => t,
948                Err(e) => {
949                    return Ok(Response::builder()
950                        .status(StatusCode::INTERNAL_SERVER_ERROR)
951                        .header("content-type", "text/plain")
952                        .body(axum::body::Body::new(Full::new(Bytes::from(format!(
953                            "handler error: {e}"
954                        )))))
955                        .unwrap());
956                }
957            };
958
959        if let Ok(Some(ws_fn)) = lua_resp.get::<Option<mlua::Function>>("ws") {
960            return build_ws_upgrade_response(lua, &headers, lua_resp, ws_fn, peer_addr, req);
961        }
962
963        return lua_response_to_http(lua, &lua_resp);
964    }
965
966    let body_bytes = match http_body_util::BodyExt::collect(req.into_body()).await {
967        Ok(collected) => collected.to_bytes(),
968        Err(_) => Bytes::new(),
969    };
970    let body_str = String::from_utf8_lossy(&body_bytes).to_string();
971
972    match build_lua_request_and_call(lua, &handler, &method, &path, &query, &headers, &body_str)
973        .await
974    {
975        Ok(lua_resp) => lua_response_to_http(lua, &lua_resp),
976        Err(e) => Ok(Response::builder()
977            .status(StatusCode::INTERNAL_SERVER_ERROR)
978            .header("content-type", "text/plain")
979            .body(axum::body::Body::new(Full::new(Bytes::from(format!(
980                "handler error: {e}"
981            )))))
982            .unwrap()),
983    }
984}
985
986#[cfg(feature = "server")]
987fn build_ws_upgrade_response(
988    lua: &Lua,
989    headers: &[(String, String)],
990    resp_table: Table,
991    ws_fn: mlua::Function,
992    peer_addr: String,
993    req: Request<Incoming>,
994) -> Result<Response<ServerBody>, hyper::Error> {
995    let key = match validate_ws_request(headers) {
996        Ok(k) => k,
997        Err(msg) => {
998            return Ok(Response::builder()
999                .status(StatusCode::BAD_REQUEST)
1000                .header("content-type", "text/plain")
1001                .body(axum::body::Body::new(Full::new(Bytes::from(format!(
1002                    "websocket upgrade rejected: {msg}"
1003                )))))
1004                .unwrap());
1005        }
1006    };
1007    let accept = compute_ws_accept(&key);
1008
1009    let mut builder = Response::builder()
1010        .status(StatusCode::SWITCHING_PROTOCOLS)
1011        .header(hyper::header::UPGRADE, "websocket")
1012        .header(hyper::header::CONNECTION, "Upgrade")
1013        .header("sec-websocket-accept", accept);
1014
1015    // User-supplied headers (e.g., for auth or tracing). Skip the protocol-controlled ones
1016    // we just set, in case the handler accidentally returns them too.
1017    if let Ok(Some(headers_table)) = resp_table.get::<Option<Table>>("headers") {
1018        for pair in headers_table.pairs::<String, mlua::String>().flatten() {
1019            let (k, v) = pair;
1020            let kl = k.to_ascii_lowercase();
1021            if matches!(
1022                kl.as_str(),
1023                "upgrade" | "connection" | "sec-websocket-accept"
1024            ) {
1025                continue;
1026            }
1027            if let Ok(s) = v.to_str() {
1028                builder = builder.header(&k, s.as_ref());
1029            }
1030        }
1031    }
1032
1033    let response = builder
1034        .body(axum::body::Body::new(Full::new(Bytes::new())))
1035        .unwrap();
1036
1037    let lua_clone = lua.clone();
1038    tokio::task::spawn_local(async move {
1039        let upgraded = match hyper::upgrade::on(req).await {
1040            Ok(u) => u,
1041            Err(e) => {
1042                error!("http.serve: ws upgrade failed: {e}");
1043                return;
1044            }
1045        };
1046        let io = hyper_util::rt::TokioIo::new(upgraded);
1047        let stream = tokio_tungstenite::WebSocketStream::from_raw_socket(
1048            io,
1049            tokio_tungstenite::tungstenite::protocol::Role::Server,
1050            None,
1051        )
1052        .await;
1053        let conn = super::ws::WsServerConn::new(stream, peer_addr);
1054        let ud = match lua_clone.create_userdata(conn) {
1055            Ok(u) => u,
1056            Err(e) => {
1057                error!("http.serve: ws userdata creation failed: {e}");
1058                return;
1059            }
1060        };
1061        if let Err(e) = ws_fn.call_async::<()>(ud).await
1062            && !e.to_string().contains("conn:read: ")
1063        {
1064            error!("http.serve: ws handler error: {e}");
1065        }
1066    });
1067
1068    Ok(response)
1069}
1070
1071#[cfg(feature = "server")]
1072async fn build_lua_request_and_call(
1073    lua: &Lua,
1074    handler: &mlua::Function,
1075    method: &str,
1076    path: &str,
1077    query: &str,
1078    headers: &[(String, String)],
1079    body: &str,
1080) -> mlua::Result<Table> {
1081    let req_table = lua.create_table()?;
1082    req_table.set("method", method.to_string())?;
1083    req_table.set("path", path.to_string())?;
1084    req_table.set("query", query.to_string())?;
1085    req_table.set("body", body.to_string())?;
1086
1087    // Parse query string into a params table with URL-decoded keys and values
1088    // (e.g. "a=1&b=hello%20world" -> {a="1", b="hello world"}).
1089    // Uses form_urlencoded which handles percent-encoding and `+` -> ` ` correctly,
1090    // so consumers like assay.ory.hydra get the raw value back rather than a doubly-encoded string.
1091    let params_table = lua.create_table()?;
1092    if !query.is_empty() {
1093        for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
1094            params_table.set(key.into_owned(), value.into_owned())?;
1095        }
1096    }
1097    req_table.set("params", params_table)?;
1098
1099    let headers_table = lua.create_table()?;
1100    for (k, v) in headers {
1101        headers_table.set(k.as_str(), v.as_str())?;
1102    }
1103    req_table.set("headers", headers_table)?;
1104
1105    handler.call_async::<Table>(req_table).await
1106}
1107
1108#[cfg(feature = "server")]
1109fn lua_response_to_http(
1110    lua: &Lua,
1111    resp_table: &Table,
1112) -> Result<Response<ServerBody>, hyper::Error> {
1113    let status = resp_table
1114        .get::<Option<u16>>("status")
1115        .unwrap_or(None)
1116        .unwrap_or(200);
1117
1118    // Check for SSE function first
1119    if let Ok(Some(sse_fn)) = resp_table.get::<Option<mlua::Function>>("sse") {
1120        let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(32);
1121
1122        let mut builder =
1123            Response::builder().status(StatusCode::from_u16(status).unwrap_or(StatusCode::OK));
1124
1125        // Apply custom headers first so they take precedence over SSE defaults
1126        if let Ok(Some(headers_table)) = resp_table.get::<Option<Table>>("headers") {
1127            for pair in headers_table.pairs::<String, Value>().flatten() {
1128                let (k, v) = pair;
1129                match v {
1130                    Value::String(s) => {
1131                        if let Ok(s) = s.to_str() {
1132                            builder = builder.header(&k, s.as_ref());
1133                        }
1134                    }
1135                    Value::Table(t) => {
1136                        // Array of strings → multiple headers with the same name
1137                        // (required for Set-Cookie when setting multiple cookies)
1138                        for val in t.sequence_values::<String>().flatten() {
1139                            builder = builder.header(&k, val);
1140                        }
1141                    }
1142                    _ => {}
1143                }
1144            }
1145        }
1146
1147        let mut response = builder.body(axum::body::Body::new(SseBody { rx })).unwrap();
1148        let response_headers = response.headers_mut();
1149        if !response_headers.contains_key(hyper::header::CONTENT_TYPE) {
1150            response_headers.insert(
1151                hyper::header::CONTENT_TYPE,
1152                hyper::header::HeaderValue::from_static("text/event-stream"),
1153            );
1154        }
1155        if !response_headers.contains_key(hyper::header::CACHE_CONTROL) {
1156            response_headers.insert(
1157                hyper::header::CACHE_CONTROL,
1158                hyper::header::HeaderValue::from_static("no-cache"),
1159            );
1160        }
1161        if !response_headers.contains_key(hyper::header::CONNECTION) {
1162            response_headers.insert(
1163                hyper::header::CONNECTION,
1164                hyper::header::HeaderValue::from_static("keep-alive"),
1165            );
1166        }
1167
1168        // Spawn the SSE function on the local set.
1169        // We wrap tx in Rc<RefCell<Option>> so we can explicitly close the channel
1170        // after the SSE function returns. If tx were only inside the Lua closure,
1171        // it would stay alive as long as Lua's GC keeps the closure, preventing the
1172        // channel from closing and the response body from completing.
1173        let lua_clone = lua.clone();
1174        tokio::task::spawn_local(async move {
1175            let tx_holder: Rc<RefCell<Option<tokio::sync::mpsc::Sender<Bytes>>>> =
1176                Rc::new(RefCell::new(Some(tx)));
1177            let tx_for_fn = tx_holder.clone();
1178
1179            let send_fn = match lua_clone.create_async_function(move |_lua, event_table: Table| {
1180                let tx_ref = tx_for_fn.clone();
1181                async move {
1182                    let formatted = format_sse_event(&event_table)?;
1183                    let tx = tx_ref
1184                        .borrow()
1185                        .clone()
1186                        .ok_or_else(|| mlua::Error::runtime("SSE stream closed"))?;
1187                    if tx.send(Bytes::from(formatted)).await.is_err() {
1188                        return Err(mlua::Error::runtime("SSE stream closed"));
1189                    }
1190                    Ok(())
1191                }
1192            }) {
1193                Ok(f) => f,
1194                Err(e) => {
1195                    error!("http.serve SSE: failed to create send callback: {e}");
1196                    return;
1197                }
1198            };
1199
1200            if let Err(e) = sse_fn.call_async::<()>(send_fn).await
1201                && !e.to_string().contains("SSE stream closed")
1202            {
1203                error!("http.serve SSE: handler error: {e}");
1204            }
1205
1206            // Explicitly close the channel so the response body completes
1207            tx_holder.borrow_mut().take();
1208        });
1209
1210        return Ok(response);
1211    }
1212
1213    let mut builder =
1214        Response::builder().status(StatusCode::from_u16(status).unwrap_or(StatusCode::OK));
1215
1216    let has_content_type =
1217        if let Ok(Some(headers_table)) = resp_table.get::<Option<Table>>("headers") {
1218            let mut found_ct = false;
1219            for pair in headers_table.pairs::<String, Value>().flatten() {
1220                let (k, v) = pair;
1221                if k.eq_ignore_ascii_case("content-type") {
1222                    found_ct = true;
1223                }
1224                match v {
1225                    Value::String(s) => {
1226                        if let Ok(s) = s.to_str() {
1227                            builder = builder.header(&k, s.as_ref());
1228                        }
1229                    }
1230                    Value::Table(t) => {
1231                        // Array of strings → multiple headers with the same name
1232                        // (required for Set-Cookie when setting multiple cookies)
1233                        for val in t.sequence_values::<String>().flatten() {
1234                            builder = builder.header(&k, val);
1235                        }
1236                    }
1237                    _ => {}
1238                }
1239            }
1240            found_ct
1241        } else {
1242            false
1243        };
1244
1245    let body_bytes = if let Ok(Some(json_table)) = resp_table.get::<Option<Table>>("json") {
1246        let json_val =
1247            lua_value_to_json(&Value::Table(json_table)).unwrap_or(serde_json::Value::Null);
1248        let serialized = serde_json::to_string(&json_val).unwrap_or_else(|_| "null".to_string());
1249        if !has_content_type {
1250            builder = builder.header("content-type", "application/json");
1251        }
1252        Bytes::from(serialized)
1253    } else if let Ok(Some(body_lua)) = resp_table.get::<Option<mlua::String>>("body") {
1254        if !has_content_type {
1255            builder = builder.header("content-type", "text/plain");
1256        }
1257        Bytes::from(body_lua.as_bytes().to_vec())
1258    } else {
1259        if !has_content_type {
1260            builder = builder.header("content-type", "text/plain");
1261        }
1262        Bytes::new()
1263    };
1264
1265    Ok(builder
1266        .body(axum::body::Body::new(Full::new(body_bytes)))
1267        .unwrap())
1268}
1269
1270// ── tests ────────────────────────────────────────────────────────────────────
1271
1272#[cfg(all(test, feature = "server"))]
1273mod tests {
1274    use super::*;
1275    use axum::Router;
1276    use axum::routing::get;
1277    use mlua::Lua;
1278
1279    /// `LuaAxumRouter` wraps an `axum::Router` so it can be passed through Lua
1280    /// as `mlua` userdata. This test mirrors a typical downstream embedding
1281    /// pattern: build a Router in Rust, wrap it in `LuaAxumRouter`, hand it
1282    /// to the Lua VM via `create_userdata`, stash it as a global, then read
1283    /// it back out and confirm the round-trip preserves the underlying type.
1284    #[test]
1285    fn lua_axum_router_round_trips_through_mlua_globals() {
1286        let lua = Lua::new();
1287        let router = Router::new().route("/ping", get(|| async { "pong" }));
1288        let wrapped = LuaAxumRouter(router);
1289
1290        let ud = lua
1291            .create_userdata(wrapped)
1292            .expect("create_userdata for LuaAxumRouter");
1293        lua.globals()
1294            .set("EXTRA_ROUTER", ud)
1295            .expect("stash userdata in globals");
1296
1297        let value: mlua::Value = lua
1298            .globals()
1299            .get("EXTRA_ROUTER")
1300            .expect("read userdata back from globals");
1301        let ud = match value {
1302            mlua::Value::UserData(u) => u,
1303            other => panic!("expected UserData, got {other:?}"),
1304        };
1305        let _borrowed = ud
1306            .borrow::<LuaAxumRouter>()
1307            .expect("downcast to LuaAxumRouter");
1308    }
1309
1310    /// `Clone` on `LuaAxumRouter` should be cheap and preserve route
1311    /// dispatch — `axum::Router::clone` is a shallow `Arc` clone, so cloning
1312    /// the wrapper just clones that handle. We verify both clones still
1313    /// produce the same route at the type level (compile check).
1314    #[test]
1315    fn lua_axum_router_is_clone_and_preserves_routes() {
1316        let router = Router::<()>::new().route("/health", get(|| async { "ok" }));
1317        let wrapped = LuaAxumRouter(router);
1318        let _cloned = wrapped.clone();
1319        // If this compiles and `clone()` is callable, the public bound holds.
1320    }
1321}