nidus-http 1.0.15

Axum and Tower HTTP integration, controllers, middleware, health, metrics, and server defaults for Nidus.
Documentation
use crate::error::RoutePathError;

pub(crate) fn join_paths(prefix: &str, path: &str) -> Result<String, RoutePathError> {
    let prefix = normalize_mount_prefix(prefix)?;
    let path = normalize_path(path)?;
    Ok(join_normalized_paths(&prefix, &path))
}

pub(crate) fn normalize_mount_prefix(prefix: impl AsRef<str>) -> Result<String, RoutePathError> {
    let mut prefix = normalize_path(prefix)?;
    let trimmed_len = prefix.trim_end_matches('/').len();
    if trimmed_len == 0 {
        prefix.truncate(1);
    } else {
        prefix.truncate(trimmed_len);
    }
    Ok(prefix)
}

pub(crate) fn normalize_path(path: impl AsRef<str>) -> Result<String, RoutePathError> {
    let path = path.as_ref().trim();
    let parameter_count = validate_path(path)?;
    let leading_slash = usize::from(!path.starts_with('/'));
    let mut normalized = String::with_capacity(path.len() + leading_slash + parameter_count);
    if leading_slash == 1 {
        normalized.push('/');
    }

    for (index, segment) in path.split('/').enumerate() {
        if index > 0 {
            normalized.push('/');
        }
        if let Some(name) = segment.strip_prefix(':') {
            normalized.push('{');
            normalized.push_str(name);
            normalized.push('}');
        } else {
            normalized.push_str(segment);
        }
    }
    Ok(normalized)
}

pub(crate) fn join_normalized_paths(prefix: &str, path: &str) -> String {
    if prefix == "/" {
        return path.to_owned();
    }
    if path == "/" {
        return prefix.to_owned();
    }

    let mut full_path = String::with_capacity(prefix.len() + path.len());
    full_path.push_str(prefix);
    full_path.push_str(path);
    full_path
}

fn validate_path(path: &str) -> Result<usize, RoutePathError> {
    let mut parameter_count = 0;
    for segment in path.split('/') {
        if segment == ":" {
            return Err(RoutePathError::empty_parameter(path));
        }
        if segment.starts_with(':') {
            parameter_count += 1;
        }
    }
    Ok(parameter_count)
}

#[cfg(test)]
mod tests {
    use super::{join_normalized_paths, normalize_mount_prefix, normalize_path};

    #[test]
    fn normalize_path_preserves_structure_and_converts_parameters() {
        for (input, expected) in [
            ("", "/"),
            ("/", "/"),
            (
                " users/:id/posts/:post_id/ ",
                "/users/{id}/posts/{post_id}/",
            ),
            ("//users//:id", "//users//{id}"),
            ("/users/{id}", "/users/{id}"),
        ] {
            assert_eq!(normalize_path(input).unwrap(), expected, "{input}");
        }
    }

    #[test]
    fn normalized_path_join_handles_root_and_nested_routes() {
        assert_eq!(join_normalized_paths("/", "/users/{id}"), "/users/{id}");
        assert_eq!(normalize_mount_prefix("/health///").unwrap(), "/health");
        assert_eq!(join_normalized_paths("/health", "/"), "/health");
        assert_eq!(
            join_normalized_paths("/users", "/{id}/posts"),
            "/users/{id}/posts"
        );
    }
}