use std::collections::HashMap;
use std::str::FromStr;
#[derive(Debug, Clone)]
pub struct RouteContext {
path: String,
path_params: HashMap<String, String>,
query: String,
}
impl RouteContext {
pub fn new(path: String, path_params: HashMap<String, String>, query: String) -> Self {
Self {
path,
path_params,
query,
}
}
pub fn path(&self) -> &str {
&self.path
}
pub fn query(&self) -> &str {
&self.query
}
pub fn path_param(&self, name: &str) -> Option<String> {
self.path_params.get(name).cloned()
}
pub fn path_params(&self) -> &HashMap<String, String> {
&self.path_params
}
}
#[derive(Debug, thiserror::Error)]
pub enum ExtractError {
#[error("missing path parameter `{0}`")]
MissingPath(String),
#[error("missing query parameter `{0}`")]
MissingQuery(String),
#[error("failed to parse `{name}`: {source}")]
Parse {
name: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
}
pub trait FromRequest: Sized {
fn from_request(ctx: &RouteContext) -> Result<Self, ExtractError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PathParam<T>(T);
impl<T> PathParam<T> {
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> AsRef<T> for PathParam<T> {
fn as_ref(&self) -> &T {
&self.0
}
}
impl<T> std::ops::Deref for PathParam<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
impl<T: FromStr> PathParam<T>
where
T::Err: std::error::Error + Send + Sync + 'static,
{
pub fn extract(ctx: &RouteContext, name: &str) -> Result<Self, ExtractError> {
let raw = ctx
.path_param(name)
.ok_or_else(|| ExtractError::MissingPath(name.to_string()))?;
let parsed = T::from_str(&raw).map_err(|e| ExtractError::Parse {
name: name.to_string(),
source: Box::new(e),
})?;
Ok(PathParam(parsed))
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct QueryParam<T>(T);
impl<T> QueryParam<T> {
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> AsRef<T> for QueryParam<T> {
fn as_ref(&self) -> &T {
&self.0
}
}
impl<T> std::ops::Deref for QueryParam<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
impl<T: FromStr> QueryParam<T>
where
T::Err: std::error::Error + Send + Sync + 'static,
{
pub fn extract(ctx: &RouteContext, name: &str) -> Result<Self, ExtractError> {
let raw = parse_query(ctx.query(), name)
.ok_or_else(|| ExtractError::MissingQuery(name.to_string()))?;
let parsed = T::from_str(&raw).map_err(|e| ExtractError::Parse {
name: name.to_string(),
source: Box::new(e),
})?;
Ok(QueryParam(parsed))
}
}
fn parse_query(query: &str, key: &str) -> Option<String> {
for pair in query.split('&').filter(|p| !p.is_empty()) {
let mut it = pair.splitn(2, '=');
let k = it.next()?;
let v = it.next().unwrap_or("");
if k == key {
return Some(url_decode(v));
}
}
None
}
fn url_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
if let (Some(h), Some(l)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
out.push(h * 16 + l);
i += 3;
} else {
out.push(b'%');
i += 1;
}
}
b'%' => {
out.push(b'%');
i += 1;
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(&e.into_bytes()).into_owned())
}
fn hex_digit(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_query_finds_first_match() {
assert_eq!(parse_query("a=1&b=2", "a"), Some("1".to_string()));
assert_eq!(parse_query("a=1&b=2", "b"), Some("2".to_string()));
assert_eq!(parse_query("a=1&b=2", "c"), None);
}
#[test]
fn parse_query_handles_empty_value() {
assert_eq!(parse_query("a=", "a"), Some("".to_string()));
}
#[test]
fn url_decode_handles_percent_encoding() {
assert_eq!(url_decode("hello%20world"), "hello world");
assert_eq!(url_decode("a+b"), "a b");
assert_eq!(url_decode("plain"), "plain");
}
#[test]
fn url_decode_passes_through_invalid_percent() {
assert_eq!(url_decode("a%ZZ"), "a%ZZ");
}
}