1#[derive(Debug, Clone, PartialEq, Eq, Hash)]
5pub enum PathSegment {
6 Literal(String),
8 Param(String),
10 Tail(String),
12}
13
14impl PathSegment {
15 pub fn name(&self) -> Option<&str> {
17 match self {
18 Self::Literal(_) => None,
19 Self::Param(name) | Self::Tail(name) => Some(name),
20 }
21 }
22}
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
30pub struct RoutePath {
31 segments: Vec<PathSegment>,
32}
33
34impl RoutePath {
35 pub fn parse(path: &str) -> Self {
41 let segments = path.split('/').filter(|segment| !segment.is_empty()).map(read_segment).collect();
42
43 Self { segments }
44 }
45
46 pub fn segments(&self) -> &[PathSegment] {
48 &self.segments
49 }
50
51 pub fn is_empty(&self) -> bool {
53 self.segments.is_empty()
54 }
55
56 pub fn names(&self) -> impl Iterator<Item = &str> {
58 self.segments.iter().filter_map(PathSegment::name)
59 }
60}
61
62impl From<&str> for RoutePath {
63 fn from(path: &str) -> Self {
64 Self::parse(path)
65 }
66}
67
68fn read_segment(segment: &str) -> PathSegment {
70 if let Some(name) = segment.strip_prefix(':') {
71 return PathSegment::Param(name.to_owned());
72 }
73 if let Some(name) = segment.strip_prefix('*') {
74 return PathSegment::Tail(name.to_owned());
75 }
76 if let Some(name) = segment.strip_prefix('{').and_then(|segment| segment.strip_suffix('}')) {
77 return match name.strip_prefix('*') {
78 Some(name) => PathSegment::Tail(name.to_owned()),
79 None => PathSegment::Param(name.to_owned()),
80 };
81 }
82
83 PathSegment::Literal(segment.to_owned())
84}
85
86pub trait PathSyntaxAlg {
88 fn param(&self, name: &str) -> String;
90
91 fn tail(&self, name: &str) -> String;
93}
94
95#[derive(Debug, Default)]
100pub struct CanonicalPath;
101
102impl PathSyntaxAlg for CanonicalPath {
103 fn param(&self, name: &str) -> String {
104 format!("{{{name}}}")
105 }
106
107 fn tail(&self, name: &str) -> String {
108 format!("{{*{name}}}")
109 }
110}
111
112pub fn compose_path<'a, Parts>(parts: Parts, syntax: &impl PathSyntaxAlg) -> String
117where
118 Parts: IntoIterator<Item = &'a RoutePath>,
119{
120 let mut path = String::new();
121 for part in parts {
122 for segment in part.segments() {
123 path.push('/');
124 match segment {
125 PathSegment::Literal(value) => path.push_str(value),
126 PathSegment::Param(name) => path.push_str(&syntax.param(name)),
127 PathSegment::Tail(name) => path.push_str(&syntax.tail(name)),
128 }
129 }
130 }
131
132 if path.is_empty() {
133 path.push('/');
134 }
135
136 path
137}
138
139pub fn describe_path<'a, Parts>(parts: Parts) -> String
141where
142 Parts: IntoIterator<Item = &'a RoutePath>,
143{
144 compose_path(parts, &CanonicalPath)
145}
146
147#[cfg(test)]
148mod tests {
149 use super::{PathSegment, RoutePath, describe_path};
150
151 #[test]
152 fn reads_every_spelling_a_router_accepts_as_the_same_path() {
153 let poem = RoutePath::parse("/status/:id/*rest");
154 let axum = RoutePath::parse("/status/{id}/{*rest}");
155
156 assert_eq!(poem, axum);
157 assert_eq!(
158 poem.segments(),
159 [
160 PathSegment::Literal("status".to_owned()),
161 PathSegment::Param("id".to_owned()),
162 PathSegment::Tail("rest".to_owned()),
163 ]
164 );
165 assert_eq!(poem.names().collect::<Vec<_>>(), ["id", "rest"]);
166 }
167
168 #[test]
169 fn states_nothing_for_a_separator_that_states_nothing() {
170 assert!(RoutePath::parse("/").is_empty());
171 assert_eq!(RoutePath::parse("//status//"), RoutePath::parse("status"));
172 assert_eq!(describe_path([&RoutePath::parse("/")]), "/");
173 }
174
175 #[test]
176 fn composes_parts_into_one_absolute_path() {
177 let parts = [RoutePath::parse("/api"), RoutePath::parse("v1"), RoutePath::parse("/status/:id")];
178
179 assert_eq!(describe_path(&parts), "/api/v1/status/{id}");
180 }
181
182 #[test]
183 fn matches_a_segment_no_router_reads_as_a_parameter_literally() {
184 assert_eq!(RoutePath::parse("{id}.json").segments(), [PathSegment::Literal("{id}.json".to_owned())]);
185 }
186}