Skip to content

Instantly share code, notes, and snippets.

@kameko
Created July 15, 2025 03:08
Show Gist options
  • Select an option

  • Save kameko/ad150c4068ea3976b9f4c7c175235eb6 to your computer and use it in GitHub Desktop.

Select an option

Save kameko/ad150c4068ea3976b9f4c7c175235eb6 to your computer and use it in GitHub Desktop.
The Boa library isn't verbose in the least bit.
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