Files
webdav-server-rs/src/router.rs

263 lines
7.7 KiB
Rust

//!
//! Simple and stupid HTTP router.
//!
use std::default::Default;
use std::fmt::Debug;
use lazy_static::lazy_static;
use regex::bytes::{Match, Regex, RegexSet};
use webdav_handler::{DavMethod, DavMethodSet};
// internal representation of a route.
#[derive(Debug)]
struct Route<T: Debug> {
regex: Regex,
methods: Option<DavMethodSet>,
data: T,
}
/// A matched route.
#[derive(Debug)]
pub struct MatchedRoute<'t, 'p, T: Debug> {
pub methods: Option<DavMethodSet>,
pub params: Vec<Option<Param<'p>>>,
pub data: &'t T,
}
/// A parameter on a matched route.
pub struct Param<'p>(Match<'p>);
impl Debug for Param<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.debug_struct("Param")
.field("start", &self.0.start())
.field("end", &self.0.end())
.field("as_str", &std::str::from_utf8(self.0.as_bytes()).ok())
.finish()
}
}
impl<'p> Param<'p> {
/// Returns the starting byte offset of the match in the path.
#[inline]
pub fn start(&self) -> usize {
self.0.start()
}
/// Returns the ending byte offset of the match in the path.
#[inline]
pub fn end(&self) -> usize {
self.0.end()
}
/// Returns the matched part of the path.
#[inline]
pub fn as_bytes(&self) -> &'p [u8] {
self.0.as_bytes()
}
/// Returns the matched part of the path as a &str, if it is valid utf-8.
#[inline]
pub fn as_str(&self) -> Option<&'p str> {
std::str::from_utf8(self.0.as_bytes()).ok()
}
}
pub struct Builder<T: Debug> {
routes: Vec<Route<T>>,
}
impl<T: Debug> Builder<T> {
/// Add a route.
///
/// Routes are matched in the order they were added.
///
/// If a route starts with '^', it's assumed that it is a regular
/// expression. Parameters are included as "named capture groups".
///
/// Otherwise, it's a route-expression, with just the normal :params
/// and *splat param, and parts between parentheses are optional.
///
/// Example:
///
/// - /api/get/:id
/// - /files/*path
/// - /users(/)
/// - /users(/*path)
///
pub fn add(
&mut self,
route: impl AsRef<str>,
methods: Option<DavMethodSet>,
data: T,
) -> Result<&mut Self, regex::Error>
{
let route = route.as_ref();
// Might be a regexp
if route.starts_with("^") {
return self.add_re(route, methods, data);
}
// Ignore it if it does not start with /
if !route.starts_with("/") {
return Ok(self);
}
// First, replace special characters "()*" with unicode chars
// from the private-use area, so that we can then regex-escape
// the entire string.
let re_route = route
.chars()
.map(|c| {
match c {
'*' => '\u{e001}',
'(' => '\u{e002}',
')' => '\u{e003}',
'\u{e001}' => ' ',
'\u{e002}' => ' ',
'\u{e003}' => ' ',
c => c,
}
})
.collect::<String>();
let re_route = regex::escape(&re_route);
// Translate route expression into regexp.
// We do a simple transformation:
// :ident -> (?P<ident>[^/]*)
// *ident -> (?P<ident>.*)
// (text) -> (?:text|)
lazy_static! {
static ref COLON: Regex = Regex::new(":([a-zA-Z0-9]+)").unwrap();
static ref SPLAT: Regex = Regex::new("\u{e001}([a-zA-Z0-9]+)").unwrap();
static ref MAYBE: Regex = Regex::new("\u{e002}([^\u{e002}]*)\u{e003}").unwrap();
};
let mut re_route = re_route.into_bytes();
re_route = COLON.replace_all(&re_route, &b"(?P<$1>[^/]*)"[..]).to_vec();
re_route = SPLAT.replace_all(&re_route, &b"(?P<$1>.*)"[..]).to_vec();
re_route = MAYBE.replace_all(&re_route, &b"($1)?"[..]).to_vec();
// finalize regex.
let re_route = "^".to_string() + &String::from_utf8(re_route).unwrap() + "$";
self.add_re(&re_route, methods, data)
}
// add route as regular expression.
fn add_re(&mut self, s: &str, methods: Option<DavMethodSet>, data: T) -> Result<&mut Self, regex::Error> {
// Set flags: enable ". matches everything", disable strict unicode.
// We known 's' starts with "^", add it after that.
let s2 = format!("^(?s){}", &s[1..]);
let regex = Regex::new(&s2)?;
self.routes.push(Route { regex, methods, data });
Ok(self)
}
/// Combine all the routes and compile them into an internal RegexSet.
pub fn build(&mut self) -> Router<T> {
let set = RegexSet::new(self.routes.iter().map(|r| r.regex.as_str())).unwrap();
Router {
routes: std::mem::replace(&mut self.routes, Vec::new()),
set,
}
}
}
/// Dead simple HTTP router.
#[derive(Debug)]
pub struct Router<T: Debug> {
set: RegexSet,
routes: Vec<Route<T>>,
}
impl<T: Debug> Default for Router<T> {
fn default() -> Router<T> {
Router {
set: RegexSet::new(&[] as &[&str]).unwrap(),
routes: Vec::new(),
}
}
}
impl<T: Debug> Router<T> {
/// Return a builder.
pub fn builder() -> Builder<T> {
Builder { routes: Vec::new() }
}
/// See if the path matches a route in the set.
///
/// The names of the parameters you want to be returned need to be passed in as an array.
pub fn matches<'a>(
&self,
path: &'a [u8],
method: DavMethod,
param_names: &[&str],
) -> Vec<MatchedRoute<'_, 'a, T>>
{
let mut matched = Vec::new();
for idx in self.set.matches(path) {
let route = &self.routes[idx];
if route.methods.map(|m| m.contains(method)).unwrap_or(true) {
let mut params = Vec::new();
if let Some(caps) = route.regex.captures(path) {
for name in param_names {
params.push(caps.name(name).map(|p| Param(p)));
}
} else {
for _ in param_names {
params.push(None);
}
}
matched.push(MatchedRoute {
methods: route.methods,
params,
data: &route.data,
});
}
}
matched
}
}
#[cfg(test)]
mod tests {
use super::*;
use webdav_handler::DavMethod;
fn test_match(rtr: &Router<usize>, p: &[u8], user: &str, path: &str) {
let x = rtr.matches(p, DavMethod::Get, &["user", "path"]);
assert!(x.len() > 0);
let x = &x[0];
if user != "" {
assert!(x.params[0]
.as_ref()
.map(|b| b.as_bytes() == user.as_bytes())
.unwrap_or(false));
}
if path != "" {
assert!(x.params[1]
.as_ref()
.map(|b| b.as_bytes() == path.as_bytes())
.unwrap_or(false));
}
}
#[test]
fn test_router() -> Result<(), Box<dyn std::error::Error>> {
let rtr = Router::<usize>::builder()
.add("/", None, 1)?
.add("/users(/:user)", None, 2)?
.add("/files/*path", None, 3)?
.add("/files(/*path)", None, 4)?
.build();
test_match(&rtr, b"/", "", "");
test_match(&rtr, b"/users", "", "");
test_match(&rtr, b"/users/", "", "");
test_match(&rtr, b"/users/mike", "mike", "");
test_match(&rtr, b"/files/foo/bar", "", "foo/bar");
test_match(&rtr, b"/files", "", "");
Ok(())
}
}