Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add catch all routes #197

Merged
merged 7 commits into from
Sep 5, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 28 additions & 94 deletions crates/router/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@

mod files;
mod route;

use files::Files;
use route::RouteAffinity;
use std::path::{Path, PathBuf};
use std::time::Instant;
use wws_config::Config;
Expand Down Expand Up @@ -49,28 +47,22 @@ impl Routes {
for route_path in route_paths {
routes.push(Route::new(path, route_path, &prefix, config));
}
routes.sort();
ereslibre marked this conversation as resolved.
Show resolved Hide resolved
println!("✅ Workers loaded in {:?}.", start.elapsed());

Self { routes, prefix }
}

/// Based on a set of routes and a given path, it provides the best
/// match based on the parametrized URL score. See the [`Route::can_manage_path`]
/// method to understand how to calculate the score.
/// Provides the **first** route that can handle the given path.
/// This only works because the routes are already sorted.
/// Because a '/a/b' route may be served by:
/// - /a/b.js
/// - /a/[id].js
/// - /[id]/b.wasm
/// - /[id]/[other].wasm
/// - /[id]/[..all].wasm
pub fn retrieve_best_route<'a>(&'a self, path: &str) -> Option<&'a Route> {
// Keep it to avoid calculating the score twice when iterating
// to look for the best route
let mut best_score = -1;

self.routes
.iter()
.fold(None, |acc, item| match item.affinity(path) {
RouteAffinity::CanManage(score) if best_score == -1 || score < best_score => {
best_score = score;
Some(item)
}
_ => acc,
})
self.routes.iter().find(|r| r.can_manage(path))
}

/// Defines a prefix in the context of the application.
Expand Down Expand Up @@ -108,90 +100,32 @@ impl Routes {
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;

#[test]
fn route_path_affinity() {
let build_route = |file: &str| -> Route {
let project_config = Config::default();
Route::new(
Path::new("../../tests/data/params"),
PathBuf::from(format!("../../tests/data/params{file}")),
"",
&project_config,
)
};

// Route initializes the Wasm module. We create these
// variables to avoid loading the same Wasm module multiple times
let param_route = build_route("/[id].wasm");
let fixed_route = build_route("/fixed.wasm");
let param_folder_route = build_route("/[id]/fixed.wasm");
let param_sub_route = build_route("/sub/[id].wasm");
fn retrieve_best_route() {
let project_config = Config::default();
let router = Routes::new(
Path::new("../../tests/data/params"),
"",
Vec::new(),
&project_config,
);

let tests = [
(&param_route, "/a", RouteAffinity::CanManage(1)),
(&fixed_route, "/fixed", RouteAffinity::CanManage(0)),
(&fixed_route, "/a", RouteAffinity::CannotManage),
(&param_folder_route, "/a", RouteAffinity::CannotManage),
(&param_folder_route, "/a/fixed", RouteAffinity::CanManage(1)),
(&param_sub_route, "/a/b", RouteAffinity::CannotManage),
(&param_sub_route, "/sub/b", RouteAffinity::CanManage(2)),
];

for t in tests {
assert_eq!(t.0.affinity(t.1), t.2);
}
}

#[test]
fn best_route_by_affinity() {
let build_route = |file: &str| -> Route {
let project_config = Config::default();
Route::new(
Path::new("../../tests/data/params"),
PathBuf::from(format!("../../tests/data/params{file}")),
"",
&project_config,
)
};

// Route initializes the Wasm module. We create these
// variables to avoid loading the same Wasm module multiple times
let param_route = build_route("/[id].wasm");
let fixed_route = build_route("/fixed.wasm");
let param_folder_route = build_route("/[id]/fixed.wasm");
let param_sub_route = build_route("/sub/[id].wasm");

// I'm gonna use this values for comparison as `routes` consumes
// the Route elements.
let param_path = param_route.path.clone();
let fixed_path = fixed_route.path.clone();
let param_folder_path = param_folder_route.path.clone();
let param_sub_path = param_sub_route.path.clone();

let routes = Routes {
routes: vec![
param_route,
fixed_route,
param_folder_route,
param_sub_route,
],
prefix: String::from("/"),
};

let tests = [
("/a", Some(param_path)),
("/fixed", Some(fixed_path)),
("/a/fixed", Some(param_folder_path)),
("/sub/b", Some(param_sub_path)),
("/any", Some("/[id]")),
("/fixed", Some("/fixed")),
("/any/fixed", Some("/[id]/fixed")),
("/any/sub", Some("/[id]/sub")),
("/sub/any", Some("/sub/[id]")),
("/sub/any/catch/all/routes", Some("/sub/[...all]")),
("/sub/sub/any/catch/all/routes", Some("/sub/sub/[...all]")),
("/donot/exist", None),
];

for t in tests {
let route = routes.retrieve_best_route(t.0);
for (given_path, expected_path) in tests {
let route = router.retrieve_best_route(given_path);

if let Some(path) = t.1 {
if let Some(path) = expected_path {
assert!(route.is_some());
assert_eq!(route.unwrap().path, path);
} else {
Expand Down
174 changes: 118 additions & 56 deletions crates/router/src/route.rs
Original file line number Diff line number Diff line change
@@ -1,30 +1,33 @@
// Copyright 2022 VMware, Inc.
// SPDX-License-Identifier: Apache-2.0
mod segment;

use lazy_static::lazy_static;
use regex::Regex;
use segment::Segment;
use std::{
cmp::Ordering,
cmp::Ordering::{Greater, Less},
ffi::OsStr,
path::{Component, Path, PathBuf},
};
use wws_config::Config as ProjectConfig;
use wws_worker::Worker;

lazy_static! {
static ref PARAMETER_REGEX: Regex = Regex::new(r"\[\w+\]").unwrap();
static ref DYNAMIC_ROUTE_REGEX: Regex = Regex::new(r".*\[\w+\].*").unwrap();
static ref PARAMETER_REGEX: Regex =
Regex::new(r"\[(?P<ellipsis>\.{3})?(?P<segment>\w+)\]").unwrap();
ereslibre marked this conversation as resolved.
Show resolved Hide resolved
}

/// Identify if a route can manage a certain URL and generates
/// a score in that case. This is required by dynamic routes as
/// different files can manage the same route. For example:
/// `/test` may be managed by `test.js` and `[id].js`. Regarding
/// the score, routes with a lower value will have a higher priority.
/// Represents the type of a route.
///
/// Each variant of this enum holds an associated `usize` value,
/// which represents the number of segments in the route's path.
#[derive(PartialEq, Eq, Debug)]
pub enum RouteAffinity {
CannotManage,
// Score
CanManage(i32),
pub enum RouteType {
Satic(usize),
ereslibre marked this conversation as resolved.
Show resolved Hide resolved
Dynamic(usize),
Tail(usize),
}

/// An existing route in the project. It contains a reference to the handler, the URL path,
Expand All @@ -42,6 +45,10 @@ pub struct Route {
pub handler: PathBuf,
/// The URL path
pub path: String,
/// The route type
pub route_type: RouteType,
/// The segments' URL path
pub segments: Vec<Segment>,
/// The associated worker
pub worker: Worker,
}
Expand All @@ -58,10 +65,12 @@ impl Route {
project_config: &ProjectConfig,
) -> Self {
let worker = Worker::new(base_path, &filepath, project_config).unwrap();

let route_path = Self::retrieve_route(base_path, &filepath, prefix);
Self {
path: Self::retrieve_route(base_path, &filepath, prefix),
handler: filepath,
route_type: Self::get_route_type(&route_path),
segments: Self::get_segments(&route_path),
path: route_path,
worker,
}
}
Expand Down Expand Up @@ -109,63 +118,116 @@ impl Route {
.collect()
}

/// Check if the given path can be managed by this worker. This was introduced
/// to support parameters in the URLs. Note that this method returns an integer,
/// which means the priority for this route.
///
/// Note that a /a/b route may be served by:
/// - /a/b.js
/// - /a/[id].js
/// - /[id]/b.wasm
/// - /[id]/[other].wasm
///
/// We need to establish a priority. The lower of the returned number,
/// the more priority it has. This number is calculated based on the number of used
/// parameters, as fixed routes has more priority than parameted ones.
///
/// To avoid collisions like `[id]/b.wasm` vs `/a/[id].js`. Every depth level will
/// add an extra +1 to the score. So, in case of `[id]/b.wasm` vs `/a/[id].js`,
/// the /a/b path will be managed by `[id]/b.wasm`
///
/// In case it cannot manage it, it will return -1
pub fn affinity(&self, url_path: &str) -> RouteAffinity {
let mut score: i32 = 0;
let mut split_path = self.path.split('/').peekable();
/// Determine the type of route based on the provided route path.
fn get_route_type(route_path: &str) -> RouteType {
let path_segments_count = route_path.chars().filter(|&c| c == '/').count();
if route_path.contains("/[...") {
RouteType::Tail(path_segments_count)
} else if route_path.contains("/[") {
RouteType::Dynamic(path_segments_count)
} else {
RouteType::Satic(path_segments_count)
}
}

for (depth, portion) in url_path.split('/').enumerate() {
match split_path.next() {
Some(el) if el == portion => continue,
Some(el) if PARAMETER_REGEX.is_match(el) => {
score += depth as i32;
continue;
/// Parse the route path into individual segments and determine their types.
///
/// This function parses the provided route path into individual segments and identifies
/// whether each segment is static, dynamic, or tail based on certain patterns.
fn get_segments(route_path: &str) -> Vec<Segment> {
route_path
.split('/')
.skip(1)
.into_iter()
.map(|segment| {
if segment.starts_with("[...") {
Segment::Tail(segment.to_owned())
} else if segment.contains("[") {
Segment::Dynamic(segment.to_owned())
} else {
Segment::Satic(segment.to_owned())
}
mtt-artis marked this conversation as resolved.
Show resolved Hide resolved
_ => return RouteAffinity::CannotManage,
}
}
})
.collect()
}

// I should check the other iterator to confirm is empty
if split_path.peek().is_none() {
RouteAffinity::CanManage(score)
} else {
// The split path iterator still have some entries.
RouteAffinity::CannotManage
/// Check if the given path can be managed by this worker. This was introduced
/// to support parameters in the URLs.
/// Dertermine the 'RouteType' allow to shortcut the comparaison.
pub fn can_manage(&self, path: &str) -> bool {
let path_segments_count = path.chars().filter(|&c| c == '/').count();

match self.route_type {
RouteType::Satic(_) => self.path == path,
RouteType::Dynamic(segments_count) if segments_count != path_segments_count => false,
RouteType::Tail(segments_count) if segments_count > path_segments_count => false,
_ => path
.split("/")
.skip(1)
.zip(self.segments.iter())
.all(|zip| match zip {
(sp, Segment::Satic(segment)) => sp == segment,
_ => true,
}),
}
}

/// Returns the given path with the actix format. For dynamic routing
/// we are using `[]` in the filenames. However, actix expects a `{}`
/// format for parameters.
pub fn actix_path(&self) -> String {
// Replace [] with {} for making the path compatible with
let mut formatted = self.path.replace('[', "{");
formatted = formatted.replace(']', "}");

formatted
PARAMETER_REGEX
.replace_all(&self.path, |caps: &regex::Captures| {
match (caps.name("ellipsis"), caps.name("segment")) {
(Some(_), Some(segment)) => format!("{{{}:.*}}", segment.as_str()),
(_, Some(segment)) => format!("{{{}}}", segment.as_str()),
_ => String::new(),
}
})
.into()
}

/// Check if the current route is dynamic
pub fn is_dynamic(&self) -> bool {
DYNAMIC_ROUTE_REGEX.is_match(&self.path)
match self.route_type {
RouteType::Satic(_) => false,
RouteType::Dynamic(_) => true,
RouteType::Tail(_) => true,
}
}
}

impl Ord for Route {
fn cmp(&self, other: &Self) -> Ordering {
match (&self.route_type, &other.route_type) {
(RouteType::Satic(a), RouteType::Satic(b)) => a.cmp(b),
(RouteType::Satic(_), _) => Less,
(_, RouteType::Satic(_)) => Greater,
(RouteType::Dynamic(a), RouteType::Dynamic(b)) if a == b => {
self.segments.cmp(&other.segments)
}
(RouteType::Dynamic(a), RouteType::Dynamic(b)) => a.cmp(b),
(RouteType::Dynamic(_), _) => Less,
(_, RouteType::Dynamic(_)) => Greater,
(RouteType::Tail(a), RouteType::Tail(b)) if a == b => {
self.segments.cmp(&other.segments)
}
(RouteType::Tail(a), RouteType::Tail(b)) => b.cmp(a),
}
}
}

impl PartialOrd for Route {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}

impl Eq for Route {}

impl PartialEq for Route {
fn eq(&self, other: &Self) -> bool {
self.path == other.path
}
}
ereslibre marked this conversation as resolved.
Show resolved Hide resolved

Expand Down
Loading