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
12fn 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
127fn 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 let opts: Option<mlua::Table> = match args_iter.next() {
154 Some(mlua::Value::Table(t)) => Some(t),
155 _ => None,
156 };
157
158 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 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 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 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 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); 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 register_download(lua, client, &http_table)?;
282
283 lua.globals().set("http", http_table)?;
284 Ok(())
285}
286
287fn 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 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 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
444async 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 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 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 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}