mod_http/
lib.rs

1use config::{any_err, from_lua_value, get_or_create_sub_module, SerdeWrappedValue};
2use futures_util::StreamExt;
3use mlua::prelude::LuaUserData;
4use mlua::{Lua, LuaSerdeExt, MetaMethod, UserDataMethods, Value};
5use reqwest::header::HeaderMap;
6use reqwest::{Body, Client, ClientBuilder, RequestBuilder, Response, StatusCode, Url};
7use serde::Deserialize;
8use std::collections::HashMap;
9use std::sync::{Arc, Mutex};
10use std::time::Duration;
11use tokio::sync::Mutex as TokioMutex;
12use tokio::time::Instant;
13use tokio_tungstenite::tungstenite::Message;
14
15// Client ----
16
17#[derive(Deserialize, Debug, Clone)]
18struct ClientOptions {
19    #[serde(default)]
20    user_agent: Option<String>,
21    #[serde(default)]
22    connection_verbose: Option<bool>,
23    #[serde(default, with = "duration_serde")]
24    pool_idle_timeout: Option<Duration>,
25    #[serde(default, with = "duration_serde")]
26    timeout: Option<Duration>,
27    #[serde(default)]
28    accept_invalid_certs: Option<bool>,
29}
30
31#[derive(Clone)]
32struct ClientWrapper {
33    client: Arc<Mutex<Option<Arc<Client>>>>,
34}
35
36impl ClientWrapper {
37    fn get_client(&self) -> mlua::Result<Arc<Client>> {
38        let inner = self.client.lock().unwrap();
39        inner
40            .as_ref()
41            .map(Arc::clone)
42            .ok_or_else(|| mlua::Error::external("client was closed"))
43    }
44}
45
46impl LuaUserData for ClientWrapper {
47    fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
48        methods.add_method("get", |_, this, url: String| {
49            let builder = this.get_client()?.get(url);
50            Ok(RequestWrapper::new(builder))
51        });
52        methods.add_method("post", |_, this, url: String| {
53            let builder = this.get_client()?.post(url);
54            Ok(RequestWrapper::new(builder))
55        });
56        methods.add_method("put", |_, this, url: String| {
57            let builder = this.get_client()?.put(url);
58            Ok(RequestWrapper::new(builder))
59        });
60        methods.add_method("close", |_, this, _: ()| {
61            this.client.lock().unwrap().take();
62            Ok(())
63        });
64    }
65}
66
67// Request ----
68
69#[derive(Clone)]
70struct RequestWrapper {
71    builder: Arc<Mutex<Option<RequestBuilder>>>,
72}
73
74impl RequestWrapper {
75    fn new(builder: RequestBuilder) -> Self {
76        Self {
77            builder: Arc::new(Mutex::new(Some(builder))),
78        }
79    }
80
81    fn apply<F>(&self, func: F) -> mlua::Result<()>
82    where
83        F: FnOnce(RequestBuilder) -> anyhow::Result<RequestBuilder>,
84    {
85        let b = self
86            .builder
87            .lock()
88            .unwrap()
89            .take()
90            .ok_or_else(|| mlua::Error::external("broken request builder"))?;
91
92        let b = (func)(b).map_err(any_err)?;
93
94        self.builder.lock().unwrap().replace(b);
95        Ok(())
96    }
97
98    async fn send(&self) -> mlua::Result<Response> {
99        let b = self
100            .builder
101            .lock()
102            .unwrap()
103            .take()
104            .ok_or_else(|| mlua::Error::external("broken request builder"))?;
105
106        b.send().await.map_err(any_err)
107    }
108}
109
110#[derive(Deserialize, Clone, Hash, PartialEq, Eq, Debug)]
111pub struct FilePart {
112    data: String,
113    file_name: String,
114}
115
116impl LuaUserData for RequestWrapper {
117    fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
118        methods.add_method("header", |_, this, (key, value): (String, String)| {
119            this.apply(|b| Ok(b.header(key, value)))?;
120            Ok(this.clone())
121        });
122
123        methods.add_method("headers", |_, this, headers: HashMap<String, String>| {
124            for (key, value) in headers {
125                this.apply(|b| Ok(b.header(key, value)))?;
126            }
127            Ok(this.clone())
128        });
129
130        methods.add_method("timeout", |_, this, duration: Value| {
131            let duration = match duration {
132                Value::Number(n) => std::time::Duration::from_secs_f64(n),
133                Value::String(s) => {
134                    let s = s.to_str()?;
135                    humantime::parse_duration(&s).map_err(any_err)?
136                }
137                _ => {
138                    return Err(mlua::Error::external("invalid timeout duration"));
139                }
140            };
141            this.apply(|b| Ok(b.timeout(duration)))?;
142            Ok(this.clone())
143        });
144
145        methods.add_method(
146            "basic_auth",
147            |_, this, (username, password): (String, Option<String>)| {
148                this.apply(|b| Ok(b.basic_auth(username, password)))?;
149                Ok(this.clone())
150            },
151        );
152
153        methods.add_method("bearer_auth", |_, this, token: String| {
154            this.apply(|b| Ok(b.bearer_auth(token)))?;
155            Ok(this.clone())
156        });
157
158        methods.add_method("body", |_, this, body: String| {
159            this.apply(|b| Ok(b.body(body)))?;
160            Ok(this.clone())
161        });
162
163        methods.add_method(
164            "form_url_encoded",
165            |_, this, params: HashMap<String, String>| {
166                this.apply(|b| Ok(b.form(&params)))?;
167                Ok(this.clone())
168            },
169        );
170
171        methods.add_method(
172            "form_multipart_data",
173            |lua, this, params: HashMap<String, mlua::Value>| {
174                // Generate a MIME body from the provided parameters
175                use mail_builder::headers::text::Text;
176                use mail_builder::headers::HeaderType;
177                use mail_builder::mime::MimePart;
178                use mailparse::MailHeaderMap;
179                use std::borrow::Cow;
180
181                let mut data = MimePart::new_multipart("multipart/form-data", vec![]);
182
183                for (k, v) in params {
184                    match v {
185                        mlua::Value::String(s) => {
186                            let part = if let Ok(s) = s.to_str() {
187                                MimePart::new_text(Cow::Owned(s.to_string()))
188                            } else {
189                                MimePart::new_binary(
190                                    "application/octet-stream",
191                                    Cow::Owned(s.as_bytes().to_vec()),
192                                )
193                            };
194                            data.add_part(part.header(
195                                "Content-Disposition",
196                                HeaderType::Text(Text::new(format!("form-data; name=\"{k}\""))),
197                            ));
198                        }
199                        _ => {
200                            let file: FilePart = lua.from_value(v.clone())?;
201
202                            let part = MimePart::new_binary(
203                                "application/octet-stream",
204                                file.data.into_bytes(),
205                            );
206                            data.add_part(part.header(
207                                "Content-Disposition",
208                                HeaderType::Text(Text::new(format!(
209                                    "form-data; name=\"{k}\"; filename=\"{}\"",
210                                    file.file_name
211                                ))),
212                            ));
213                        }
214                    }
215                }
216                let builder = mail_builder::MessageBuilder::new();
217                let builder = builder.body(data);
218                let body = builder.write_to_vec().map_err(any_err)?;
219
220                // Now, parse out the Content-Type header so that we can set that in
221                // the request, and get the generated body with its generated boundary
222                // string into a separate variable so that we can assign it as the body
223                // of the HTTP request.
224
225                let (headers, body_offset) = mailparse::parse_headers(&body).map_err(any_err)?;
226
227                let content_type = headers
228                    .get_first_value("Content-Type")
229                    .ok_or_else(|| mlua::Error::external("missing Content-Type!?".to_string()))?;
230
231                let body = &body[body_offset..];
232
233                this.apply(|b| Ok(b.header("Content-Type", content_type).body(body.to_vec())))?;
234
235                Ok(this.clone())
236            },
237        );
238
239        methods.add_async_method("send", |_, this, _: ()| async move {
240            let response = this.send().await?;
241            let status = response.status();
242            Ok(ResponseWrapper {
243                status,
244                response: Arc::new(Mutex::new(Some(response))),
245            })
246        });
247
248        // Convenience helper to apply AWS SigV4 signing to this request
249        methods.add_async_method(
250            "aws_sign_v4",
251            |_lua, this, params: SerdeWrappedValue<mod_aws_sigv4::SigV4Request>| async move {
252                let mut signer_params = params.0;
253
254                // Clone the current RequestBuilder so we can inspect method, URI, and query params
255                let req_builder = this
256                    .builder
257                    .lock()
258                    .unwrap()
259                    .as_ref()
260                    .ok_or_else(|| mlua::Error::external("broken request builder"))?
261                    .try_clone()
262                    .ok_or_else(|| mlua::Error::external("failed to clone request builder"))?;
263
264                let req = req_builder.build().map_err(any_err)?;
265
266                // Populate method, uri and query parameters from the request
267                signer_params.method = req.method().as_str().to_string();
268                signer_params.uri = req.url().path().to_string();
269                signer_params.query_params = req
270                    .url()
271                    .query_pairs()
272                    .map(|(k, v)| (k.to_string(), v.to_string()))
273                    .collect();
274
275                // Ensure the Host header is present
276                if !signer_params.headers.contains_key("host") {
277                    if let Some(host) = req.url().host_str() {
278                        signer_params
279                            .headers
280                            .insert("host".to_string(), host.to_string());
281                    }
282                }
283
284                let sig = mod_aws_sigv4::sign_request(signer_params)
285                    .await
286                    .map_err(any_err)?;
287
288                // Apply the computed signature headers to the underlying request builder
289                this.apply(|b| {
290                    Ok(b.header("Authorization", sig.authorization)
291                        .header("X-Amz-Date", sig.timestamp))
292                })?;
293
294                Ok(this.clone())
295            },
296        );
297    }
298}
299
300// Response ----
301
302#[derive(Clone)]
303struct ResponseWrapper {
304    status: StatusCode,
305    response: Arc<Mutex<Option<Response>>>,
306}
307
308impl ResponseWrapper {
309    fn with<F, T>(&self, func: F) -> mlua::Result<T>
310    where
311        F: FnOnce(&Response) -> anyhow::Result<T>,
312    {
313        let locked = self.response.lock().unwrap();
314        let response = locked
315            .as_ref()
316            .ok_or_else(|| mlua::Error::external("broken response wrapper"))?;
317
318        (func)(response).map_err(any_err)
319    }
320
321    async fn text(&self) -> mlua::Result<String> {
322        let r = self
323            .response
324            .lock()
325            .unwrap()
326            .take()
327            .ok_or_else(|| mlua::Error::external("broken response wrapper"))?;
328
329        r.text().await.map_err(any_err)
330    }
331
332    async fn bytes(&self, lua: &Lua) -> mlua::Result<mlua::String> {
333        let r = self
334            .response
335            .lock()
336            .unwrap()
337            .take()
338            .ok_or_else(|| mlua::Error::external("broken response wrapper"))?;
339
340        let bytes = r.bytes().await.map_err(any_err)?;
341
342        lua.create_string(bytes.as_ref())
343    }
344}
345
346impl LuaUserData for ResponseWrapper {
347    fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
348        methods.add_method("status_code", |_, this, _: ()| Ok(this.status.as_u16()));
349        methods.add_method("status_reason", |_, this, _: ()| {
350            Ok(this.status.canonical_reason())
351        });
352        methods.add_method("status_is_informational", |_, this, _: ()| {
353            Ok(this.status.is_informational())
354        });
355        methods.add_method("status_is_success", |_, this, _: ()| {
356            Ok(this.status.is_success())
357        });
358        methods.add_method("status_is_redirection", |_, this, _: ()| {
359            Ok(this.status.is_redirection())
360        });
361        methods.add_method("status_is_client_error", |_, this, _: ()| {
362            Ok(this.status.is_client_error())
363        });
364        methods.add_method("status_is_server_error", |_, this, _: ()| {
365            Ok(this.status.is_server_error())
366        });
367        methods.add_method("headers", |_, this, _: ()| {
368            this.with(|response| Ok(HeaderMapWrapper(response.headers().clone())))
369        });
370        methods.add_method("content_length", |_, this, _: ()| {
371            this.with(|response| Ok(response.content_length()))
372        });
373
374        methods.add_async_method("text", |_, this, _: ()| async move { this.text().await });
375
376        methods.add_async_method(
377            "bytes",
378            |lua, this, _: ()| async move { this.bytes(&lua).await },
379        );
380    }
381}
382
383// Headermap ---
384
385#[derive(Clone, mlua::FromLua)]
386struct HeaderMapWrapper(HeaderMap);
387
388impl LuaUserData for HeaderMapWrapper {
389    fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
390        methods.add_meta_method(MetaMethod::Index, |lua, this, key: String| {
391            if let Some(value) = this.0.get(&key) {
392                let s = lua.create_string(value.as_bytes())?;
393                return Ok(Value::String(s));
394            }
395            Ok(Value::Nil)
396        });
397
398        methods.add_meta_method(MetaMethod::Pairs, |lua, this, ()| {
399            // Snapshot every header line up front and iterate by position. The
400            // header map is a multimap, and a name-keyed iterator can never
401            // advance past a repeated name: given the previous key it re-finds
402            // its first occurrence and yields the one after it forever. Values
403            // are kept as raw bytes to avoid a lossy conversion.
404            let entries: Vec<(String, Vec<u8>)> = this
405                .0
406                .iter()
407                .map(|(name, value)| (name.as_str().to_string(), value.as_bytes().to_vec()))
408                .collect();
409
410            let mut index = 0;
411            let iter =
412                lua.create_function_mut(move |lua, (_state, _control): (Value, Value)| {
413                    let Some((name, value)) = entries.get(index) else {
414                        return Ok(mlua::MultiValue::new());
415                    };
416                    index += 1;
417                    let name = lua.create_string(name)?;
418                    let value = lua.create_string(value)?;
419                    Ok(mlua::MultiValue::from_vec(vec![
420                        Value::String(name),
421                        Value::String(value),
422                    ]))
423                })?;
424            Ok((iter, Value::Nil, Value::Nil))
425        });
426    }
427}
428
429#[derive(Clone)]
430struct WebSocketStream {
431    stream: Arc<
432        TokioMutex<
433            tokio_tungstenite::WebSocketStream<
434                tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
435            >,
436        >,
437    >,
438}
439
440impl LuaUserData for WebSocketStream {
441    fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
442        methods.add_async_method("recv", |lua, this, ()| async move {
443            let maybe_msg = {
444                let mut stream = this.stream.lock().await;
445                stream.next().await
446            };
447            let msg = match maybe_msg {
448                Some(msg) => msg.map_err(any_err)?,
449                None => return Ok(None),
450            };
451            Ok(match msg {
452                Message::Text(s) => Some(lua.create_string(&s)?),
453                Message::Close(_close_frame) => {
454                    return Ok(None);
455                }
456                Message::Pong(s) | Message::Binary(s) => Some(lua.create_string(&s)?),
457                Message::Ping(_) | Message::Frame(_) => {
458                    unreachable!()
459                }
460            })
461        });
462
463        methods.add_async_method("recv_batch", |lua, this, duration| async move {
464            let duration = match duration {
465                Value::Number(n) => std::time::Duration::from_secs_f64(n),
466                Value::String(s) => {
467                    let s = s.to_str()?;
468                    humantime::parse_duration(&s).map_err(any_err)?
469                }
470                _ => {
471                    return Err(mlua::Error::external("invalid timeout duration"));
472                }
473            };
474            let deadline = Instant::now() + duration;
475            let mut messages = vec![];
476            while let Ok(maybe_msg) = tokio::time::timeout_at(deadline, async {
477                let mut stream = this.stream.lock().await;
478                stream.next().await
479            })
480            .await
481            {
482                let msg = match maybe_msg {
483                    Some(msg) => msg.map_err(any_err)?,
484                    None => {
485                        if messages.is_empty() {
486                            return Err(mlua::Error::external("websocket closed"));
487                        }
488                        break;
489                    }
490                };
491                match msg {
492                    Message::Text(s) => messages.push(lua.create_string(&s)?),
493                    Message::Close(_close_frame) => {
494                        if messages.is_empty() {
495                            return Err(mlua::Error::external("websocket closed"));
496                        }
497                        break;
498                    }
499                    Message::Pong(s) | Message::Binary(s) => messages.push(lua.create_string(&s)?),
500                    Message::Ping(_) | Message::Frame(_) => {
501                        unreachable!()
502                    }
503                }
504            }
505
506            Ok(messages)
507        });
508    }
509}
510
511pub fn register(lua: &Lua) -> anyhow::Result<()> {
512    let http_mod = get_or_create_sub_module(lua, "http")?;
513
514    http_mod.set(
515        "build_url",
516        lua.create_function(|_lua, (url, params): (String, HashMap<String, String>)| {
517            let url = Url::parse_with_params(&url, params.into_iter()).map_err(any_err)?;
518            let url: String = url.into();
519            Ok(url)
520        })?,
521    )?;
522
523    http_mod.set(
524        "build_client",
525        lua.create_function(|lua, options: Value| {
526            let options: ClientOptions = from_lua_value(lua, options)?;
527            let mut builder = ClientBuilder::new().timeout(
528                options
529                    .timeout
530                    .unwrap_or_else(|| std::time::Duration::from_secs(60)),
531            );
532
533            if let Some(verbose) = options.connection_verbose {
534                builder = builder.connection_verbose(verbose);
535            }
536
537            if let Some(idle) = options.pool_idle_timeout {
538                builder = builder.pool_idle_timeout(idle);
539            }
540
541            if let Some(user_agent) = options.user_agent {
542                builder = builder.user_agent(user_agent);
543            }
544
545            if let Some(accept_invalid_certs) = options.accept_invalid_certs {
546                builder = builder.danger_accept_invalid_certs(accept_invalid_certs)
547            }
548
549            let client = builder.build().map_err(any_err)?;
550            Ok(ClientWrapper {
551                client: Arc::new(Mutex::new(Some(Arc::new(client)))),
552            })
553        })?,
554    )?;
555
556    http_mod.set(
557        "connect_websocket",
558        lua.create_async_function(|_, url: String| async move {
559            let (stream, response) = tokio_tungstenite::connect_async(url)
560                .await
561                .map_err(any_err)?;
562            let stream = WebSocketStream {
563                stream: Arc::new(TokioMutex::new(stream)),
564            };
565
566            // Adapt the retured http::response into a reqwest::Response
567            // so that we can use our existing ResponseWrapper type with it
568            let status = response.status();
569            let (parts, body) = response.into_parts();
570            let body = Body::from(body.unwrap_or_else(std::vec::Vec::new));
571            let response = tokio_tungstenite::tungstenite::http::Response::from_parts(parts, body);
572
573            let response = ResponseWrapper {
574                status,
575                response: Arc::new(Mutex::new(Some(Response::from(response)))),
576            };
577
578            Ok((stream, response))
579        })?,
580    )?;
581
582    Ok(())
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    fn headers_with_duplicate_set_cookie() -> HeaderMapWrapper {
590        let mut headers = HeaderMap::new();
591        headers.append("content-type", "text/html".parse().unwrap());
592        headers.append("set-cookie", "a=1".parse().unwrap());
593        headers.append("set-cookie", "b=2".parse().unwrap());
594        headers.append("set-cookie", "c=3".parse().unwrap());
595        HeaderMapWrapper(headers)
596    }
597
598    // Iterating a response's headers with `pairs` must visit every header line
599    // exactly once. It must stop even when a header name repeats (three
600    // Set-Cookie lines here, as github.com sends). The loop is capped to make a
601    // non-terminating iterator fail the count assertion rather than hang the
602    // test process.
603    #[test]
604    fn pairs_terminates_over_repeated_header_name() {
605        let lua = Lua::new();
606        lua.globals()
607            .set("headers", headers_with_duplicate_set_cookie())
608            .unwrap();
609
610        let (count, cookies): (usize, String) = lua
611            .load(
612                r#"
613                local n = 0
614                local cookies = {}
615                for k, v in pairs(headers) do
616                    n = n + 1
617                    if n > 1000 then break end
618                    if k == 'set-cookie' then
619                        cookies[#cookies + 1] = v
620                    end
621                end
622                return n, table.concat(cookies, ',')
623            "#,
624            )
625            .eval()
626            .unwrap();
627
628        k9::assert_equal!(count, 4);
629        k9::assert_equal!(cookies, "a=1,b=2,c=3");
630    }
631
632    // Direct indexing returns the first value for a repeated header name and is
633    // the safe accessor to use in policy.
634    #[test]
635    fn index_returns_first_value_for_repeated_header_name() {
636        let lua = Lua::new();
637        lua.globals()
638            .set("headers", headers_with_duplicate_set_cookie())
639            .unwrap();
640
641        let cookie: String = lua.load(r#"return headers['set-cookie']"#).eval().unwrap();
642        k9::assert_equal!(cookie, "a=1");
643    }
644}