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

Add optional path parameters #147

Merged
merged 1 commit into from
Sep 10, 2024
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Optional parameters are now supported in routes.

### Changed

- Successful matches now return a flattened representation of node data.
Expand Down
73 changes: 71 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ The goal of `wayfind` is to remain competitive with the fastest libraries, while
Dynamic parameters allow matching for any byte, excluding the path delimiter `/`.

We support both:
- Whole segment parameters: `/{name}/`
- Inline parameters: `/{year}-{month}-{day}/`
- whole segment parameters: `/{name}/`
- inline parameters: `/{year}-{month}-{day}/`

Inline dynamic parameters are greedy in nature, similar to a regex `.*`, and will attempt to match as many bytes as possible.

Expand Down Expand Up @@ -104,6 +104,75 @@ fn main() -> Result<(), Box<dyn Error>> {
}
```

### Optional Parameters

`wayfind` supports marking a parameter as optional.

We support:
- optional dynamic segments: `/users/{user?}`
- optional dynamic inlines: `/release/v{major}.{minor?}.{patch?}`
- optional wildcard segments: `/files/{*file?}/info`

Optional parameters work as syntactic sugar for equivilant, simplified routes.

`/users/{user?}`:
- `/users/{user}`
- `/users`

`/release/v{major}.{minor?}.{patch?}`:
- `/release/v{major}.{minor}.{patch}`
- `/release/v{major}.{minor}`
- `/release/v{major}`

`/files/{*file?}/info`:
- `/files/{*file}/info`
- `/files/info`

There is a small overhead to using optionals, due to `Arc` usage internally for data storage.

#### Example

```rust
use std::error::Error;
use wayfind::{Path, Router};

fn main() -> Result<(), Box<dyn Error>> {
let mut router = Router::new();
router.insert("/users/{id?}", 1)?;
router.insert("/files/{*slug}/{file}.{extension?}", 2)?;

let path = Path::new("/users")?;
let search = router.search(&path)?.unwrap();
assert_eq!(*search.data, 1);

let path = Path::new("/users/123")?;
let search = router.search(&path)?.unwrap();
assert_eq!(*search.data, 1);
assert_eq!(search.parameters[0].key, "id");
assert_eq!(search.parameters[0].value, "123");

let path = Path::new("/files/documents/folder/report.pdf")?;
let search = router.search(&path)?.unwrap();
assert_eq!(*search.data, 2);
assert_eq!(search.parameters[0].key, "slug");
assert_eq!(search.parameters[0].value, "documents/folder");
assert_eq!(search.parameters[1].key, "file");
assert_eq!(search.parameters[1].value, "report");
assert_eq!(search.parameters[2].key, "extension");
assert_eq!(search.parameters[2].value, "pdf");

let path = Path::new("/files/documents/folder/readme")?;
let search = router.search(&path)?.unwrap();
assert_eq!(*search.data, 2);
assert_eq!(search.parameters[0].key, "slug");
assert_eq!(search.parameters[0].value, "documents/folder");
assert_eq!(search.parameters[1].key, "file");
assert_eq!(search.parameters[1].value, "readme");

Ok(())
}
```

### Constraints

Constraints allow for custom logic to be injected into the routing process.
Expand Down
298 changes: 298 additions & 0 deletions src/expander.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
use crate::parser::{ParsedRoute, RoutePart, RouteParts};
use std::collections::VecDeque;

/// Represents a collection of expanded, simplified routes, derived from an original route.
#[derive(Debug, PartialEq, Eq)]
pub struct ExpandedRoutes {
pub raw: ParsedRoute,
pub routes: Vec<ParsedRoute>,
}

impl ExpandedRoutes {
pub fn new(route: ParsedRoute) -> Self {
let mut routes = VecDeque::new();
Self::recursive_expand(route.parts.0.clone(), VecDeque::new(), &mut routes);

let routes =
routes
.into_iter()
.map(|parts| {
// Handle special case, where optional is at the start of a route.
// Replace with a single "/" part.
if parts.0.iter().all(
|part| matches!(part, RoutePart::Static { prefix } if prefix.is_empty()),
) {
RouteParts(VecDeque::from(vec![RoutePart::Static {
prefix: b"/".to_vec(),
}]))
} else {
parts
}
})
.map(ParsedRoute::from)
.collect();

Self { raw: route, routes }
}

fn recursive_expand(
mut remaining: VecDeque<RoutePart>,
mut current: VecDeque<RoutePart>,
expanded: &mut VecDeque<RouteParts>,
) {
let Some(part) = remaining.pop_front() else {
expanded.push_back(RouteParts(current));
return;
};

match part {
RoutePart::Static { .. }
| RoutePart::Dynamic {
optional: false, ..
}
| RoutePart::Wildcard {
optional: false, ..
} => {
current.push_back(part);
Self::recursive_expand(remaining, current, expanded);
}
RoutePart::Dynamic { .. } | RoutePart::Wildcard { .. } => {
// Handle optional present case
let mut new_part = part.clone();
new_part.disable_optional();
let mut new_route = current.clone();
new_route.push_back(new_part);
Self::recursive_expand(remaining.clone(), new_route, expanded);

// Handle optional absent case
// TODO: Consider calculating this at insert time.
// Then we could handle this at the match level?
let is_segment = matches!(&part, RoutePart::Static { .. })
|| current.back().map_or(true, RoutePart::ends_with_slash)
|| remaining.front().map_or(true, RoutePart::starts_with_slash);

if is_segment {
// Trim the prior `/`, if exists.
if let Some(RoutePart::Static { prefix }) = current.back_mut() {
prefix.pop();
}

Self::recursive_expand(remaining, current, expanded);
} else {
// Trim any prior static characters.
let mut current = current.clone();
while let Some(last) = current.back() {
if matches!(last, RoutePart::Static { prefix } if !prefix.is_empty() && !last.ends_with_slash())
{
current.pop_back();
} else {
break;
}
}

expanded.push_back(RouteParts(current));
}
}
};
}
}

#[cfg(test)]
mod tests {
use super::*;
use similar_asserts::assert_eq;
use std::error::Error;

#[test]
fn test_expander_segment_simple() -> Result<(), Box<dyn Error>> {
let route = ParsedRoute::new(b"/users/{id?}")?;
let expanded = ExpandedRoutes::new(route.clone());

assert_eq!(
expanded,
ExpandedRoutes {
raw: route,
routes: vec![
ParsedRoute::new(b"/users/{id}")?,
ParsedRoute::new(b"/users")?
],
}
);

Ok(())
}

#[test]
fn test_expander_segment_simple_suffix() -> Result<(), Box<dyn Error>> {
let route = ParsedRoute::new(b"/users/{id?}/info")?;
let expanded = ExpandedRoutes::new(route.clone());

assert_eq!(
expanded,
ExpandedRoutes {
raw: route,
routes: vec![
ParsedRoute::new(b"/users/{id}/info")?,
ParsedRoute::new(b"/users/info")?
],
}
);

Ok(())
}

#[test]
fn test_expander_segment_multiple() -> Result<(), Box<dyn Error>> {
let route = ParsedRoute::new(b"/users/{id?}/{name?}")?;
let expanded = ExpandedRoutes::new(route.clone());

assert_eq!(
expanded,
ExpandedRoutes {
raw: route,
routes: vec![
ParsedRoute::new(b"/users/{id}/{name}")?,
ParsedRoute::new(b"/users/{id}")?,
ParsedRoute::new(b"/users/{name}")?,
ParsedRoute::new(b"/users")?,
]
}
);

Ok(())
}

#[test]
fn test_expander_segment_constraint() -> Result<(), Box<dyn Error>> {
let route = ParsedRoute::new(b"/users/{id?:int}")?;
let expanded = ExpandedRoutes::new(route.clone());

assert_eq!(
expanded,
ExpandedRoutes {
raw: route,
routes: vec![
ParsedRoute::new(b"/users/{id:int}")?,
ParsedRoute::new(b"/users")?,
]
}
);

Ok(())
}

#[test]
fn test_expander_segment_all_optional() -> Result<(), Box<dyn Error>> {
let route = ParsedRoute::new(b"/{category?}/{subcategory?}/{id?}")?;
let expanded = ExpandedRoutes::new(route.clone());

assert_eq!(
expanded,
ExpandedRoutes {
raw: route,
routes: vec![
ParsedRoute::new(b"/{category}/{subcategory}/{id}")?,
ParsedRoute::new(b"/{category}/{subcategory}")?,
ParsedRoute::new(b"/{category}/{id}")?,
ParsedRoute::new(b"/{category}")?,
ParsedRoute::new(b"/{subcategory}/{id}")?,
ParsedRoute::new(b"/{subcategory}")?,
ParsedRoute::new(b"/{id}")?,
ParsedRoute::new(b"/")?,
]
}
);

Ok(())
}

#[test]
fn test_expander_segment_mixed() -> Result<(), Box<dyn Error>> {
let route = ParsedRoute::new(b"/api/{version}/{resource}/{id?}/details")?;
let expanded = ExpandedRoutes::new(route.clone());

assert_eq!(
expanded,
ExpandedRoutes {
raw: route,
routes: vec![
ParsedRoute::new(b"/api/{version}/{resource}/{id}/details")?,
ParsedRoute::new(b"/api/{version}/{resource}/details")?,
]
}
);

Ok(())
}

#[test]
fn test_expander_inline_simple() -> Result<(), Box<dyn Error>> {
let route = ParsedRoute::new(b"/files/{name}.{extension?}")?;
let expanded = ExpandedRoutes::new(route.clone());

assert_eq!(
expanded,
ExpandedRoutes {
raw: route,
routes: vec![
ParsedRoute::new(b"/files/{name}.{extension}")?,
ParsedRoute::new(b"/files/{name}")?,
],
}
);

Ok(())
}

#[test]
fn test_expander_inline_multiple() -> Result<(), Box<dyn Error>> {
let route = ParsedRoute::new(b"/release/v{major}.{minor?}.{patch?}")?;
let expanded = ExpandedRoutes::new(route.clone());

assert_eq!(
expanded,
ExpandedRoutes {
raw: route,
routes: vec![
ParsedRoute::new(b"/release/v{major}.{minor}.{patch}")?,
ParsedRoute::new(b"/release/v{major}.{minor}")?,
ParsedRoute::new(b"/release/v{major}")?,
],
}
);

Ok(())
}

#[test]
fn test_expander_start() -> Result<(), Box<dyn Error>> {
let route = ParsedRoute::new(b"{id?}")?;
let expanded = ExpandedRoutes::new(route.clone());

assert_eq!(
expanded,
ExpandedRoutes {
raw: route,
routes: vec![ParsedRoute::new(b"{id}")?, ParsedRoute::new(b"/")?,],
}
);

Ok(())
}

#[test]
fn test_expander_start_slash() -> Result<(), Box<dyn Error>> {
let route = ParsedRoute::new(b"/{id?}")?;
let expanded = ExpandedRoutes::new(route.clone());

assert_eq!(
expanded,
ExpandedRoutes {
raw: route,
routes: vec![ParsedRoute::new(b"/{id}")?, ParsedRoute::new(b"/")?,],
}
);

Ok(())
}
}
Loading