soaprs-http 0.3.0

Transport-neutral HTTP contracts and policies for soaprs
Documentation
//! Portable route path syntax.

use std::fmt;

use soaprs_core::{SoapError, SoapResult};

/// A validated route path using `{parameter}` placeholders.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RoutePath(String);

impl RoutePath {
    /// Validates and creates a route path.
    pub fn new(path: impl Into<String>) -> SoapResult<Self> {
        let path = path.into();
        validate_path(&path)?;
        Ok(Self(path))
    }

    /// Returns the portable route path.
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Joins a group prefix and endpoint path using portable syntax.
    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))
    }

    /// Returns a canonical route shape with parameter names removed.
    ///
    /// `/users/{id}` and `/users/{user_id}` intentionally share one shape so
    /// catalogs can reject framework-dependent route conflicts.
    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("/")
    }

    /// Returns declared parameter names in route order.
    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"]);
    }
}