Skip to content

Commit

Permalink
Add support for 'optional' parameters
Browse files Browse the repository at this point in the history
  • Loading branch information
CathalMullan committed Sep 10, 2024
1 parent 044463d commit 8a329ce
Show file tree
Hide file tree
Showing 11 changed files with 591 additions and 70 deletions.
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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,55 @@ fn main() -> Result<(), Box<dyn Error>> {
}
```

### Optional Parameters

`wayfind` supports optional parameters for both dynamic and wildcard routes.

TODO

#### 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/{*files}/{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, "files");
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, "files");
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
246 changes: 246 additions & 0 deletions src/expander.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
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;
};

if !part.is_optional() {
current.push_back(part);
Self::recursive_expand(remaining, current, expanded);
return;
}

let mut new_part = current.clone();
new_part.push_back(part.clone().disable_optional());
Self::recursive_expand(remaining.clone(), new_part, expanded);

if part.is_segment(current.back(), remaining.front()) {
if let Some(RoutePart::Static { prefix }) = current.back_mut() {
prefix.pop();
}

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

expanded.push_back(RouteParts(trimmed_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(())
}
}
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ pub(crate) mod decode;

pub mod errors;

pub(crate) mod expander;

pub(crate) mod node;
pub use node::search::{Match, Parameter};

Expand Down
11 changes: 8 additions & 3 deletions src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,21 @@ pub enum NodeKind {
pub enum NodeData<T> {
/// Data is stored inline.
Inline {
/// The original route path.
/// The original route.
route: Arc<str>,

/// The associated data.
value: T,
},

/// Data is stored at the router level, as it's shared between 2 or more nodes.
#[allow(dead_code)]
Reference(Arc<str>),
Reference {
/// The original route.
route: Arc<str>,

/// The expanded route.
expanded: Arc<str>,
},
}

/// Represents a node in the tree structure.
Expand Down
Loading

0 comments on commit 8a329ce

Please sign in to comment.