csaf-crud 0.3.4

CSAF 2.0 / 2.1 advisory CRUD server with HATEOAS JSON API and HTML UI (TLS 1.3, HTTP/1.1 + HTTP/2 + HTTP/3)
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! HATEOAS (HAL-like) link helpers for the JSON API.
//!
//! Provides functions to build `_links` objects following the HAL specification
//! pattern, enabling API consumers to discover related resources without
//! hard-coding URLs.

use serde_json::{Value, json};

/// Build a `self` link for a given path.
#[must_use]
pub fn self_link(path: &str) -> Value {
    json!({"href": path})
}

/// Build the full `_links` object for a single CSAF document resource.
#[must_use]
pub fn csaf_links(tracking_id: &str) -> Value {
    json!({
        "self": {"href": format!("/api/v1/csaf/{tracking_id}")},
        "collection": {"href": "/api/v1/csaf"},
        "validate": {"href": format!("/api/v1/csaf/{tracking_id}/validate")},
        "audit_log": {"href": format!("/api/v1/audit-log?tracking_id={tracking_id}")}
    })
}

/// Build `_links` for a paginated collection endpoint.
#[must_use]
pub fn collection_links(base: &str, page: usize, per_page: usize, total: usize) -> Value {
    let last_page = if total == 0 {
        1
    } else {
        total.div_ceil(per_page)
    };
    let mut links = json!({
        "self": {"href": format!("{base}?page={page}&per_page={per_page}")},
        "first": {"href": format!("{base}?page=1&per_page={per_page}")},
        "last": {"href": format!("{base}?page={last_page}&per_page={per_page}")}
    });
    if page < last_page {
        links["next"] = json!({"href": format!("{base}?page={}&per_page={per_page}", page + 1)});
    }
    if page > 1 {
        links["prev"] = json!({"href": format!("{base}?page={}&per_page={per_page}", page - 1)});
    }
    links
}

/// Build `_links` for the API root.
#[must_use]
pub fn api_root_links() -> Value {
    json!({
        "self": {"href": "/api/v1"},
        "csaf": {"href": "/api/v1/csaf"},
        "settings": {"href": "/api/v1/settings"},
        "provider_metadata": {"href": "/api/v1/provider-metadata"},
        "audit_log": {"href": "/api/v1/audit-log"},
        "system_info": {"href": "/api/v1/system/info"},
        "health": {"href": "/api/v1/system/health"}
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_self_link() {
        let link = self_link("/api/v1/csaf");
        assert_eq!(link["href"], "/api/v1/csaf");
    }

    #[test]
    fn test_csaf_links() {
        let links = csaf_links("ndaal-sa-2026-003");
        assert_eq!(links["self"]["href"], "/api/v1/csaf/ndaal-sa-2026-003");
        assert_eq!(links["collection"]["href"], "/api/v1/csaf");
        assert_eq!(
            links["validate"]["href"],
            "/api/v1/csaf/ndaal-sa-2026-003/validate"
        );
    }

    #[test]
    fn test_csaf_links_all_keys_present() {
        let links = csaf_links("ndaal-sa-2026-001");
        let obj = links.as_object().expect("should be object");
        for key in &["self", "collection", "validate", "audit_log"] {
            assert!(obj.contains_key(*key), "missing key: {key}");
        }
    }

    #[test]
    fn test_collection_links_first_page() {
        let links = collection_links("/api/v1/csaf", 1, 20, 100);
        assert!(links.get("prev").is_none());
        assert!(links.get("next").is_some());
        assert_eq!(links["first"]["href"], "/api/v1/csaf?page=1&per_page=20");
    }

    #[test]
    fn test_collection_links_last_page() {
        let links = collection_links("/api/v1/csaf", 5, 20, 100);
        assert!(links.get("prev").is_some());
        assert!(links.get("next").is_none());
        assert_eq!(links["last"]["href"], "/api/v1/csaf?page=5&per_page=20");
    }

    #[test]
    fn test_collection_links_middle_page() {
        let links = collection_links("/api/v1/csaf", 3, 20, 100);
        assert!(links.get("prev").is_some());
        assert!(links.get("next").is_some());
    }

    #[test]
    fn test_collection_links_empty() {
        let links = collection_links("/api/v1/csaf", 1, 20, 0);
        assert!(links.get("next").is_none());
    }

    #[test]
    fn test_collection_links_single_page() {
        let links = collection_links("/api/v1/csaf", 1, 20, 15);
        assert!(links.get("next").is_none());
        assert!(links.get("prev").is_none());
        assert_eq!(links["last"]["href"], "/api/v1/csaf?page=1&per_page=20");
    }

    #[test]
    fn test_api_root_links() {
        let links = api_root_links();
        let obj = links.as_object().expect("should be object");
        for key in &[
            "self",
            "csaf",
            "settings",
            "provider_metadata",
            "audit_log",
            "system_info",
            "health",
        ] {
            assert!(obj.contains_key(*key), "missing key: {key}");
        }
    }
}