use crate::fetch::headers::JsHeaders;
use boa_engine::object::builtins::{JsPromise, JsUint8Array};
use boa_engine::value::{Convert, TryFromJs, TryIntoJs};
use boa_engine::{
Context, JsData, JsNativeError, JsResult, JsString, JsValue, boa_class, js_error, js_str,
js_string,
};
use boa_gc::{Finalize, Trace};
use http::{HeaderName, HeaderValue, StatusCode};
use std::rc::Rc;
#[derive(Debug, Copy, Clone)]
pub enum ResponseType {
Basic,
Cors,
Default,
Error,
Opaque,
OpaqueRedirect,
}
impl ResponseType {
#[must_use]
pub fn to_string(self) -> JsString {
match self {
ResponseType::Basic => js_string!("basic"),
ResponseType::Cors => js_string!("cors"),
ResponseType::Default => js_string!("default"),
ResponseType::Error => js_string!("error"),
ResponseType::Opaque => js_string!("opaque"),
ResponseType::OpaqueRedirect => js_string!("opaqueredirect"),
}
}
}
impl TryFromJs for ResponseType {
fn try_from_js(value: &JsValue, context: &mut Context) -> JsResult<Self> {
let value_str = value.to_string(context)?;
if value_str == js_str!("basic") {
Ok(ResponseType::Basic)
} else if value_str == js_str!("cors") {
Ok(ResponseType::Cors)
} else if value_str == js_str!("default") {
Ok(ResponseType::Default)
} else if value_str == js_str!("error") {
Ok(ResponseType::Error)
} else if value_str == js_str!("opaque") {
Ok(ResponseType::Opaque)
} else if value_str == js_str!("opaqueredirect") {
Ok(ResponseType::OpaqueRedirect)
} else {
Err(js_error!(TypeError: "Invalid response type value"))
}
}
}
impl TryIntoJs for ResponseType {
fn try_into_js(&self, _: &mut Context) -> JsResult<JsValue> {
Ok(self.to_string().into())
}
}
fn is_null_body_status(status: u16) -> bool {
matches!(status, 101 | 103 | 204 | 205 | 304)
}
fn is_valid_reason_phrase(s: &str) -> bool {
s.bytes()
.all(|b| matches!(b, 0x09 | 0x20 | 0x21..=0x7E | 0x80..=0xFF))
}
#[derive(Clone, Debug, Trace, Finalize, JsData)]
pub struct JsResponse {
url: JsString,
#[unsafe_ignore_trace]
r#type: ResponseType,
status: u16,
status_text: JsString,
headers: JsHeaders,
#[unsafe_ignore_trace]
body: Rc<Vec<u8>>,
}
impl JsResponse {
#[must_use]
pub fn basic(url: JsString, inner: http::Response<Vec<u8>>) -> Self {
let (parts, body) = inner.into_parts();
let status = parts.status.as_u16();
let status_text = JsString::from(parts.status.canonical_reason().unwrap_or(""));
let headers = JsHeaders::from_http(parts.headers);
let body = Rc::new(body);
Self {
url,
r#type: ResponseType::Basic,
status,
status_text,
headers,
body,
}
}
#[must_use]
pub fn error() -> Self {
Self {
url: js_string!(""),
r#type: ResponseType::Error,
status: 0,
status_text: JsString::default(),
headers: JsHeaders::default(),
body: Rc::new(Vec::new()),
}
}
#[must_use]
pub fn body(&self) -> Rc<Vec<u8>> {
self.body.clone()
}
}
#[derive(Debug, Default, Clone, TryFromJs, TryIntoJs, Trace, Finalize, JsData)]
#[boa(rename_all = "camelCase")]
pub struct JsResponseOptions {
status: Option<u16>,
status_text: Option<JsString>,
headers: Option<JsHeaders>,
}
fn initialize_response(
init: &JsResponseOptions,
body_with_type: Option<(Vec<u8>, Option<&str>)>,
) -> JsResult<JsResponse> {
let status = init.status.unwrap_or(200);
if !(200..=599).contains(&status) {
return Err(
js_error!(RangeError: "The status provided ({}) is outside the range [200, 599].", status),
);
}
let status_text = init.status_text.clone().unwrap_or_default();
let status_text_str = status_text.to_std_string_escaped();
if !is_valid_reason_phrase(&status_text_str) {
return Err(
js_error!(TypeError: "statusText contains characters that are not valid in a reason-phrase."),
);
}
let mut headers = init.headers.clone().unwrap_or_default();
let body = if let Some((body_bytes, body_type)) = body_with_type {
if is_null_body_status(status) {
return Err(
js_error!(TypeError: "Response body is not allowed for null body status codes (101, 103, 204, 205, 304)."),
);
}
if let Some(content_type) = body_type
&& !headers.has(Convert::from("content-type".to_string()))?
{
headers.append(
Convert::from("content-type".to_string()),
Convert::from(content_type.to_string()),
)?;
}
Rc::new(body_bytes)
} else {
Rc::new(Vec::new())
};
Ok(JsResponse {
url: js_string!(""),
r#type: ResponseType::Default,
status,
status_text,
headers,
body,
})
}
fn extract_body(val: &JsValue, context: &mut Context) -> JsResult<(Vec<u8>, Option<&'static str>)> {
let bytes = val.to_string(context)?.to_std_string_escaped().into_bytes();
Ok((bytes, Some("text/plain;charset=UTF-8")))
}
#[boa_class(rename = "Response")]
#[boa(rename_all = "camelCase")]
impl JsResponse {
#[boa(static)]
#[boa(rename = "error")]
fn error_() -> Self {
Self::error()
}
#[boa(static)]
fn redirect(url: JsValue, status: Option<u16>, context: &mut Context) -> JsResult<Self> {
let status = status.unwrap_or(302);
if !matches!(status, 301 | 302 | 303 | 307 | 308) {
return Err(js_error!(RangeError: "Invalid redirect status: {}", status));
}
let url_str = url.to_string(context)?.to_std_string_escaped();
http::Uri::try_from(url_str.as_str())
.map_err(|_| js_error!(TypeError: "Invalid URL: {}", url_str))?;
let status_code = StatusCode::from_u16(status)
.map_err(|_| js_error!(RangeError: "Invalid status code: {}", status))?;
let mut headers = http::header::HeaderMap::new();
headers.insert(
HeaderName::from_static("location"),
HeaderValue::try_from(url_str)
.map_err(|_| js_error!(TypeError: "Invalid URL for header value"))?,
);
Ok(Self {
url: js_string!(""),
r#type: ResponseType::Default,
status: status_code.as_u16(),
status_text: JsString::from(status_code.canonical_reason().unwrap_or("")),
headers: JsHeaders::from_http(headers),
body: Rc::new(Vec::new()),
})
}
#[boa(static)]
#[boa(rename = "json")]
fn json_static(data: JsValue, init: JsValue, context: &mut Context) -> JsResult<Self> {
let json_val = data.to_json(context)?.ok_or_else(|| {
JsNativeError::typ().with_message("value cannot be serialized to JSON")
})?;
let json_bytes = serde_json::to_vec(&json_val)
.map_err(|e| JsNativeError::error().with_message(e.to_string()))?;
let body_with_type = (json_bytes, Some("application/json"));
let options = if init.is_null_or_undefined() {
JsResponseOptions::default()
} else {
JsResponseOptions::try_from_js(&init, context)?
};
initialize_response(&options, Some(body_with_type))
}
#[boa(constructor)]
fn constructor(body: Option<JsValue>, init: JsValue, context: &mut Context) -> JsResult<Self> {
let body_with_type: Option<(Vec<u8>, Option<&'static str>)> = match body {
None => None,
Some(ref val) if val.is_null_or_undefined() => None,
Some(val) => Some(extract_body(&val, context)?),
};
let options = if init.is_null_or_undefined() {
JsResponseOptions::default()
} else {
JsResponseOptions::try_from_js(&init, context)?
};
initialize_response(&options, body_with_type)
}
#[boa(getter)]
#[must_use]
pub fn status(&self) -> u16 {
self.status
}
#[boa(getter)]
fn ok(&self) -> bool {
let status = self.status();
(200..=299).contains(&status)
}
#[boa(getter)]
fn status_text(&self) -> JsString {
self.status_text.clone()
}
#[boa(getter)]
#[must_use]
pub fn headers(&self) -> JsHeaders {
self.headers.clone()
}
#[boa(getter)]
#[boa(rename = "type")]
fn r#type(&self) -> JsString {
self.r#type.to_string()
}
#[boa(getter)]
fn url(&self) -> JsString {
let s = self.url.to_std_string_escaped();
let without_fragment = s.find('#').map_or(s.as_str(), |i| &s[..i]);
JsString::from(without_fragment)
}
#[boa(getter)]
#[allow(clippy::unused_self)]
fn redirected(&self) -> bool {
false
}
#[boa(rename = "clone")]
fn clone_response(&self) -> Self {
Self {
url: self.url.clone(),
r#type: self.r#type,
status: self.status,
status_text: self.status_text.clone(),
headers: self.headers.deep_clone(),
body: Rc::new((*self.body).clone()),
}
}
fn bytes(&self, context: &mut Context) -> JsPromise {
let body = self.body.clone();
JsPromise::from_async_fn(
async move |context| {
JsUint8Array::from_iter(body.iter().copied(), &mut context.borrow_mut())
.map(Into::into)
},
context,
)
}
fn text(&self, context: &mut Context) -> JsPromise {
let body = self.body.clone();
JsPromise::from_async_fn(
async move |_| {
let body = String::from_utf8_lossy(body.as_ref());
Ok(JsString::from(body).into())
},
context,
)
}
fn json(&self, context: &mut Context) -> JsPromise {
let body = self.body.clone();
JsPromise::from_async_fn(
async move |context| {
let json_string = String::from_utf8_lossy(body.as_ref());
let json = serde_json::from_str::<serde_json::Value>(&json_string)
.map_err(|e| JsNativeError::syntax().with_message(e.to_string()))?;
JsValue::from_json(&json, &mut context.borrow_mut())
},
context,
)
}
}