1use 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 pub type Client = reqwest::blocking::Client;
44
45 #[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 #[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
145 let value = reqwest::header::HeaderValue::from_str(value)
146 .map_err::<Box<EvalAltResult>, _>(|error| {
147 error.to_string().into()
148 })?;
149
150 Ok((name, value))
151 } else {
152 Err(format!("'{header}' is not a valid header").into())
153 }
154 })
155 .collect::<Result<reqwest::header::HeaderMap, Box<EvalAltResult>>>()?,
156 )
157 .body(body.to_string())
159 .send()
160 .and_then(|response| match output {
161 Output::Text => response.text().map(rhai::Dynamic::from),
162 Output::Json => response.json::<rhai::Map>().map(rhai::Dynamic::from),
163 })
164 .map_err(|error| format!("{error:?}").into())
165 }
166}
167
168def_package! {
169 pub HttpPackage(_module) {} |> |engine| {
171 engine.register_static_module("http", rhai::exported_module!(api).into());
174 }
175}
176
177#[cfg(test)]
178pub mod test {
179 use crate::HttpPackage;
180 use rhai::packages::Package;
181
182 fn setup_engine() -> rhai::Engine {
183 let mut engine = rhai::Engine::new();
184
185 HttpPackage::new().register_into_engine(&mut engine);
186 engine
187 }
188
189 #[test]
190 fn test_simple_query() {
191 let engine = setup_engine();
192
193 let body: String = engine
194 .eval(
195 r#"
196let client = http::client();
197
198client.request(#{ method: "GET", url: "http://example.com" })"#,
199 )
200 .unwrap();
201
202 assert!(body
203 .find("This domain is for use in documentation examples without needing permission.")
204 .is_some());
205 }
206
207 #[test]
208 fn test_simple_query_headers() {
209 let engine = setup_engine();
210 let body: rhai::Map = engine
211 .eval(
212 r#"
213let client = http::client();
214
215client.request(#{
216 "method": "GET",
217 "url": "https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest?slug=bitcoin&convert=EUR",
218 "headers": [
219 "X-CMC_PRO_API_KEY: xxx",
220 "Accept: application/json"
221 ],
222 "output": "json",
223})
224"#,
225 )
226 .unwrap();
227
228 println!("{body:#?}");
229 }
230
231 #[test]
232 fn test_bad_header_name() {
233 let engine = setup_engine();
234 let error = engine
235 .eval::<()>(
236 r#"
237let client = http::client();
238
239client.request(#{
240 "method": "GET",
241 "url": "http://example.com",
242 "headers": [
243 "test/abc: xxx",
244 ],
245 "output": "json",
246})
247"#,
248 )
249 .err()
250 .unwrap();
251
252 assert!(matches!(
253 *error,
254 rhai::EvalAltResult::ErrorRuntime(dynamic, position) if dynamic.to_string() == "invalid HTTP header name" &&
255 position == rhai::Position::new(4, 8)
256 ));
257 }
258
259 #[test]
260 fn test_bad_header_value() {
261 let engine = setup_engine();
262 let error = engine
264 .eval::<()>(
265 "let client = http::client();\n\
266 \n\
267 client.request(#{\n\
268 \"method\": \"GET\",\n\
269 \"url\": \"http://example.com\",\n\
270 \"headers\": [\n\
271 \"X-Custom: \x7F\",\n\
272 ],\n\
273 })",
274 )
275 .err()
276 .unwrap();
277
278 assert!(matches!(
279 *error,
280 rhai::EvalAltResult::ErrorRuntime(dynamic, position) if dynamic.to_string() == "failed to parse header value" &&
281 position == rhai::Position::new(3, 8)
282 ));
283 }
284
285 #[test]
286 fn test_bad_header() {
287 let engine = setup_engine();
288 let error = engine
289 .eval::<()>(
290 r#"
291let client = http::client();
292
293client.request(#{
294 "method": "GET",
295 "url": "http://example.com",
296 "headers": [
297 "my header",
298 ],
299 "output": "json",
300})
301"#,
302 )
303 .err()
304 .unwrap();
305
306 assert!(matches!(
307 *error,
308 rhai::EvalAltResult::ErrorRuntime(dynamic, position) if dynamic.to_string() == "'my header' is not a valid header" &&
309 position == rhai::Position::new(4, 8)
310 ));
311 }
312
313 #[test]
314 fn test_invalid_parameters() {
315 let engine = setup_engine();
316 let error = engine
317 .eval::<()>(
318 r#"
319let client = http::client();
320
321client.request(#{
322 "output": "json",
323})
324"#,
325 )
326 .err()
327 .unwrap();
328
329 assert!(matches!(*error, rhai::EvalAltResult::ErrorParsing(_, _)));
330 }
331
332 #[test]
333 fn test_invalid_method() {
334 let engine = setup_engine();
335 let error = engine
336 .eval::<()>(
337 r#"
338let client = http::client();
339
340client.request(#{
341 "method": "INVALID METHOD",
342 "url": "http://example.com",
343})
344"#,
345 )
346 .err()
347 .unwrap();
348
349 assert!(matches!(
350 *error,
351 rhai::EvalAltResult::ErrorRuntime(dynamic, position) if dynamic.to_string().contains("invalid HTTP method") &&
352 position == rhai::Position::new(4, 8)
353 ));
354 }
355
356 #[test]
357 fn test_request_send_failure() {
358 let engine = setup_engine();
359 let error = engine
360 .eval::<()>(
361 r#"
362let client = http::client();
363
364client.request(#{
365 "method": "GET",
366 "url": "http://this-host-does-not-exist.invalid",
367})
368"#,
369 )
370 .err()
371 .unwrap();
372
373 assert!(matches!(
374 *error,
375 rhai::EvalAltResult::ErrorRuntime(_, position) if position == rhai::Position::new(4, 8)
376 ));
377 }
378}