Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ pub mod microbench {
let mut get = Router::new();
get.insert("/hello", 0usize).expect("static route");
get.insert("/items/:id", 1usize).expect("param route");
let mut all_paths = Router::new();
all_paths
.insert("/hello", crate::state::MethodMask::from_method("GET"))
.expect("static all_paths");
all_paths
.insert("/items/:id", crate::state::MethodMask::from_method("GET"))
.expect("param all_paths");
CompiledRouters {
get,
post: Router::new(),
Expand All @@ -44,6 +51,7 @@ pub mod microbench {
delete: Router::new(),
options: Router::new(),
websocket: Router::new(),
all_paths,
}
}

Expand Down Expand Up @@ -433,6 +441,10 @@ impl App {
m.insert(&path, idx)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{e}")))?;
}
{
let mut masks = st.path_method_masks.lock();
masks.entry(path).or_default().insert_method(&method);
}
// Keep auto-compiled routing snapshots fresh when routes are added before explicit freeze().
st.compiled = None;
Ok(())
Expand Down
147 changes: 74 additions & 73 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,55 @@ use matchit::Router;
use parking_lot::Mutex;
use pyo3::prelude::*;

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct MethodMask(pub u8);

impl MethodMask {
pub const GET: u8 = 1 << 0;
pub const HEAD: u8 = 1 << 1;
pub const POST: u8 = 1 << 2;
pub const PUT: u8 = 1 << 3;
pub const PATCH: u8 = 1 << 4;
pub const DELETE: u8 = 1 << 5;
pub const OPTIONS: u8 = 1 << 6;

pub fn from_method(method: &str) -> Self {
match method {
"GET" => Self(Self::GET | Self::HEAD),
"HEAD" => Self(Self::HEAD),
"POST" => Self(Self::POST),
"PUT" => Self(Self::PUT),
"PATCH" => Self(Self::PATCH),
"DELETE" => Self(Self::DELETE),
"OPTIONS" => Self(Self::OPTIONS),
_ => Self(0),
}
}

pub fn insert_method(&mut self, method: &str) {
self.0 |= Self::from_method(method).0;
}

pub fn to_vec(self) -> Vec<String> {
const ORDER: [(&str, u8); 7] = [
("GET", MethodMask::GET),
("HEAD", MethodMask::HEAD),
("POST", MethodMask::POST),
("PUT", MethodMask::PUT),
("PATCH", MethodMask::PATCH),
("DELETE", MethodMask::DELETE),
("OPTIONS", MethodMask::OPTIONS),
];
let mut out = Vec::with_capacity(7);
for (name, flag) in ORDER {
if (self.0 & flag) != 0 {
out.push(name.to_string());
}
}
out
}
}

/// Immutable route tables built at [`AppState::freeze`](AppState) time so the request
/// path can be matched without per-method `Mutex` locks (issue #4).
pub struct CompiledRouters {
Expand All @@ -15,6 +64,7 @@ pub struct CompiledRouters {
pub delete: Router<usize>,
pub options: Router<usize>,
pub websocket: Router<usize>,
pub all_paths: Router<MethodMask>,
}

fn router_for_compiled<'a>(c: &'a CompiledRouters, method: &str) -> Option<&'a Router<usize>> {
Expand Down Expand Up @@ -117,6 +167,8 @@ pub struct AppState {
pub security_headers: Option<Py<PyAny>>,
/// Global connection pool for the Postgres database.
pub db_pool: Option<sqlx::PgPool>,
/// Bitmask of allowed HTTP methods per registered path template.
pub path_method_masks: Mutex<std::collections::HashMap<String, MethodMask>>,
}

impl AppState {
Expand Down Expand Up @@ -146,6 +198,7 @@ impl AppState {
cors: None,
security_headers: None,
db_pool: None,
path_method_masks: Mutex::new(std::collections::HashMap::new()),
}
}

Expand All @@ -172,6 +225,10 @@ impl AppState {

/// Clone current mutex-protected [`Router`]s into a snapshot (used at freeze / tests).
pub fn snapshot_routers(&self) -> CompiledRouters {
let mut all_paths = Router::new();
for (path, mask) in self.path_method_masks.lock().iter() {
let _ = all_paths.insert(path, *mask);
}
CompiledRouters {
get: self.get.lock().clone(),
post: self.post.lock().clone(),
Expand All @@ -180,6 +237,7 @@ impl AppState {
delete: self.delete.lock().clone(),
options: self.options.lock().clone(),
websocket: self.websocket.lock().clone(),
all_paths,
}
}
}
Expand Down Expand Up @@ -235,6 +293,12 @@ pub fn match_route_compiled(

/// All HTTP methods that match `path` in a precomputed [`CompiledRouters`] (lock-free 405 list).
pub fn methods_matching_path_compiled(compiled: &CompiledRouters, path: &str) -> Vec<String> {
if let Ok(m) = compiled.all_paths.at(path) {
let v = m.value.to_vec();
if !v.is_empty() {
return v;
}
}
const ORDER: [&str; 7] = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
let mut have = [false; 7];
if compiled.get.at(path).is_ok() {
Expand Down Expand Up @@ -286,77 +350,16 @@ pub fn map_method_router<'a>(
/// [1]: https://www.rfc-editor.org/rfc/rfc9110#name-405-method-not-allowed
#[cfg(test)]
fn methods_matching_path(state: &AppState, path: &str) -> Vec<String> {
const ORDER: [&str; 7] = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
let mut have = [false; 7];
if let Some(c) = &state.compiled {
if c.get.at(path).is_ok() {
have[0] = true;
have[1] = true;
}
if c.post.at(path).is_ok() {
have[2] = true;
}
if c.put.at(path).is_ok() {
have[3] = true;
}
if c.patch.at(path).is_ok() {
have[4] = true;
}
if c.delete.at(path).is_ok() {
have[5] = true;
}
if c.options.at(path).is_ok() {
have[6] = true;
}
methods_matching_path_compiled(c, path)
} else {
{
let g = state.get.lock();
if g.at(path).is_ok() {
have[0] = true;
have[1] = true;
}
}
{
let r = state.post.lock();
if r.at(path).is_ok() {
have[2] = true;
}
}
{
let r = state.put.lock();
if r.at(path).is_ok() {
have[3] = true;
}
}
{
let r = state.patch.lock();
if r.at(path).is_ok() {
have[4] = true;
}
}
{
let r = state.delete.lock();
if r.at(path).is_ok() {
have[5] = true;
}
}
{
let r = state.options.lock();
if r.at(path).is_ok() {
have[6] = true;
}
}
let compiled = state.snapshot_routers();
methods_matching_path_compiled(&compiled, path)
}
ORDER
.iter()
.zip(have)
.filter(|(_, ok)| *ok)
.map(|(m, _)| (*m).to_string())
.collect()
}

/// Returns route index and path params, or `None` if the method is unsupported; `Some(None)` if
/// no match; `Some(Some)` on success. Uses [`CompiledRouters`] when set (lock-free).
/// method is valid but path did not match.
#[cfg(test)]
#[allow(clippy::type_complexity)]
fn match_route(
Expand All @@ -365,14 +368,7 @@ fn match_route(
path: &str,
) -> Option<Option<(usize, Vec<(String, String)>)>> {
if let Some(c) = &state.compiled {
let g = router_for_compiled(c, method)?;
return Some(g.at(path).ok().map(|m| {
let mut pmap = Vec::new();
for (k, v) in m.params.iter() {
pmap.push((k.to_string(), v.to_string()));
}
(*m.value, pmap)
}));
return match_route_compiled(c, method, path);
}
let g = map_method_router(state, method)?;
Some(g.at(path).ok().map(|m| {
Expand Down Expand Up @@ -414,6 +410,11 @@ mod tests {
fn methods_matching_path_uses_compiled() {
let mut s = AppState::new();
s.post.lock().insert("/x", 0usize).unwrap();
s.path_method_masks
.lock()
.entry("/x".to_string())
.or_default()
.insert_method("POST");
s.compiled = Some(Arc::new(s.snapshot_routers()));
let m = methods_matching_path(&s, "/x");
assert_eq!(m, vec!["POST".to_string()]);
Expand Down
Loading