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            r#"<!doctype html>
170<html>
171<head>
172    <title>Example Domain</title>
173
174    <meta charset="utf-8" />
175    <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
176    <meta name="viewport" content="width=device-width, initial-scale=1" />
177    <style type="text/css">
178    body {
179        background-color: #f0f0f2;
180        margin: 0;
181        padding: 0;
182        font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
183        
184    }
185    div {
186        width: 600px;
187        margin: 5em auto;
188        padding: 2em;
189        background-color: #fdfdff;
190        border-radius: 0.5em;
191        box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
192    }
193    a:link, a:visited {
194        color: #38488f;
195        text-decoration: none;
196    }
197    @media (max-width: 700px) {
198        div {
199            margin: 0 auto;
200            width: auto;
201        }
202    }
203    </style>    
204</head>
205
206<body>
207<div>
208    <h1>Example Domain</h1>
209    <p>This domain is for use in illustrative examples in documents. You may use this
210    domain in literature without prior coordination or asking for permission.</p>
211    <p><a href="https://www.iana.org/domains/example">More information...</a></p>
212</div>
213</body>
214</html>
215"#
216        );
217    }
218
219    #[test]
220    fn simple_query_headers() {
221        let mut engine = rhai::Engine::new();
222
223        HttpPackage::new().register_into_engine(&mut engine);
224
225        let body: rhai::Map = engine
226            .eval(
227                r#"
228let client = http::client();
229
230client.request(#{
231    "method": "GET",
232    "url": "https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?slug=bitcoin&convert=EUR",
233    "headers": [
234        "X-CMC_PRO_API_KEY: xxx",
235        "Accept: application/json"
236    ],
237    "output": "json",
238})
239"#,
240            )
241            .unwrap();
242
243        println!("{body:#?}");
244    }
245}