Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ test-case = "3.3.1"
rstest = "0.26.1"
url = "2.5.8"
tokio = { version = "1.53.1", default-features = false }
form_urlencoded = "1.2.2"
futures-concurrency = "7.7.1"
dynify = "0.1.2"
futures-channel = "0.3.34"
Expand Down
2 changes: 2 additions & 0 deletions core/runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ boa_wintertc.workspace = true
bus = { workspace = true, optional = true }
bytemuck.workspace = true
either = { workspace = true, optional = true }
form_urlencoded = { workspace = true, optional = true }
futures = "0.3.32"
futures-lite.workspace = true
http = { workspace = true, optional = true }
Expand Down Expand Up @@ -52,6 +53,7 @@ all = ["default", "reqwest-blocking"]
url = ["dep:url"]
fetch = [
"dep:either",
"dep:form_urlencoded",
"dep:http",
"dep:serde_json",
"boa_engine/either",
Expand Down
119 changes: 118 additions & 1 deletion core/runtime/src/fetch/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
//! [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Request
use super::HttpRequest;
use super::headers::JsHeaders;
use boa_engine::object::builtins::JsPromise;
use boa_engine::value::{Convert, TryFromJs};
use boa_engine::{
Finalize, JsData, JsObject, JsResult, JsString, JsValue, Trace, boa_class, js_error,
Context, Finalize, JsData, JsNativeError, JsObject, JsResult, JsString, JsValue, Trace,
boa_class, js_error,
};
use either::Either;
use std::mem;
Expand All @@ -26,6 +28,12 @@ pub struct RequestInit {
}

impl RequestInit {
/// Returns `true` if a `body` field was explicitly provided in the init object.
#[must_use]
pub fn has_body(&self) -> bool {
self.body.is_some()
}

/// Takes the abort signal from the options, if present.
pub fn take_signal(&mut self) -> Option<JsObject> {
self.signal.take()
Expand Down Expand Up @@ -235,4 +243,113 @@ impl JsRequest {
fn clone_request(&self) -> Self {
self.clone()
}

/// Returns the HTTP method of the request.
///
/// See <https://fetch.spec.whatwg.org/#dom-request-method>
#[boa(getter)]
fn method(&self) -> JsString {
JsString::from(self.inner.method().as_str())
}

/// Returns the URL of the request.
///
/// See <https://fetch.spec.whatwg.org/#dom-request-url>
#[boa(getter)]
fn url(&self) -> JsString {
JsString::from(self.inner.uri().to_string().as_str())
}

/// Returns the headers associated with the request.
///
/// See <https://fetch.spec.whatwg.org/#dom-request-headers>
#[boa(getter)]
fn headers(&self) -> JsHeaders {
JsHeaders::from_http(self.inner.headers().clone())
}

/// Reads the request body as a UTF-8 string.
///
/// Returns a `Promise` that resolves to a string.
///
/// See <https://fetch.spec.whatwg.org/#dom-body-text>
fn text(&self, context: &mut Context) -> JsPromise {
let body = self.inner.body().clone();
JsPromise::from_async_fn(
async move |_| {
let text = String::from_utf8_lossy(&body);
Ok(JsString::from(text.as_ref()).into())
},
context,
)
}

/// Reads the request body and parses it as JSON.
///
/// Returns a `Promise` that resolves to the parsed JavaScript value.
///
/// See <https://fetch.spec.whatwg.org/#dom-body-json>
fn json(&self, context: &mut Context) -> JsPromise {
let body = self.inner.body().clone();
JsPromise::from_async_fn(
async move |context| {
let json_str = String::from_utf8_lossy(&body);
let json = serde_json::from_str::<serde_json::Value>(&json_str)
.map_err(|e| JsNativeError::syntax().with_message(e.to_string()))?;
JsValue::from_json(&json, &mut context.borrow_mut())
},
context,
)
}

/// Reads the request body and parses it as `application/x-www-form-urlencoded`.
///
/// Returns a `Promise` that resolves to a plain JS object with the form fields.
///
/// Note: This is a simplified implementation that only supports
/// `application/x-www-form-urlencoded` bodies. Multipart form data is not
/// supported. When the same key appears multiple times, the last value wins.
///
/// See <https://fetch.spec.whatwg.org/#dom-body-formdata>
fn form_data(&self, context: &mut Context) -> JsPromise {
let body = self.inner.body().clone();
let content_type = self
.inner
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(str::to_string);

JsPromise::from_async_fn(
async move |context| {
// Reject multipart; accept url-encoded or no Content-Type.
let is_url_encoded = content_type
.as_deref()
.is_none_or(|ct| ct.starts_with("application/x-www-form-urlencoded"));

if !is_url_encoded {
return Err(JsNativeError::typ()
.with_message(
"formData() only supports application/x-www-form-urlencoded bodies",
)
.into());
}

let ctx = &mut context.borrow_mut();
let form_obj = JsObject::default(ctx.intrinsics());

for (key, value) in form_urlencoded::parse(&body) {
form_obj.set(
JsString::from(key.as_ref()),
JsString::from(value.as_ref()),
false,
ctx,
)?;
}

Ok(form_obj.into())
},
context,
)
}
}
44 changes: 44 additions & 0 deletions core/runtime/src/fetch/tests/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,3 +436,47 @@ fn request_clone_signal_override() {
}),
]);
}

#[test]
fn request_getters() {
run_test_actions([
TestAction::harness(),
TestAction::inspect_context(|ctx| {
let fetcher = TestFetcher::default();
crate::fetch::register(fetcher, None, ctx).expect("failed to register fetch");
}),
TestAction::run(indoc! {r#"
globalThis.done = (async () => {
const request = new Request("http://unit.test/api?x=1", {
method: "POST",
headers: { "content-type": "application/json", "x-test": "yes" },
body: '{"a":1,"b":[2,3]}',
});
assertEq(request.method, "POST");
assertEq(request.url, "http://unit.test/api?x=1");
assertEq(request.headers.get("content-type"), "application/json");
assertEq(request.headers.get("x-test"), "yes");
assertEq(await request.text(), '{"a":1,"b":[2,3]}');
const json = await request.json();
assertEq(json.a, 1);
assertEq(json.b[1], 3);

const form = new Request("http://unit.test/form", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "a=1&b=two",
});
const fields = await form.formData();
assertEq(fields.a, "1");
assertEq(fields.b, "two");
})();
"#}),
TestAction::inspect_context(|ctx| {
let done = ctx.global_object().get(js_str!("done"), ctx).unwrap();
done.as_promise()
.unwrap()
.await_blocking(ctx)
.expect("request getter assertions should pass");
}),
]);
}
Loading