use std::fmt;
use soaprs_core::{SoapError, SoapResult};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RoutePath(String);
impl RoutePath {
pub fn new(path: impl Into<String>) -> SoapResult<Self> {
let path = path.into();
validate_path(&path)?;
Ok(Self(path))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn join(&self, child: &Self) -> SoapResult<Self> {
if self.0 == "/" {
return Ok(child.clone());
}
if child.0 == "/" {
return Ok(self.clone());
}
Self::new(format!("{}{}", self.0, child.0))
}
pub fn shape(&self) -> String {
if self.0 == "/" {
return "/".to_owned();
}
self.0
.split('/')
.map(|segment| {
if segment.starts_with('{') && segment.ends_with('}') {
"{}"
} else {
segment
}
})
.collect::<Vec<_>>()
.join("/")
}
pub fn parameter_names(&self) -> Vec<&str> {
self.0
.split('/')
.filter_map(|segment| segment.strip_prefix('{')?.strip_suffix('}'))
.collect()
}
}
impl fmt::Display for RoutePath {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
fn validate_path(path: &str) -> SoapResult<()> {
if !path.starts_with('/') {
return invalid(path, "must start with `/`");
}
if path.contains(['?', '#']) || path.chars().any(char::is_whitespace) {
return invalid(path, "cannot contain a query, fragment, or whitespace");
}
if path == "/" {
return Ok(());
}
for segment in path.split('/').skip(1) {
if segment.is_empty() {
return invalid(path, "cannot contain empty segments");
}
let has_opening_brace = segment.contains('{');
let has_closing_brace = segment.contains('}');
if has_opening_brace || has_closing_brace {
if !(segment.starts_with('{') && segment.ends_with('}')) {
return invalid(path, "parameter braces must cover an entire segment");
}
let parameter = &segment[1..segment.len() - 1];
if !valid_parameter(parameter) {
return invalid(path, "contains an invalid parameter name");
}
} else if !segment.chars().all(|character| {
character == '-'
|| character == '_'
|| character == '.'
|| character.is_ascii_alphanumeric()
}) {
return invalid(path, "contains a non-portable static segment");
}
}
Ok(())
}
fn valid_parameter(parameter: &str) -> bool {
let mut characters = parameter.chars();
let Some(first) = characters.next() else {
return false;
};
(first == '_' || first.is_ascii_alphabetic())
&& characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
}
fn invalid<T>(path: &str, reason: &str) -> SoapResult<T> {
Err(SoapError::validation(format!(
"invalid route path `{path}`: {reason}"
)))
}
#[cfg(test)]
mod tests {
use super::RoutePath;
#[test]
fn accepts_root_static_and_parameter_paths() {
assert!(RoutePath::new("/").is_ok());
assert!(RoutePath::new("/users").is_ok());
assert!(RoutePath::new("/users/{user_id}").is_ok());
}
#[test]
fn rejects_framework_specific_or_ambiguous_paths() {
assert!(RoutePath::new("users").is_err());
assert!(RoutePath::new("/users/:id").is_err());
assert!(RoutePath::new("/users/{id}/").is_err());
assert!(RoutePath::new("/users/{bad-name}").is_err());
assert!(RoutePath::new("/users/{id}?active=true").is_err());
}
#[test]
fn joins_groups_and_canonicalizes_parameter_shapes() {
let prefix = RoutePath::new("/api/v1");
let child = RoutePath::new("/users/{user_id}");
let same_shape = RoutePath::new("/api/v1/users/{id}");
let (Some(prefix), Some(child), Some(same_shape)) =
(prefix.ok(), child.ok(), same_shape.ok())
else {
panic!("valid route fixtures");
};
let joined = prefix.join(&child);
assert_eq!(
joined.as_ref().ok().map(RoutePath::as_str),
Some("/api/v1/users/{user_id}")
);
assert_eq!(
joined.ok().map(|path| path.shape()),
Some(same_shape.shape())
);
assert_eq!(child.parameter_names(), vec!["user_id"]);
}
}