#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PathSegment {
Literal(String),
Param(String),
Tail(String),
}
impl PathSegment {
pub fn name(&self) -> Option<&str> {
match self {
Self::Literal(_) => None,
Self::Param(name) | Self::Tail(name) => Some(name),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct RoutePath {
segments: Vec<PathSegment>,
}
impl RoutePath {
pub fn parse(path: &str) -> Self {
let segments = path.split('/').filter(|segment| !segment.is_empty()).map(read_segment).collect();
Self { segments }
}
pub fn segments(&self) -> &[PathSegment] {
&self.segments
}
pub fn is_empty(&self) -> bool {
self.segments.is_empty()
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.segments.iter().filter_map(PathSegment::name)
}
}
impl From<&str> for RoutePath {
fn from(path: &str) -> Self {
Self::parse(path)
}
}
fn read_segment(segment: &str) -> PathSegment {
if let Some(name) = segment.strip_prefix(':') {
return PathSegment::Param(name.to_owned());
}
if let Some(name) = segment.strip_prefix('*') {
return PathSegment::Tail(name.to_owned());
}
if let Some(name) = segment.strip_prefix('{').and_then(|segment| segment.strip_suffix('}')) {
return match name.strip_prefix('*') {
Some(name) => PathSegment::Tail(name.to_owned()),
None => PathSegment::Param(name.to_owned()),
};
}
PathSegment::Literal(segment.to_owned())
}
pub trait PathSyntaxAlg {
fn param(&self, name: &str) -> String;
fn tail(&self, name: &str) -> String;
}
#[derive(Debug, Default)]
pub struct CanonicalPath;
impl PathSyntaxAlg for CanonicalPath {
fn param(&self, name: &str) -> String {
format!("{{{name}}}")
}
fn tail(&self, name: &str) -> String {
format!("{{*{name}}}")
}
}
pub fn compose_path<'a, Parts>(parts: Parts, syntax: &impl PathSyntaxAlg) -> String
where
Parts: IntoIterator<Item = &'a RoutePath>,
{
let mut path = String::new();
for part in parts {
for segment in part.segments() {
path.push('/');
match segment {
PathSegment::Literal(value) => path.push_str(value),
PathSegment::Param(name) => path.push_str(&syntax.param(name)),
PathSegment::Tail(name) => path.push_str(&syntax.tail(name)),
}
}
}
if path.is_empty() {
path.push('/');
}
path
}
pub fn describe_path<'a, Parts>(parts: Parts) -> String
where
Parts: IntoIterator<Item = &'a RoutePath>,
{
compose_path(parts, &CanonicalPath)
}
#[cfg(test)]
mod tests {
use super::{PathSegment, RoutePath, describe_path};
#[test]
fn reads_every_spelling_a_router_accepts_as_the_same_path() {
let poem = RoutePath::parse("/status/:id/*rest");
let axum = RoutePath::parse("/status/{id}/{*rest}");
assert_eq!(poem, axum);
assert_eq!(
poem.segments(),
[
PathSegment::Literal("status".to_owned()),
PathSegment::Param("id".to_owned()),
PathSegment::Tail("rest".to_owned()),
]
);
assert_eq!(poem.names().collect::<Vec<_>>(), ["id", "rest"]);
}
#[test]
fn states_nothing_for_a_separator_that_states_nothing() {
assert!(RoutePath::parse("/").is_empty());
assert_eq!(RoutePath::parse("//status//"), RoutePath::parse("status"));
assert_eq!(describe_path([&RoutePath::parse("/")]), "/");
}
#[test]
fn composes_parts_into_one_absolute_path() {
let parts = [RoutePath::parse("/api"), RoutePath::parse("v1"), RoutePath::parse("/status/:id")];
assert_eq!(describe_path(&parts), "/api/v1/status/{id}");
}
#[test]
fn matches_a_segment_no_router_reads_as_a_parameter_literally() {
assert_eq!(RoutePath::parse("{id}.json").segments(), [PathSegment::Literal("{id}.json".to_owned())]);
}
}