diff --git a/Cargo.lock b/Cargo.lock index a6e0df4ce2a..bde4bc4a384 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -578,6 +578,7 @@ dependencies = [ "bus", "bytemuck", "either", + "form_urlencoded", "futures", "futures-lite", "http", diff --git a/Cargo.toml b/Cargo.toml index 18a43fd86df..ed3b4320a17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -139,6 +139,7 @@ test-case = "3.3.1" rstest = "0.26.1" url = "2.5.8" tokio = { version = "1.52.3", default-features = false } +form_urlencoded = "1.2" futures-concurrency = "7.7.1" dynify = "0.1.2" futures-channel = "0.3.32" diff --git a/core/runtime/Cargo.toml b/core/runtime/Cargo.toml index 1c4c4568da3..dd8b4d4a582 100644 --- a/core/runtime/Cargo.toml +++ b/core/runtime/Cargo.toml @@ -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 } @@ -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", diff --git a/core/runtime/src/fetch/request.rs b/core/runtime/src/fetch/request.rs index 2991dfdd9a2..2e51c6fa65b 100644 --- a/core/runtime/src/fetch/request.rs +++ b/core/runtime/src/fetch/request.rs @@ -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; @@ -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 { self.signal.take() @@ -222,4 +230,113 @@ impl JsRequest { fn clone_request(&self) -> Self { self.clone() } + + /// Returns the HTTP method of the request. + /// + /// See + #[boa(getter)] + fn method(&self) -> JsString { + JsString::from(self.inner.method().as_str()) + } + + /// Returns the URL of the request. + /// + /// See + #[boa(getter)] + fn url(&self) -> JsString { + JsString::from(self.inner.uri().to_string().as_str()) + } + + /// Returns the headers associated with the request. + /// + /// See + #[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 + 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 + 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::(&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 + 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, + ) + } } diff --git a/core/runtime/src/fetch/tests/request.rs b/core/runtime/src/fetch/tests/request.rs index b14a7c84859..0f6f851bf39 100644 --- a/core/runtime/src/fetch/tests/request.rs +++ b/core/runtime/src/fetch/tests/request.rs @@ -354,3 +354,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"); + }), + ]); +}