Skip to main content

rhai_http/
lib.rs

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