use http::{HeaderMap, HeaderValue, Method, header, request::Parts};
use serde::de::DeserializeOwned;
use super::{HttpError, decoding::decode_strictly};
#[cfg(test)]
mod tests;
pub const MAX_REQUEST_BODY: usize = 1 << 20;
pub fn body_of<T: DeserializeOwned>(parts: &Parts, body: &[u8]) -> Result<T, HttpError> {
require_json(&parts.headers)?;
if body.len() > MAX_REQUEST_BODY {
return Err(too_large());
}
decode_strictly(body).map_err(HttpError::malformed)
}
pub fn no_body(parts: &Parts, body: &[u8]) -> Result<(), HttpError> {
require_same_origin(parts)?;
let is_empty_object = || {
serde_json::from_slice::<serde_json::Map<String, serde_json::Value>>(body)
.is_ok_and(|members| members.is_empty())
};
if body.is_empty() || is_empty_object() {
return Ok(());
}
Err(HttpError::malformed(
"this route takes no body, so the body has to be empty, or {}",
))
}
pub(crate) fn require_json(headers: &HeaderMap) -> Result<(), HttpError> {
let Some(value) = headers.get(header::CONTENT_TYPE) else {
return Err(HttpError::unsupported_media_type(
"Content-Type is required",
));
};
let text = String::from_utf8_lossy(value.as_bytes());
let Some(media_type) = media_type_of(&text) else {
return Err(HttpError::unsupported_media_type(format!(
"{text:?} could not be parsed"
)));
};
if media_type != "application/json" {
return Err(HttpError::unsupported_media_type(format!(
"{media_type:?} is not application/json"
)));
}
Ok(())
}
pub(crate) fn require_same_origin(parts: &Parts) -> Result<(), HttpError> {
if matches!(parts.method, Method::GET | Method::HEAD | Method::OPTIONS) {
return Ok(());
}
let refused =
|| HttpError::forbidden("a request without a body is not accepted from another origin");
if let Some(site) = parts.headers.get("sec-fetch-site") {
return if site == "same-origin" || site == "none" {
Ok(())
} else {
Err(refused())
};
}
let Some(origin) = parts.headers.get(header::ORIGIN) else {
return Ok(());
};
let host = parts
.headers
.get(header::HOST)
.map(HeaderValue::as_bytes)
.or_else(|| {
parts
.uri
.authority()
.map(|authority| authority.as_str().as_bytes())
});
let origin_host = origin
.to_str()
.ok()
.and_then(|origin| origin.split_once("://"))
.map(|(_, rest)| rest.split('/').next().unwrap_or_default().as_bytes());
match (origin_host, host) {
(Some(origin_host), Some(host)) if origin_host == host => Ok(()),
_ => Err(refused()),
}
}
pub(crate) fn too_large() -> HttpError {
HttpError::too_large(format!("at most {MAX_REQUEST_BODY} bytes are read"))
}
fn media_type_of(value: &str) -> Option<String> {
let media_type = value.split(';').next()?.trim();
let (kind, subtype) = media_type.split_once('/')?;
let is_token = |part: &str| {
!part.is_empty()
&& part
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte))
};
(is_token(kind) && is_token(subtype)).then(|| media_type.to_ascii_lowercase())
}