Created
July 15, 2025 03:08
-
-
Save kameko/ad150c4068ea3976b9f4c7c175235eb6 to your computer and use it in GitHub Desktop.
The Boa library isn't verbose in the least bit.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| use std::str::FromStr; | |
| use boa_engine::JsData; | |
| use boa_engine::JsError; | |
| use boa_engine::JsObject; | |
| use boa_engine::JsResult; | |
| use boa_engine::JsString; | |
| use boa_engine::JsSymbol; | |
| use boa_engine::JsValue; | |
| use boa_engine::NativeFunction; | |
| use boa_engine::js_string; | |
| use boa_engine::object::ObjectInitializer; | |
| use boa_engine::object::builtins::JsArray; | |
| use boa_engine::property::Attribute; | |
| use boa_engine::property::PropertyKey; | |
| use boa_gc::Finalize; | |
| use boa_gc::Trace; | |
| use serde_json::Value; | |
| use url::Url; | |
| /// A simple HTTP client for making requests in JavaScript. | |
| #[derive(Debug, Default, Trace, Finalize, JsData)] | |
| pub struct HttpClient {} | |
| impl HttpClient { | |
| /// Name of the built-in `http` property. | |
| pub const NAME: JsString = js_string!("http"); | |
| /// Initialize the HTTP client and add it to the JavaScript context. | |
| pub fn init(context: &mut boa_engine::Context) -> JsObject { | |
| fn wrap_request_method( | |
| f: fn(&JsValue, &[JsValue], &mut boa_engine::Context) -> JsResult<JsValue>, | |
| ) -> NativeFunction { | |
| // SAFETY: `HttpClient` doesn't contain types that need tracing. | |
| unsafe { | |
| NativeFunction::from_closure(move |this, args, context| f(this, args, context)) | |
| } | |
| } | |
| ObjectInitializer::with_native_data_and_proto( | |
| Self::default(), | |
| JsObject::with_object_proto(context.realm().intrinsics()), | |
| context, | |
| ) | |
| .property( | |
| JsSymbol::to_string_tag(), | |
| Self::NAME, | |
| Attribute::CONFIGURABLE, | |
| ) | |
| .function( | |
| wrap_request_method(Self::send_request), | |
| js_string!("sendRequest"), | |
| 0, | |
| ) | |
| .build() | |
| } | |
| fn send_request( | |
| _this: &JsValue, | |
| args: &[JsValue], | |
| context: &mut boa_engine::Context, | |
| ) -> JsResult<JsValue> { | |
| // Get arguments | |
| if args.len() < 2 { | |
| return Err(JsError::from_opaque( | |
| js_string!("sendRequest requires at least 2 arguments: method and callback").into(), | |
| )); | |
| } | |
| let reqobj = args[0].as_object().ok_or_else(|| { | |
| JsError::from_opaque(js_string!("First argument must be an object").into()) | |
| })?; | |
| let callback = args[1].as_function().ok_or_else(|| { | |
| JsError::from_opaque(js_string!("Second argument must be a function").into()) | |
| })?; | |
| // Extract URL from the request object | |
| let url = reqobj.get(js_string!("url"), context)?; | |
| let url = url | |
| .as_string() | |
| .ok_or_else(|| JsError::from_opaque(js_string!("URL must be a string").into()))?; | |
| let url = Url::parse( | |
| &url.to_std_string() | |
| .map_err(|e| JsError::from_opaque(js_string!(format!("{:?}", e)).into()))?, | |
| ) | |
| .map_err(|_| JsError::from_opaque(js_string!("Invalid URL format").into()))?; | |
| // Extract method from the request object | |
| let method = reqobj.get(js_string!("method"), context)?; | |
| let method = method | |
| .as_string() | |
| .ok_or_else(|| JsError::from_opaque(js_string!("Method must be a string").into()))?; | |
| let method = method | |
| .to_std_string() | |
| .map_err(|e| JsError::from_opaque(js_string!(format!("{:?}", e)).into()))?; | |
| let method = match method.to_uppercase().as_str() { | |
| "GET" => minreq::Method::Get, | |
| "POST" => minreq::Method::Post, | |
| "PUT" => minreq::Method::Put, | |
| "DELETE" => minreq::Method::Delete, | |
| "PATCH" => minreq::Method::Patch, | |
| "HEAD" => minreq::Method::Head, | |
| "OPTIONS" => minreq::Method::Options, | |
| "TRACE" => minreq::Method::Trace, | |
| "CONNECT" => minreq::Method::Connect, | |
| _ => minreq::Method::Custom(method), | |
| }; | |
| // TODO: Add optional timeout: | |
| // https://docs.rs/minreq/2.14.0/minreq/struct.Request.html#method.with_timeout | |
| // Extract headers from the request object | |
| let header = if reqobj.has_property(js_string!("header"), context)? { | |
| let header = reqobj.get(js_string!("header"), context)?; | |
| let header = header.as_object().ok_or_else(|| { | |
| JsError::from_opaque(js_string!("Headers must be an object").into()) | |
| })?; | |
| let mut extracted = vec![]; | |
| for key in header.own_property_keys(context)? { | |
| let index = match key.clone() { | |
| PropertyKey::String(s) => s, | |
| _ => { | |
| return Err(JsError::from_opaque( | |
| js_string!("Header keys must be strings").into(), | |
| )); | |
| } | |
| }; | |
| let value = header.get(index, context)?; | |
| if !value.is_string() { | |
| return Err(JsError::from_opaque( | |
| js_string!("Header values must be strings").into(), | |
| )); | |
| } | |
| extracted.push(( | |
| key.to_string(), | |
| value | |
| .to_string(context)? | |
| .to_std_string() | |
| .map_err(|e| JsError::from_opaque(js_string!(format!("{:?}", e)).into()))?, | |
| )); | |
| } | |
| Some(extracted) | |
| } else { | |
| None | |
| }; | |
| // Extract body from the request object | |
| let body = if reqobj.has_property(js_string!("body"), context)? { | |
| let body = reqobj.get(js_string!("body"), context)?; | |
| if !body.is_object() { | |
| return Err(JsError::from_opaque( | |
| js_string!("Body must be an object").into(), | |
| )); | |
| } | |
| Some(body.to_json(context)?) | |
| } else { | |
| None | |
| }; | |
| // Create the request | |
| let mut req = minreq::Request::new(method, url); | |
| if let Some(headers) = header { | |
| for (key, value) in headers { | |
| req = req.with_header(key, value); | |
| } | |
| } | |
| if let Some(body) = body { | |
| req = req.with_json(&body).map_err(|e| { | |
| JsError::from_opaque( | |
| js_string!(format!("Failed to serialize body: {:?}", e)).into(), | |
| ) | |
| })?; | |
| } | |
| let resultobj = reqobj.clone(); | |
| let req_result = Self::make_request(context, req); | |
| let args = match req_result { | |
| Ok(response) => { | |
| resultobj | |
| .set(js_string!("previous"), response.clone(), false, context) | |
| .map_err(|e| JsError::from_opaque(js_string!(format!("{:?}", e)).into()))?; | |
| vec![JsValue::undefined(), JsValue::from(response)] | |
| } | |
| Err(err) => { | |
| let _ = resultobj.delete_property_or_throw(js_string!("previous"), context); | |
| vec![err.to_opaque(context), JsValue::undefined()] | |
| } | |
| }; | |
| let func_result = callback.call(&JsValue::undefined(), &args, context)?; | |
| if !func_result.is_undefined() { | |
| resultobj | |
| .set(js_string!("returned"), func_result, false, context) | |
| .map_err(|e| JsError::from_opaque(js_string!(format!("{:?}", e)).into()))?; | |
| } | |
| Ok(resultobj.into()) | |
| } | |
| fn make_request(context: &mut boa_engine::Context, req: minreq::Request) -> JsResult<JsValue> { | |
| // Parse the response | |
| let response = req.send().map_err(|e| { | |
| JsError::from_opaque(js_string!(format!("Failed to send request: {:?}", e)).into()) | |
| })?; | |
| // Extract status code and response body | |
| let status_code_json = serde_json::json!({ | |
| "code": response.status_code, | |
| "message": response.reason_phrase, | |
| }); | |
| let status_code_json = JsValue::from_json(&status_code_json, context)?; | |
| let mut headers = vec![]; | |
| for (name, value) in response.headers.iter() { | |
| let obj = serde_json::json!({ | |
| "name": name, | |
| "value": value, | |
| }); | |
| headers.push(JsValue::from_json(&obj, context)?); | |
| } | |
| let headers = JsArray::from_iter(headers, context); | |
| let response_body = String::from_utf8_lossy(response.as_bytes()); | |
| // If the response body is JSON, parse it; otherwise, treat it as a string. | |
| let response_body = if let Ok(response_body) = serde_json::from_str::<Value>(&response_body) | |
| { | |
| JsValue::from_json(&response_body, context)? | |
| } else { | |
| JsString::from_str(&response_body) | |
| .map_err(|e| { | |
| JsError::from_opaque( | |
| js_string!(format!( | |
| "Failed to convert response body to string: {:?}", | |
| e | |
| )) | |
| .into(), | |
| ) | |
| })? | |
| .into() | |
| }; | |
| let obj = ObjectInitializer::new(context) | |
| .property( | |
| js_string!("status"), | |
| status_code_json, | |
| Attribute::WRITABLE | Attribute::CONFIGURABLE, | |
| ) | |
| .property( | |
| js_string!("header"), | |
| headers, | |
| Attribute::WRITABLE | Attribute::CONFIGURABLE, | |
| ) | |
| .property( | |
| js_string!("body"), | |
| response_body, | |
| Attribute::WRITABLE | Attribute::CONFIGURABLE, | |
| ) | |
| .build(); | |
| Ok(obj.into()) | |
| } | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| use boa_engine::Context; | |
| use boa_engine::Source; | |
| use tracing_test::traced_test; | |
| #[test] | |
| #[traced_test] | |
| fn test_send_request() -> eyre::Result<()> { | |
| let mut context = Context::default(); | |
| crate::scripting::logging::TracingLogger::register(&mut context) | |
| .map_err(|e| eyre::eyre!("Failed to register tracing logger: {:?}", e))?; | |
| context | |
| .eval(Source::from_bytes( | |
| "console.log('Tracing logger registered');", | |
| )) | |
| .map_err(|e| eyre::eyre!("Failed to evaluate script: {:?}", e))?; | |
| let client = HttpClient::init(&mut context); | |
| context | |
| .register_global_property(HttpClient::NAME, client, Attribute::all()) | |
| .map_err(|e| eyre::eyre!("Failed to register HTTP client: {:?}", e))?; | |
| let result = context | |
| .eval(Source::from_bytes( | |
| r#" | |
| console.log('Sending request...'); | |
| var request = { | |
| url: "https://restful-booker.herokuapp.com/booking/1", | |
| method: "GET", | |
| header: { | |
| "Accept": "application/json" | |
| } | |
| }; | |
| var result = http.sendRequest(request, (error, response) => { | |
| if (error) { | |
| console.error("Error:", error); | |
| } else { | |
| console.log("Response:", response.status.code); | |
| console.log("firstname:", response.body.firstname); | |
| console.log("lastname:", response.body.lastname); | |
| } | |
| }); | |
| // console.log(result.previous.status.code); | |
| result.previous.status.code | |
| "#, | |
| )) | |
| .map_err(|e| eyre::eyre!("Failed to evaluate script: {:?}", e))?; | |
| tracing::info!("Result: {:?}", result); | |
| Ok(()) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment