use rustc_hash::FxHashMap;
use zenith_api::normalize::{percent_decode_with_policy, InvalidSequencePolicy};
use zenith_api::{CanonicalRequest, CanonicalResponse};
#[derive(Debug, Clone)]
pub enum ExtractError {
NotFound(String),
ParseError {
name: String,
expected: &'static str,
},
OutOfRange {
name: String,
value: String,
},
Custom(String),
}
impl std::fmt::Display for ExtractError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExtractError::NotFound(name) => write!(f, "Parameter not found: {}", name),
ExtractError::ParseError { name, expected } => {
write!(f, "Failed to parse '{}' as {}", name, expected)
}
ExtractError::OutOfRange { name, value } => {
write!(f, "Parameter '{}' value '{}' out of range", name, value)
}
ExtractError::Custom(msg) => write!(f, "{}", msg),
}
}
}
impl std::error::Error for ExtractError {}
pub trait FromRequest: Sized {
fn from_request(request: &CanonicalRequest, params: &FxHashMap<String, String>) -> Result<Self, ExtractError>;
}
pub trait IntoResponse {
fn into_response(self) -> CanonicalResponse;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathParam<'a> {
pub name: &'a str,
pub value: String,
}
impl<'a> PathParam<'a> {
pub fn as_str(&self) -> &str {
&self.value
}
pub fn parse<T: std::str::FromStr>(&self) -> Result<T, ExtractError> {
self.value.parse::<T>().map_err(|_| ExtractError::ParseError {
name: self.name.to_string(),
expected: std::any::type_name::<T>(),
})
}
}
pub fn path_param<'a>(
params: &'a FxHashMap<String, String>,
name: &'a str,
) -> Result<PathParam<'a>, ExtractError> {
params
.get(name)
.map(|v| PathParam { name, value: v.clone() })
.ok_or_else(|| ExtractError::NotFound(name.to_string()))
}
pub fn path_param_parse<T>(
params: &FxHashMap<String, String>,
name: &str,
) -> Result<T, ExtractError>
where
T: std::str::FromStr,
{
let param = path_param(params, name)?;
param.parse::<T>()
}
pub fn parse_query(query: &str) -> FxHashMap<String, String> {
let mut params = FxHashMap::default();
if query.is_empty() {
return params;
}
for pair in query.split('&') {
if let Some((key, value)) = pair.split_once('=') {
let (Some(decoded_key), Some(decoded_value)) = (url_decode(key), url_decode(value))
else {
continue;
};
params.insert(decoded_key, decoded_value);
} else if !pair.is_empty()
&& let Some(decoded) = url_decode(pair)
{
params.insert(decoded, String::new());
}
}
params
}
pub fn url_decode(s: &str) -> Option<String> {
percent_decode_with_policy(s, true, InvalidSequencePolicy::Preserve)
}
pub fn query_param(
request: &CanonicalRequest,
name: &str,
) -> Result<String, ExtractError> {
let query = request.query_str();
if query.is_empty() {
return Err(ExtractError::NotFound(name.to_string()));
}
for pair in query.split('&') {
if let Some((key, value)) = pair.split_once('=') {
let Some(decoded_key) = url_decode(key) else { continue };
let Some(decoded_value) = url_decode(value) else { continue };
if decoded_key == name {
return Ok(decoded_value);
}
} else if !pair.is_empty() {
let Some(decoded) = url_decode(pair) else { continue };
if decoded == name {
return Ok(String::new());
}
}
}
Err(ExtractError::NotFound(name.to_string()))
}
pub fn query_param_or(request: &CanonicalRequest, name: &str, default: &str) -> String {
query_param(request, name).unwrap_or_else(|_| default.to_string())
}
pub fn query_param_parse<T>(request: &CanonicalRequest, name: &str) -> Result<T, ExtractError>
where
T: std::str::FromStr,
{
let value = query_param(request, name)?;
value.parse::<T>().map_err(|_| ExtractError::ParseError {
name: name.to_string(),
expected: std::any::type_name::<T>(),
})
}
pub fn header_value<'a>(request: &'a CanonicalRequest, name: &str) -> Option<&'a str> {
request.find_header(name).map(|h| h.value_str())
}
pub fn header_required<'a>(request: &'a CanonicalRequest, name: &str) -> Result<&'a str, ExtractError> {
header_value(request, name).ok_or_else(|| ExtractError::NotFound(name.to_string()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UserId(pub u64);
impl UserId {
pub fn from_params(params: &FxHashMap<String, String>) -> Result<Self, ExtractError> {
let id: u64 = path_param_parse(params, "id")?;
Ok(Self(id))
}
}
impl FromRequest for UserId {
fn from_request(_request: &CanonicalRequest, params: &FxHashMap<String, String>) -> Result<Self, ExtractError> {
Self::from_params(params)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Pagination {
pub page: u32,
pub per_page: u32,
}
impl Pagination {
pub fn from_request(request: &CanonicalRequest) -> Self {
let page = query_param_parse::<u32>(request, "page").unwrap_or(1);
let per_page = query_param_parse::<u32>(request, "per_page").unwrap_or(20);
Self { page, per_page }
}
pub fn offset(&self) -> u32 {
if self.page == 0 {
return 0;
}
self.page.saturating_sub(1).saturating_mul(self.per_page)
}
}
impl FromRequest for Pagination {
fn from_request(request: &CanonicalRequest, _params: &FxHashMap<String, String>) -> Result<Self, ExtractError> {
Ok(Self::from_request(request))
}
}
impl IntoResponse for &str {
fn into_response(self) -> CanonicalResponse {
let mut response = CanonicalResponse::new(200);
let _ = response
.add_header(b"content-type", b"text/plain");
response.set_body(self.as_bytes().to_vec());
response
}
}
impl IntoResponse for String {
fn into_response(self) -> CanonicalResponse {
let mut response = CanonicalResponse::new(200);
let _ = response
.add_header(b"content-type", b"text/plain");
response.set_body(self.into_bytes());
response
}
}
impl IntoResponse for &String {
fn into_response(self) -> CanonicalResponse {
let mut response = CanonicalResponse::new(200);
let _ = response
.add_header(b"content-type", b"text/plain");
response.set_body(self.as_bytes().to_vec());
response
}
}
impl IntoResponse for () {
fn into_response(self) -> CanonicalResponse {
CanonicalResponse::new(204)
}
}
impl IntoResponse for u16 {
fn into_response(self) -> CanonicalResponse {
CanonicalResponse::new(self)
}
}
impl<T: IntoResponse> IntoResponse for Result<T, ExtractError> {
fn into_response(self) -> CanonicalResponse {
match self {
Ok(val) => val.into_response(),
Err(e) => {
let mut response = CanonicalResponse::new(400);
response.set_body(e.to_string().into_bytes());
response
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_path_param() {
let mut params = FxHashMap::default();
params.insert("id".to_string(), "42".to_string());
let result = path_param(¶ms, "id").unwrap();
assert_eq!(result.as_str(), "42");
let id: u64 = path_param_parse(¶ms, "id").unwrap();
assert_eq!(id, 42);
}
#[test]
fn test_path_param_not_found() {
let params = FxHashMap::default();
let result = path_param(¶ms, "id");
assert!(result.is_err());
}
#[test]
fn test_parse_query() {
let params = parse_query("name=hello&age=25&flag");
assert_eq!(params.get("name").unwrap(), "hello");
assert_eq!(params.get("age").unwrap(), "25");
assert_eq!(params.get("flag").unwrap(), "");
}
#[test]
fn test_url_decode() {
assert_eq!(url_decode("hello%20world").unwrap(), "hello world");
assert_eq!(url_decode("%E4%BD%A0%E5%A5%BD").unwrap(), "你好");
assert_eq!(url_decode("simple").unwrap(), "simple");
}
#[test]
fn test_url_decode_unified_semantics() {
assert_eq!(url_decode("a+b+c").unwrap(), "a b c");
assert_eq!(url_decode("test%ZZdata").unwrap(), "test%ZZdata");
assert_eq!(url_decode("%").unwrap(), "%");
assert_eq!(url_decode("%2").unwrap(), "%2");
assert!(url_decode("%FF").is_none());
assert!(url_decode("%FF%FE").is_none());
}
#[test]
fn test_parse_query_skips_invalid_utf8_param() {
let params = parse_query("bad=%FF&good=ok");
assert!(!params.contains_key("bad"));
assert_eq!(params.get("good").unwrap(), "ok");
let params = parse_query("%FF=v&a=1");
assert_eq!(params.len(), 1);
assert_eq!(params.get("a").unwrap(), "1");
}
#[test]
fn test_parse_query_plus_as_space() {
let params = parse_query("name=John+Doe");
assert_eq!(params.get("name").unwrap(), "John Doe");
}
#[test]
fn test_query_param() {
let mut request = CanonicalRequest::empty();
let _ = request.set_query("key=value&num=42");
let val = query_param(&request, "key").unwrap();
assert_eq!(val, "value");
let num: u32 = query_param_parse(&request, "num").unwrap();
assert_eq!(num, 42);
let default = query_param_or(&request, "missing", "default");
assert_eq!(default, "default");
}
#[test]
fn test_header_extract() {
let mut request = CanonicalRequest::empty();
request
.add_header(b"x-custom", b"test-value")
.unwrap();
let val = header_value(&request, "x-custom").unwrap();
assert_eq!(val, "test-value");
let missing = header_value(&request, "x-missing");
assert!(missing.is_none());
}
#[test]
fn test_user_id() {
let mut params = FxHashMap::default();
params.insert("id".to_string(), "99".to_string());
let user_id = UserId::from_params(¶ms).unwrap();
assert_eq!(user_id.0, 99);
}
#[test]
fn test_pagination() {
let mut request = CanonicalRequest::empty();
let _ = request.set_query("page=3&per_page=10");
let pagination = Pagination::from_request(&request);
assert_eq!(pagination.page, 3);
assert_eq!(pagination.per_page, 10);
assert_eq!(pagination.offset(), 20);
}
#[test]
fn test_into_response_str() {
let response = "Hello World".into_response();
assert_eq!(response.status_code, 200);
assert_eq!(response.body(), b"Hello World");
}
#[test]
fn test_into_response_status() {
let response: CanonicalResponse = 404u16.into_response();
assert_eq!(response.status_code, 404);
}
#[test]
fn test_into_response_result() {
let ok_result: Result<&str, ExtractError> = Ok("success");
let response = ok_result.into_response();
assert_eq!(response.status_code, 200);
let err_result: Result<&str, ExtractError> =
Err(ExtractError::NotFound("test".to_string()));
let response = err_result.into_response();
assert_eq!(response.status_code, 400);
}
}