add router core

This commit is contained in:
Adrian Wannenmacher committed 2022-12-03 21:49:16 +01:00
1 parent 1e99e35572
commit 1f1c49b9c9
22 files changed
+5005 -1

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
use std::collections::HashMap;
use urlencoding::encode;
use crate::{
segments::{NameMap, NamedSegment},
Name,
};
pub fn resolve_name(
map: &NameMap,
name: &Name,
parameters: &HashMap<Name, String>,
) -> Option<String> {
debug_assert!(
map.contains_key(&name),
"named navigation to unknown name: {name}"
);
let target = map.get(&name)?;
let mut res = String::new();
for t in target {
res += "/";
match t {
NamedSegment::Fixed(f) => res += f,
NamedSegment::Parameter(p) => {
debug_assert!(
parameters.contains_key(p),
"named navigation is missing parameter: target {name} parameter {p}"
);
let val = parameters.get(p)?;
res += &encode(val);
}
}
}
if res.is_empty() {
res += "/";
}
Some(res)
}
#[cfg(test)]
mod tests {
use crate::{
prelude::RootIndex,
routes::{ParameterRoute, Route, Segment},
};
use super::*;
fn test_map() -> NameMap {
NamedSegment::from_segment(
&Segment::<&str>::empty()
.fixed(
"fixed",
Route::empty().name::<u8>().nested(
Segment::empty().catch_all(ParameterRoute::empty::<u16>().name::<u32>()),
),
)
.catch_all(ParameterRoute::empty::<i8>().name::<i16>()),
)
}
#[test]
fn root_index() {
assert_eq!(
resolve_name(&test_map(), &Name::of::<RootIndex>(), &HashMap::new()),
Some(String::from("/"))
)
}
#[test]
fn fixed() {
assert_eq!(
resolve_name(&test_map(), &Name::of::<u8>(), &HashMap::new()),
Some(String::from("/fixed"))
)
}
#[test]
fn matching() {
assert_eq!(
resolve_name(&test_map(), &Name::of::<i16>(), &{
let mut r = HashMap::new();
r.insert(Name::of::<i8>(), String::from("test"));
r
}),
Some(String::from("/test"))
);
}
#[test]
fn nested() {
assert_eq!(
resolve_name(&test_map(), &Name::of::<u32>(), &{
let mut r = HashMap::new();
r.insert(Name::of::<u16>(), String::from("nested"));
r
}),
Some(String::from("/fixed/nested"))
);
}
#[test]
#[should_panic = "named navigation to unknown name: bool"]
#[cfg(debug_assertions)]
fn missing_name_debug() {
resolve_name(&test_map(), &Name::of::<bool>(), &HashMap::new());
}
#[test]
#[cfg(not(debug_assertions))]
fn missing_name_release() {
assert!(resolve_name(&test_map(), &Name::of::<bool>(), &HashMap::new()).is_none());
}
#[test]
#[should_panic = "named navigation is missing parameter: target u32 parameter u16"]
#[cfg(debug_assertions)]
fn missing_parameter_debug() {
resolve_name(&test_map(), &Name::of::<u32>(), &HashMap::new());
}
#[test]
#[cfg(not(debug_assertions))]
fn missing_parameter_release() {
assert!(resolve_name(&test_map(), &Name::of::<u32>(), &HashMap::new()).is_none());
}
#[test]
fn url_encoding() {
assert_eq!(
resolve_name(&test_map(), &Name::of::<u32>(), &{
let mut r = HashMap::new();
r.insert(Name::of::<u16>(), String::from("🥳"));
r
}),
Some(String::from("/fixed/%F0%9F%A5%B3"))
);
}
}
+489
View File
@@ -0,0 +1,489 @@
use either::Either;
use urlencoding::decode;
use crate::{
navigation::NavigationTarget,
routes::{ParameterRoute, Route, RouteContent, Segment},
RouterState,
};
pub fn route_segment<T: Clone>(
segment: &Segment<T>,
values: &[&str],
state: RouterState<T>,
) -> Either<RouterState<T>, NavigationTarget> {
route_segment_internal(segment, values, state, None, false)
}
fn route_segment_internal<T: Clone>(
segment: &Segment<T>,
values: &[&str],
state: RouterState<T>,
mut fallback: Option<RouteContent<T>>,
mut clear_fallback: bool,
) -> Either<RouterState<T>, NavigationTarget> {
// fallback
if let Some(fb) = &segment.fallback {
fallback = Some(fb.clone());
}
if let Some(clear) = &segment.clear_fallback {
clear_fallback = *clear;
}
// index route
if values.is_empty() {
if let Some(c) = &segment.index {
return merge(state, c.clone());
}
return Either::Left(state);
}
// fixed route
if let Some(r) = segment.fixed.get(values[0]) {
return merge_route(values, r, state, fallback, clear_fallback);
}
// matching routes
for (m, r) in &segment.matching {
if m.matches(values[0]) {
return merge_parameter_route(values, r, state, fallback, clear_fallback);
}
}
// catchall
if let Some(c) = &segment.catch_all {
return merge_parameter_route(values, c.as_ref(), state, fallback, clear_fallback);
}
merge_fallback(state, fallback, clear_fallback)
}
fn merge<T: Clone>(
mut state: RouterState<T>,
content: RouteContent<T>,
) -> Either<RouterState<T>, NavigationTarget> {
match content {
RouteContent::Content(c) => state.content.push(c),
RouteContent::Redirect(t) => return Either::Right(t),
RouteContent::MultiContent { main, named } => {
if let Some(main) = main {
state.content.push(main);
}
for (name, content) in named {
state.named_content.entry(name).or_default().push(content);
}
}
}
Either::Left(state)
}
fn merge_route<T: Clone>(
values: &[&str],
route: &Route<T>,
mut state: RouterState<T>,
fallback: Option<RouteContent<T>>,
clear_fallback: bool,
) -> Either<RouterState<T>, NavigationTarget> {
// merge content
if let Some(c) = &route.content {
match merge(state, c.clone()) {
Either::Left(s) => state = s,
Either::Right(t) => return Either::Right(t),
}
}
if let Some(n) = &route.name {
state.names.insert(n.clone());
}
match (&route.nested, values.is_empty()) {
(Some(n), _) => route_segment_internal(n, &values[1..], state, fallback, clear_fallback),
(None, false) => merge_fallback(state, fallback, clear_fallback),
_ => Either::Left(state),
}
}
fn merge_parameter_route<T: Clone>(
values: &[&str],
route: &ParameterRoute<T>,
mut state: RouterState<T>,
fallback: Option<RouteContent<T>>,
clear_fallback: bool,
) -> Either<RouterState<T>, NavigationTarget> {
// merge content
if let Some(c) = &route.content {
match merge(state, c.clone()) {
Either::Left(s) => state = s,
Either::Right(t) => return Either::Right(t),
}
}
if let Some(n) = &route.name {
state.names.insert(n.clone());
}
state.parameters.insert(
route.key.clone(),
decode(values[0]).unwrap(/* string already is UTF-8 */).into_owned(),
);
match (&route.nested, values.is_empty()) {
(Some(n), _) => route_segment_internal(n, &values[1..], state, fallback, clear_fallback),
(None, false) => merge_fallback(state, fallback, clear_fallback),
_ => Either::Left(state),
}
}
fn merge_fallback<T: Clone>(
mut state: RouterState<T>,
fallback: Option<RouteContent<T>>,
clear_fallback: bool,
) -> Either<RouterState<T>, NavigationTarget> {
// fallback clearing
if clear_fallback {
state.content.clear();
state.names.clear();
state.parameters.clear();
}
// fallback content
match fallback {
Some(fallback) => merge(state, fallback),
None => Either::Left(state),
}
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, HashMap, HashSet};
use crate::{
routes::{multi, ContentAtom},
Name,
};
use super::*;
fn test_segment() -> Segment<&'static str> {
Segment::content(ContentAtom("index"))
.fixed("fixed", Route::content(ContentAtom("fixed")).name::<bool>())
.matching(
String::from("matching"),
ParameterRoute::content::<u8>(ContentAtom("matching"))
.nested(Segment::empty().fixed("nested", ContentAtom("matching nested"))),
)
.catch_all(
ParameterRoute::content::<u16>(ContentAtom("catch all"))
.nested(Segment::empty().fixed("nested", ContentAtom("catch all nested"))),
)
.fixed(
"nested",
Route::content(ContentAtom("nested")).name::<u32>().nested(
Segment::content(ContentAtom("nested index"))
.fixed("again", ContentAtom("nested again")),
),
)
.fixed("redirect", "/redirect")
.fixed(
"fallback",
Route::content(ContentAtom("fallback")).nested(
Segment::empty()
.fixed(
"keep",
Route::content(ContentAtom("keep route")).nested(
Segment::content(ContentAtom("keep index"))
.fallback(ContentAtom("keep")),
),
)
.fixed(
"clear",
Route::content(ContentAtom("clear route")).nested(
Segment::empty()
.fallback(ContentAtom("clear"))
.clear_fallback(true),
),
),
),
)
.fixed(
"no_fallback",
Route::content(ContentAtom("no fallback")).nested(
Segment::empty()
.fixed(
"keep",
Route::content(ContentAtom("keep route"))
.nested(Segment::empty().clear_fallback(false)),
)
.fixed(
"clear",
Route::content(ContentAtom("clear route"))
.nested(Segment::empty().clear_fallback(true)),
),
),
)
.fixed(
"named_content",
Route::content(
multi(None)
.add_named::<i8>(ContentAtom("1"))
.add_named::<i16>(ContentAtom("2")),
)
.nested(Segment::content(multi(Some(ContentAtom("3"))))),
)
}
#[test]
fn route_index() {
let state = route_segment(
&test_segment(),
&[],
RouterState {
path: String::from("/"),
can_go_back: false,
can_go_forward: true,
..Default::default()
},
);
assert!(state.is_left());
let state = state.unwrap_left();
assert_eq!(state.content, vec![ContentAtom("index")]);
assert!(state.names.is_empty());
assert!(state.parameters.is_empty());
assert_eq!(state.path, String::from("/"));
assert_eq!(state.can_go_back, false);
assert_eq!(state.can_go_forward, true);
}
#[test]
fn route_fixed() {
let state = route_segment(&test_segment(), &["fixed"], Default::default());
assert!(state.is_left());
let state = state.unwrap_left();
assert_eq!(state.content, vec![ContentAtom("fixed")]);
assert_eq!(state.names, {
let mut r = HashSet::new();
r.insert(Name::of::<bool>());
r
});
assert!(state.parameters.is_empty());
}
#[test]
fn route_matching() {
let state = route_segment(&test_segment(), &["matching"], Default::default());
assert!(state.is_left());
let state = state.unwrap_left();
assert_eq!(state.content, vec![ContentAtom("matching")]);
assert!(state.names.is_empty());
assert_eq!(state.parameters, {
let mut r = HashMap::new();
r.insert(Name::of::<u8>(), String::from("matching"));
r
});
}
#[test]
fn route_matching_nested() {
let state = route_segment(&test_segment(), &["matching", "nested"], Default::default());
assert!(state.is_left());
let state = state.unwrap_left();
assert_eq!(
state.content,
vec![ContentAtom("matching"), ContentAtom("matching nested")]
);
assert!(state.names.is_empty());
assert_eq!(state.parameters, {
let mut r = HashMap::new();
r.insert(Name::of::<u8>(), String::from("matching"));
r
});
}
#[test]
fn route_catch_all() {
let state = route_segment(&test_segment(), &["invalid"], Default::default());
assert!(state.is_left());
let state = state.unwrap_left();
assert_eq!(state.content, vec![ContentAtom("catch all")]);
assert!(state.names.is_empty());
assert_eq!(state.parameters, {
let mut r = HashMap::new();
r.insert(Name::of::<u16>(), String::from("invalid"));
r
});
}
#[test]
fn route_catch_all_nested() {
let state = route_segment(&test_segment(), &["invalid", "nested"], Default::default());
assert!(state.is_left());
let state = state.unwrap_left();
assert_eq!(
state.content,
vec![ContentAtom("catch all"), ContentAtom("catch all nested")]
);
assert!(state.names.is_empty());
assert_eq!(state.parameters, {
let mut r = HashMap::new();
r.insert(Name::of::<u16>(), String::from("invalid"));
r
});
}
#[test]
fn route_nested_index() {
let state = route_segment(&test_segment(), &["nested"], Default::default());
assert!(state.is_left());
let state = state.unwrap_left();
assert_eq!(
state.content,
vec![ContentAtom("nested"), ContentAtom("nested index")]
);
assert_eq!(state.names, {
let mut r = HashSet::new();
r.insert(Name::of::<u32>());
r
});
assert!(state.parameters.is_empty());
}
#[test]
fn route_nested_again() {
let state = route_segment(&test_segment(), &["nested", "again"], Default::default());
assert!(state.is_left());
let state = state.unwrap_left();
assert_eq!(
state.content,
vec![ContentAtom("nested"), ContentAtom("nested again")]
);
assert_eq!(state.names, {
let mut r = HashSet::new();
r.insert(Name::of::<u32>());
r
});
assert!(state.parameters.is_empty());
}
#[test]
fn route_redirect() {
let state = route_segment(&test_segment(), &["redirect"], Default::default());
assert_eq!(state.unwrap_right(), "/redirect".into());
}
#[test]
fn route_fallback_keep() {
let state = route_segment(
&test_segment(),
&["fallback", "keep", "invalid"],
Default::default(),
);
assert!(state.is_left());
let state = state.unwrap_left();
assert_eq!(
state.content,
vec![
ContentAtom("fallback"),
ContentAtom("keep route"),
ContentAtom("keep")
]
);
assert!(state.names.is_empty());
assert!(state.parameters.is_empty());
}
#[test]
fn route_fallback_clear() {
let state = route_segment(
&test_segment(),
&["fallback", "clear", "invalid"],
Default::default(),
);
assert!(state.is_left());
let state = state.unwrap_left();
assert_eq!(state.content, vec![ContentAtom("clear")]);
assert!(state.names.is_empty());
assert!(state.parameters.is_empty());
}
#[test]
fn route_named_content() {
let state = route_segment(&test_segment(), &["named_content"], Default::default());
assert!(state.is_left());
let state = state.unwrap_left();
assert_eq!(state.content, vec![ContentAtom("3")]);
assert_eq!(state.named_content, {
let mut r = BTreeMap::new();
r.insert(Name::of::<i8>(), vec![ContentAtom("1")]);
r.insert(Name::of::<i16>(), vec![ContentAtom("2")]);
r
});
assert!(state.names.is_empty());
assert!(state.parameters.is_empty());
}
#[test]
#[ignore = "not yet implemented"]
fn no_fallback() {
let state = route_segment(
&test_segment(),
&["no_fallback", "keep", "invalid"],
Default::default(),
);
assert!(state.is_left());
let state = state.unwrap_left();
assert_eq!(
state.content,
vec![
ContentAtom("fallback"),
ContentAtom("keep route"),
ContentAtom("keep")
]
);
assert!(state.names.is_empty());
assert!(state.parameters.is_empty());
}
#[test]
#[ignore = "not yet implemented"]
fn no_fallback_with_clearing() {
let state = route_segment(
&test_segment(),
&["fallback", "clear", "invalid"],
Default::default(),
);
assert!(state.is_left());
let state = state.unwrap_left();
assert!(state.content.is_empty());
assert!(state.names.is_empty());
assert!(state.parameters.is_empty());
}
#[test]
fn url_encoding() {
let state = route_segment(&test_segment(), &["%F0%9F%A5%B3"], Default::default());
assert!(state.is_left());
let state = state.unwrap_left();
assert_eq!(state.content, vec![ContentAtom("catch all")]);
assert!(state.names.is_empty());
assert_eq!(state.parameters, {
let mut r = HashMap::new();
r.insert(Name::of::<u16>(), "🥳".to_string());
r
});
}
}
+147
View File
@@ -0,0 +1,147 @@
use std::collections::BTreeMap;
use urlencoding::encode;
use crate::{routes::Segment, Name};
pub fn gen_sitemap<T: Clone>(seg: &Segment<T>, current: &str, map: &mut Vec<String>) {
for (p, r) in &seg.fixed {
let current = format!("{current}/{p}");
map.push(current.clone());
if let Some(n) = &r.nested {
gen_sitemap(n, &current, map);
}
}
for (_, r) in &seg.matching {
let current = format!("{current}/\\{}", r.key);
map.push(current.clone());
if let Some(n) = &r.nested {
gen_sitemap(n, &current, map);
}
}
if let Some(r) = &seg.catch_all {
let current = format!("{current}/\\{}", r.key);
map.push(current.clone());
if let Some(n) = &r.nested {
gen_sitemap(n, &current, map)
}
}
}
pub fn gen_parameter_sitemap<T: Clone>(
seg: &Segment<T>,
parameters: &BTreeMap<Name, Vec<String>>,
current: &str,
map: &mut Vec<String>,
) {
for (p, r) in &seg.fixed {
let current = format!("{current}/{p}");
map.push(current.clone());
if let Some(n) = &r.nested {
gen_parameter_sitemap(n, parameters, &current, map);
}
}
for (m, r) in &seg.matching {
if let Some(rp) = parameters.get(&r.key) {
for p in rp {
if m.matches(p) {
let current = format!("{current}/{}", encode(p).into_owned());
map.push(current.clone());
if let Some(n) = &r.nested {
gen_parameter_sitemap(n, parameters, &current, map);
}
}
}
}
}
if let Some(r) = &seg.catch_all {
if let Some(rp) = parameters.get(&r.key) {
for p in rp {
let current = format!("{current}/{}", encode(p).into_owned());
map.push(current.clone());
if let Some(n) = &r.nested {
gen_parameter_sitemap(n, parameters, &current, map);
}
}
}
}
}
#[cfg(test)]
mod tests {
use crate::routes::{ParameterRoute, Route};
use super::*;
fn test_segment() -> Segment<&'static str> {
Segment::empty()
.fixed(
"fixed",
Route::empty().nested(Segment::empty().fixed("nested", Route::empty())),
)
.matching(
String::from("m1"),
ParameterRoute::empty::<u8>().nested(
Segment::empty().matching(String::from("n2"), ParameterRoute::empty::<u16>()),
),
)
.matching(String::from("no match"), ParameterRoute::empty::<u32>())
.matching(String::from("no parameter"), ParameterRoute::empty::<u64>())
.catch_all(
ParameterRoute::empty::<u32>()
.nested(Segment::empty().catch_all(ParameterRoute::empty::<u16>())),
)
}
#[test]
fn sitemap() {
let mut result = Vec::new();
result.push(String::from("/"));
gen_sitemap(&test_segment(), "", &mut result);
assert_eq!(
result,
vec![
"/",
"/fixed",
"/fixed/nested",
"/\\u8",
"/\\u8/\\u16",
"/\\u32",
"/\\u64",
"/\\u32",
"/\\u32/\\u16"
]
);
}
#[test]
fn sitemap_with_parameters() {
let mut parameters = BTreeMap::new();
parameters.insert(Name::of::<u8>(), vec!["m1".to_string(), "m2".to_string()]);
parameters.insert(Name::of::<u16>(), vec!["n1".to_string(), "n2".to_string()]);
parameters.insert(Name::of::<u32>(), vec!["catch all".to_string()]);
let mut result = Vec::new();
result.push(String::from("/"));
gen_parameter_sitemap(&test_segment(), &parameters, "", &mut result);
assert_eq!(
result,
vec![
"/",
"/fixed",
"/fixed/nested",
"/m1",
"/m1/n2",
"/catch%20all",
"/catch%20all/n1",
"/catch%20all/n2"
]
);
}
}
+120
View File
@@ -0,0 +1,120 @@
use either::Either;
use crate::{
navigation::{NavigationTarget, Query},
segments::NameMap,
Name,
};
use super::resolve_name;
pub fn resolve_target(
names: &NameMap,
target: &NavigationTarget,
) -> Either<Either<String, Name>, String> {
match target {
NavigationTarget::Internal(i) => Either::Left(Either::Left(i.clone())),
NavigationTarget::Named {
name,
parameters,
query,
} => Either::Left(
resolve_name(names, name, parameters)
.map(|mut p| {
if let Some(q) = query {
match q {
Query::Single(s) => {
if !s.starts_with('?') {
p += "?";
}
p += &s;
}
#[cfg(feature = "serde")]
Query::List(l) => {
let res = serde_urlencoded::to_string(l);
// TODO: find a test case where this assertion is not met
debug_assert!(res.is_ok(), "cannot serialize query list: {l:?}");
if let Ok(q) = res {
p += "?";
p += &q;
}
}
}
}
p
})
.map(|p| Either::Left(p))
.unwrap_or(Either::Right(name.clone())),
),
NavigationTarget::External(e) => Either::Right(e.to_string()),
}
}
#[cfg(test)]
mod tests {
use crate::{prelude::RootIndex, routes::Segment, segments::NamedSegment};
use super::*;
#[test]
fn resolve_internal() {
let names = NamedSegment::from_segment(&Segment::<&str>::empty());
assert_eq!(
resolve_target(&names, &NavigationTarget::Internal("/test".to_string())),
Either::Left(Either::Left(String::from("/test")))
);
}
#[test]
fn resolve_named() {
let names = NamedSegment::from_segment(&Segment::<&str>::empty());
assert_eq!(
resolve_target(&names, &NavigationTarget::named::<RootIndex>()),
Either::Left(Either::Left(String::from("/")))
);
}
#[test]
fn resolve_named_with_query_single() {
let names = NamedSegment::from_segment(&Segment::<&str>::empty());
let without = resolve_target(
&names,
&NavigationTarget::named::<RootIndex>().query("huhu"),
);
let with = resolve_target(
&names,
&NavigationTarget::named::<RootIndex>().query("?huhu"),
);
let correct = Either::Left(Either::Left(String::from("/?huhu")));
assert_eq!(with, correct);
assert_eq!(without, correct);
assert_eq!(with, without);
}
#[test]
#[cfg(feature = "serde")]
fn resolve_named_with_query_list() {
let names = NamedSegment::from_segment(&Segment::<&str>::empty());
assert_eq!(
resolve_target(
&names,
&NavigationTarget::named::<RootIndex>()
.query(vec![("some", "test"), ("another", "value")])
),
Either::Left(Either::Left(String::from("/?some=test&another=value")))
);
}
#[test]
fn resolve_external() {
let names = NamedSegment::from_segment(&Segment::<&str>::empty());
assert_eq!(
resolve_target(
&names,
&NavigationTarget::External("https://dioxuslabs.com/".to_string())
),
Either::Right(String::from("https://dioxuslabs.com/"))
);
}
}