rhai_http/
lib.rs

1use rhai::{def_package, plugin::*};
2
3#[derive(Default, Clone, serde::Deserialize)]
4#[serde(rename_all = "snake_case")]
5pub enum Output {
6    #[default]
7    Text,
8    Json,
9}
10
11#[derive(Clone, serde::Deserialize)]
12pub struct Parameters {
13    method: String,
14    url: String,
15    #[serde(default)]
16    headers: rhai::Array,
17    #[serde(default)]
18    body: rhai::Dynamic,
19    #[serde(default)]
20    output: Output,
21}
22
23#[export_module]
24pub mod api {
25    use std::str::FromStr;
26
27    /// A HTTP client that can execute HTTP requests. See `http::client` to create an instance.
28    ///
29    /// # rhai-autodocs:index:1
30    pub type Client = reqwest::blocking::Client;
31
32    /// Create a new HTTP client. Can be used to query HTTP endpoints.
33    ///
34    /// # Errors
35    ///
36    /// - TLS backend could not be initialized
37    /// - Resolver could not load the system configuration
38    ///
39    /// # Example
40    ///
41    /// ```js
42    /// let client = http::client();
43    /// ```
44    ///
45    /// # rhai-autodocs:index:2
46    #[rhai_fn(return_raw)]
47    pub fn client() -> Result<Client, Box<rhai::EvalAltResult>> {
48        reqwest::blocking::Client::builder()
49            .build()
50            .map_err(|error| error.to_string().into())
51    }
52
53    /// Execute an HTTP request.
54    ///
55    /// # Args
56    ///
57    /// - `parameter`: A map of parameters with the following fields:
58    ///     - `method`: the method to use. (e.g. "POST", "GET", etc.)
59    ///     - `url`: Endpoint to query.
60    ///     - `headers`: Optional headers to add to the query.
61    ///     - `body`: Optional body to add to the query.
62    ///     - `output`: Output format of the response retrieved by the client, can either be 'text' or 'json'. Defaults to 'text'.
63    ///
64    /// # Errors
65    ///
66    /// - The url failed to be parsed
67    /// - Headers failed to be parsed
68    /// - The request failed
69    ///
70    /// # Example
71    ///
72    /// ```js
73    /// let client = http::client();
74    ///
75    /// let response = client.request(#{
76    ///     method: "GET",
77    ///     url: "http://example.com"
78    /// });
79    ///
80    /// print(response)
81    /// ```
82    ///
83    /// # rhai-autodocs:index:3
84    #[rhai_fn(global, pure, return_raw)]
85    pub fn request(
86        client: &mut Client,
87        parameters: rhai::Map,
88    ) -> Result<rhai::Dynamic, Box<rhai::EvalAltResult>> {
89        let Parameters {
90            method,
91            url,
92            headers,
93            body,
94            output,
95        } = rhai::serde::from_dynamic::<Parameters>(&parameters.into())?;
96
97        let method = reqwest::Method::from_str(&method)
98            .map_err::<Box<rhai::EvalAltResult>, _>(|error| error.to_string().into())?;
99
100        client
101            .request(method, url)
102            .headers(
103                headers
104                    .iter()
105                    .map(|header| {
106                        if let Some((name, value)) = header.to_string().split_once(':') {
107                            let name = name.trim();
108                            let value = value.trim();
109
110                            let name = reqwest::header::HeaderName::from_str(name).map_err::<Box<
111                                EvalAltResult,
112                            >, _>(
113                                |error| error.to_string().into(),
114                            )?;
115                            let value = reqwest::header::HeaderValue::from_str(value)
116                                .map_err::<Box<EvalAltResult>, _>(|error| {
117                                    error.to_string().into()
118                                })?;
119
120                            Ok((name, value))
121                        } else {
122                            Err(format!("'{header}' is not a valid header").into())
123                        }
124                    })
125                    .collect::<Result<reqwest::header::HeaderMap, Box<EvalAltResult>>>()?,
126            )
127            // FIXME: string or blob.
128            .body(body.to_string())
129            .send()
130            .and_then(|response| match output {
131                Output::Text => response.text().map(rhai::Dynamic::from),
132                Output::Json => response.json::<rhai::Map>().map(rhai::Dynamic::from),
133            })
134            .map_err(|error| format!("{error:?}").into())
135    }
136}
137
138def_package! {
139    /// Package to build and send http requests.
140    pub HttpPackage(_module) {} |> |engine| {
141        // NOTE: since package modules items are registered in the global namespace,
142        //       this is used to move the items in the `http` namespace.
143        engine.register_static_module("http", rhai::exported_module!(api).into());
144    }
145}
146
147#[cfg(test)]
148pub mod test {
149    use crate::HttpPackage;
150    use rhai::packages::Package;
151
152    #[test]
153    fn simple_query() {
154        let mut engine = rhai::Engine::new();
155
156        HttpPackage::new().register_into_engine(&mut engine);
157
158        let body: String = engine
159            .eval(
160                r#"
161let client = http::client();
162
163client.request(#{ method: "GET", url: "http://example.com" })"#,
164            )
165            .unwrap();
166
167        assert_eq!(
168            body,
169            concat!(
170                r#"<!doctype html><html lang="en"><head><title>Example Domain</title><meta name="viewport" content="width=device-width, initial-scale=1"><style>body{background:#eee;width:60vw;margin:15vh auto;font-family:system-ui,sans-serif}h1{font-size:1.5em}div{opacity:0.8}a:link,a:visited{color:#348}</style><body><div><h1>Example Domain</h1><p>This domain is for use in documentation examples without needing permission. Avoid use in operations.<p><a href="https://iana.org/domains/example">Learn more</a></div></body></html>"#,
171                "\n"
172            )
173        );
174    }
175
176    #[test]
177    fn simple_query_headers() {
178        let mut engine = rhai::Engine::new();
179
180        HttpPackage::new().register_into_engine(&mut engine);
181
182        let body: rhai::Map = engine
183            .eval(
184                r#"
185let client = http::client();
186
187client.request(#{
188    "method": "GET",
189    "url": "https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?slug=bitcoin&convert=EUR",
190    "headers": [
191        "X-CMC_PRO_API_KEY: xxx",
192        "Accept: application/json"
193    ],
194    "output": "json",
195})
196"#,
197            )
198            .unwrap();
199
200        println!("{body:#?}");
201    }
202}