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>(¶meters.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}