ghost-io-api 0.1.0

Strongly-typed, async Rust client for the Ghost CMS Content API
Documentation
//! Content API authentication using API keys.
//!
//! The Ghost Content API uses a simple API key passed as a query parameter.
//! This module provides a type-safe wrapper for Content API keys with
//! validation and query parameter generation.
//!
//! # Example
//!
//! ```
//! use ghost_io_api::auth::content::ContentApiKey;
//!
//! let key = ContentApiKey::new("22444f78447824223cefc48062").unwrap();
//! let query_param = key.as_query_param();
//! assert_eq!(query_param, "key=22444f78447824223cefc48062");
//! ```

use crate::error::{GhostError, Result};
use std::fmt;

/// Content API key for authentication.
///
/// The Content API uses a simple API key mechanism where the key is passed
/// as a query parameter (?key=...) in requests. Keys are 26-character
/// hexadecimal strings generated by Ghost.
///
/// # Format
///
/// Content API keys are 26-character hexadecimal strings (lowercase).
/// Example: `22444f78447824223cefc48062`
///
/// # Security
///
/// Content API keys are **safe for public use** as they only grant read
/// access to published content. They should still be treated with care to
/// avoid quota exhaustion.
///
/// # Example
///
/// ```
/// use ghost_io_api::auth::content::ContentApiKey;
///
/// // Valid key
/// let key = ContentApiKey::new("22444f78447824223cefc48062").unwrap();
/// assert_eq!(key.as_str(), "22444f78447824223cefc48062");
///
/// // Invalid key (too short)
/// let result = ContentApiKey::new("invalid");
/// assert!(result.is_err());
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContentApiKey {
    key: String,
}

impl ContentApiKey {
    /// Minimum valid key length (26 characters).
    pub const MIN_KEY_LENGTH: usize = 26;

    /// Maximum valid key length (26 characters).
    pub const MAX_KEY_LENGTH: usize = 26;

    /// Creates a new Content API key with validation.
    ///
    /// # Validation
    ///
    /// - Must be exactly 26 characters long
    /// - Must contain only hexadecimal characters (0-9, a-f)
    /// - Automatically converts uppercase to lowercase
    ///
    /// # Errors
    ///
    /// Returns `GhostError::Auth` if:
    /// - Key is not 26 characters long
    /// - Key contains non-hexadecimal characters
    /// - Key is empty
    ///
    /// # Example
    ///
    /// ```
    /// use ghost_io_api::auth::content::ContentApiKey;
    ///
    /// // Valid key
    /// let key = ContentApiKey::new("22444f78447824223cefc48062").unwrap();
    /// assert!(key.is_valid());
    ///
    /// // Invalid - too short
    /// assert!(ContentApiKey::new("short").is_err());
    ///
    /// // Invalid - non-hex characters
    /// assert!(ContentApiKey::new("gggggggggggggggggggggggggg").is_err());
    /// ```
    pub fn new(key: impl Into<String>) -> Result<Self> {
        let key = key.into().trim().to_lowercase();

        if key.is_empty() {
            return Err(GhostError::auth("Content API key cannot be empty"));
        }

        if key.len() != Self::MIN_KEY_LENGTH {
            return Err(GhostError::auth(format!(
                "Content API key must be exactly {} characters, got {}",
                Self::MIN_KEY_LENGTH,
                key.len()
            )));
        }

        if !key.chars().all(|c| c.is_ascii_hexdigit()) {
            return Err(GhostError::auth(
                "Content API key must contain only hexadecimal characters (0-9, a-f)",
            ));
        }

        Ok(Self { key })
    }

    /// Returns the key as a string slice.
    ///
    /// # Example
    ///
    /// ```
    /// use ghost_io_api::auth::content::ContentApiKey;
    ///
    /// let key = ContentApiKey::new("22444f78447824223cefc48062").unwrap();
    /// assert_eq!(key.as_str(), "22444f78447824223cefc48062");
    /// ```
    pub fn as_str(&self) -> &str {
        &self.key
    }

    /// Returns the key formatted as a query parameter.
    ///
    /// This returns the key in the format `key=<value>` ready to be
    /// appended to a URL query string.
    ///
    /// # Example
    ///
    /// ```
    /// use ghost_io_api::auth::content::ContentApiKey;
    ///
    /// let key = ContentApiKey::new("22444f78447824223cefc48062").unwrap();
    /// let param = key.as_query_param();
    /// assert_eq!(param, "key=22444f78447824223cefc48062");
    ///
    /// // Can be used in URLs
    /// let url = format!("https://demo.ghost.io/ghost/api/content/posts/?{}", param);
    /// assert!(url.contains("?key="));
    /// ```
    pub fn as_query_param(&self) -> String {
        format!("key={}", self.key)
    }

    /// Validates the key format.
    ///
    /// Returns `true` if the key is valid (correct length and hex characters).
    ///
    /// Note: This will always return `true` for keys created via `new()`
    /// since validation happens at construction time.
    ///
    /// # Example
    ///
    /// ```
    /// use ghost_io_api::auth::content::ContentApiKey;
    ///
    /// let key = ContentApiKey::new("22444f78447824223cefc48062").unwrap();
    /// assert!(key.is_valid());
    /// ```
    pub fn is_valid(&self) -> bool {
        self.key.len() == Self::MIN_KEY_LENGTH && self.key.chars().all(|c| c.is_ascii_hexdigit())
    }
}

impl fmt::Display for ContentApiKey {
    /// Formats the key for display (shows first 8 and last 4 characters).
    ///
    /// For security, the full key is not displayed. Use `as_str()` if you
    /// need the complete key.
    ///
    /// # Example
    ///
    /// ```
    /// use ghost_io_api::auth::content::ContentApiKey;
    ///
    /// let key = ContentApiKey::new("22444f78447824223cefc48062").unwrap();
    /// let display = format!("{}", key);
    /// assert_eq!(display, "ContentApiKey(22444f78...8062)");
    /// ```
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.key.len() >= 12 {
            write!(
                f,
                "ContentApiKey({}...{})",
                &self.key[..8],
                &self.key[self.key.len() - 4..]
            )
        } else {
            write!(f, "ContentApiKey(***)")
        }
    }
}

impl AsRef<str> for ContentApiKey {
    fn as_ref(&self) -> &str {
        &self.key
    }
}

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

    const VALID_KEY: &str = "22444f78447824223cefc48062";

    #[test]
    fn test_valid_key() {
        let key = ContentApiKey::new(VALID_KEY).unwrap();
        assert_eq!(key.as_str(), VALID_KEY);
        assert!(key.is_valid());
    }

    #[test]
    fn test_key_normalization() {
        // Uppercase should be converted to lowercase
        let key = ContentApiKey::new("22444F78447824223CEFC48062").unwrap();
        assert_eq!(key.as_str(), VALID_KEY);

        // Whitespace should be trimmed
        let key = ContentApiKey::new("  22444f78447824223cefc48062  ").unwrap();
        assert_eq!(key.as_str(), VALID_KEY);
    }

    #[test]
    fn test_empty_key() {
        let result = ContentApiKey::new("");
        assert!(result.is_err());
        assert!(result.unwrap_err().is_auth_error());
    }

    #[test]
    fn test_too_short_key() {
        let result = ContentApiKey::new("short");
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(err.is_auth_error());
        assert!(err.to_string().contains("must be exactly 26 characters"));
    }

    #[test]
    fn test_too_long_key() {
        let result = ContentApiKey::new("22444f78447824223cefc480621234");
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(err.is_auth_error());
        assert!(err.to_string().contains("must be exactly 26 characters"));
    }

    #[test]
    fn test_invalid_characters() {
        let result = ContentApiKey::new("gggggggggggggggggggggggggg");
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(err.is_auth_error());
        assert!(err
            .to_string()
            .contains("must contain only hexadecimal characters"));
    }

    #[test]
    fn test_special_characters() {
        let result = ContentApiKey::new("22444f78447824223cefc4806!");
        assert!(result.is_err());
    }

    #[test]
    fn test_as_query_param() {
        let key = ContentApiKey::new(VALID_KEY).unwrap();
        assert_eq!(key.as_query_param(), "key=22444f78447824223cefc48062");
    }

    #[test]
    fn test_display() {
        let key = ContentApiKey::new(VALID_KEY).unwrap();
        let display = format!("{}", key);
        assert_eq!(display, "ContentApiKey(22444f78...8062)");
        // Should not display the full key
        assert!(!display.contains(VALID_KEY));
    }

    #[test]
    fn test_debug() {
        let key = ContentApiKey::new(VALID_KEY).unwrap();
        let debug = format!("{:?}", key);
        // Debug should show the struct name
        assert!(debug.contains("ContentApiKey"));
    }

    #[test]
    fn test_as_ref() {
        let key = ContentApiKey::new(VALID_KEY).unwrap();
        let as_ref: &str = key.as_ref();
        assert_eq!(as_ref, VALID_KEY);
    }

    #[test]
    fn test_clone() {
        let key = ContentApiKey::new(VALID_KEY).unwrap();
        let cloned = key.clone();
        assert_eq!(key, cloned);
    }

    #[test]
    fn test_eq() {
        let key1 = ContentApiKey::new(VALID_KEY).unwrap();
        let key2 = ContentApiKey::new(VALID_KEY).unwrap();
        assert_eq!(key1, key2);

        let key3 = ContentApiKey::new("12444f78447824223cefc48062").unwrap();
        assert_ne!(key1, key3);
    }

    #[test]
    fn test_constants() {
        assert_eq!(ContentApiKey::MIN_KEY_LENGTH, 26);
        assert_eq!(ContentApiKey::MAX_KEY_LENGTH, 26);
    }

    #[test]
    fn test_is_valid() {
        let key = ContentApiKey::new(VALID_KEY).unwrap();
        assert!(key.is_valid());
    }

    #[test]
    fn test_all_hex_digits() {
        // Test all valid hex characters
        let key = ContentApiKey::new("0123456789abcdef0123456789").unwrap();
        assert!(key.is_valid());
    }
}