Skip to main content

ghost_io_api/auth/
content.rs

1//! Content API authentication using API keys.
2//!
3//! The Ghost Content API uses a simple API key passed as a query parameter.
4//! This module provides a type-safe wrapper for Content API keys with
5//! validation and query parameter generation.
6//!
7//! # Example
8//!
9//! ```
10//! use ghost_io_api::auth::content::ContentApiKey;
11//!
12//! let key = ContentApiKey::new("22444f78447824223cefc48062").unwrap();
13//! let query_param = key.as_query_param();
14//! assert_eq!(query_param, "key=22444f78447824223cefc48062");
15//! ```
16
17use crate::error::{GhostError, Result};
18use std::fmt;
19
20/// Content API key for authentication.
21///
22/// The Content API uses a simple API key mechanism where the key is passed
23/// as a query parameter (?key=...) in requests. Keys are 26-character
24/// hexadecimal strings generated by Ghost.
25///
26/// # Format
27///
28/// Content API keys are 26-character hexadecimal strings (lowercase).
29/// Example: `22444f78447824223cefc48062`
30///
31/// # Security
32///
33/// Content API keys are **safe for public use** as they only grant read
34/// access to published content. They should still be treated with care to
35/// avoid quota exhaustion.
36///
37/// # Example
38///
39/// ```
40/// use ghost_io_api::auth::content::ContentApiKey;
41///
42/// // Valid key
43/// let key = ContentApiKey::new("22444f78447824223cefc48062").unwrap();
44/// assert_eq!(key.as_str(), "22444f78447824223cefc48062");
45///
46/// // Invalid key (too short)
47/// let result = ContentApiKey::new("invalid");
48/// assert!(result.is_err());
49/// ```
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ContentApiKey {
52    key: String,
53}
54
55impl ContentApiKey {
56    /// Minimum valid key length (26 characters).
57    pub const MIN_KEY_LENGTH: usize = 26;
58
59    /// Maximum valid key length (26 characters).
60    pub const MAX_KEY_LENGTH: usize = 26;
61
62    /// Creates a new Content API key with validation.
63    ///
64    /// # Validation
65    ///
66    /// - Must be exactly 26 characters long
67    /// - Must contain only hexadecimal characters (0-9, a-f)
68    /// - Automatically converts uppercase to lowercase
69    ///
70    /// # Errors
71    ///
72    /// Returns `GhostError::Auth` if:
73    /// - Key is not 26 characters long
74    /// - Key contains non-hexadecimal characters
75    /// - Key is empty
76    ///
77    /// # Example
78    ///
79    /// ```
80    /// use ghost_io_api::auth::content::ContentApiKey;
81    ///
82    /// // Valid key
83    /// let key = ContentApiKey::new("22444f78447824223cefc48062").unwrap();
84    /// assert!(key.is_valid());
85    ///
86    /// // Invalid - too short
87    /// assert!(ContentApiKey::new("short").is_err());
88    ///
89    /// // Invalid - non-hex characters
90    /// assert!(ContentApiKey::new("gggggggggggggggggggggggggg").is_err());
91    /// ```
92    pub fn new(key: impl Into<String>) -> Result<Self> {
93        let key = key.into().trim().to_lowercase();
94
95        if key.is_empty() {
96            return Err(GhostError::auth("Content API key cannot be empty"));
97        }
98
99        if key.len() != Self::MIN_KEY_LENGTH {
100            return Err(GhostError::auth(format!(
101                "Content API key must be exactly {} characters, got {}",
102                Self::MIN_KEY_LENGTH,
103                key.len()
104            )));
105        }
106
107        if !key.chars().all(|c| c.is_ascii_hexdigit()) {
108            return Err(GhostError::auth(
109                "Content API key must contain only hexadecimal characters (0-9, a-f)",
110            ));
111        }
112
113        Ok(Self { key })
114    }
115
116    /// Returns the key as a string slice.
117    ///
118    /// # Example
119    ///
120    /// ```
121    /// use ghost_io_api::auth::content::ContentApiKey;
122    ///
123    /// let key = ContentApiKey::new("22444f78447824223cefc48062").unwrap();
124    /// assert_eq!(key.as_str(), "22444f78447824223cefc48062");
125    /// ```
126    pub fn as_str(&self) -> &str {
127        &self.key
128    }
129
130    /// Returns the key formatted as a query parameter.
131    ///
132    /// This returns the key in the format `key=<value>` ready to be
133    /// appended to a URL query string.
134    ///
135    /// # Example
136    ///
137    /// ```
138    /// use ghost_io_api::auth::content::ContentApiKey;
139    ///
140    /// let key = ContentApiKey::new("22444f78447824223cefc48062").unwrap();
141    /// let param = key.as_query_param();
142    /// assert_eq!(param, "key=22444f78447824223cefc48062");
143    ///
144    /// // Can be used in URLs
145    /// let url = format!("https://demo.ghost.io/ghost/api/content/posts/?{}", param);
146    /// assert!(url.contains("?key="));
147    /// ```
148    pub fn as_query_param(&self) -> String {
149        format!("key={}", self.key)
150    }
151
152    /// Validates the key format.
153    ///
154    /// Returns `true` if the key is valid (correct length and hex characters).
155    ///
156    /// Note: This will always return `true` for keys created via `new()`
157    /// since validation happens at construction time.
158    ///
159    /// # Example
160    ///
161    /// ```
162    /// use ghost_io_api::auth::content::ContentApiKey;
163    ///
164    /// let key = ContentApiKey::new("22444f78447824223cefc48062").unwrap();
165    /// assert!(key.is_valid());
166    /// ```
167    pub fn is_valid(&self) -> bool {
168        self.key.len() == Self::MIN_KEY_LENGTH && self.key.chars().all(|c| c.is_ascii_hexdigit())
169    }
170}
171
172impl fmt::Display for ContentApiKey {
173    /// Formats the key for display (shows first 8 and last 4 characters).
174    ///
175    /// For security, the full key is not displayed. Use `as_str()` if you
176    /// need the complete key.
177    ///
178    /// # Example
179    ///
180    /// ```
181    /// use ghost_io_api::auth::content::ContentApiKey;
182    ///
183    /// let key = ContentApiKey::new("22444f78447824223cefc48062").unwrap();
184    /// let display = format!("{}", key);
185    /// assert_eq!(display, "ContentApiKey(22444f78...8062)");
186    /// ```
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        if self.key.len() >= 12 {
189            write!(
190                f,
191                "ContentApiKey({}...{})",
192                &self.key[..8],
193                &self.key[self.key.len() - 4..]
194            )
195        } else {
196            write!(f, "ContentApiKey(***)")
197        }
198    }
199}
200
201impl AsRef<str> for ContentApiKey {
202    fn as_ref(&self) -> &str {
203        &self.key
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    const VALID_KEY: &str = "22444f78447824223cefc48062";
212
213    #[test]
214    fn test_valid_key() {
215        let key = ContentApiKey::new(VALID_KEY).unwrap();
216        assert_eq!(key.as_str(), VALID_KEY);
217        assert!(key.is_valid());
218    }
219
220    #[test]
221    fn test_key_normalization() {
222        // Uppercase should be converted to lowercase
223        let key = ContentApiKey::new("22444F78447824223CEFC48062").unwrap();
224        assert_eq!(key.as_str(), VALID_KEY);
225
226        // Whitespace should be trimmed
227        let key = ContentApiKey::new("  22444f78447824223cefc48062  ").unwrap();
228        assert_eq!(key.as_str(), VALID_KEY);
229    }
230
231    #[test]
232    fn test_empty_key() {
233        let result = ContentApiKey::new("");
234        assert!(result.is_err());
235        assert!(result.unwrap_err().is_auth_error());
236    }
237
238    #[test]
239    fn test_too_short_key() {
240        let result = ContentApiKey::new("short");
241        assert!(result.is_err());
242        let err = result.unwrap_err();
243        assert!(err.is_auth_error());
244        assert!(err.to_string().contains("must be exactly 26 characters"));
245    }
246
247    #[test]
248    fn test_too_long_key() {
249        let result = ContentApiKey::new("22444f78447824223cefc480621234");
250        assert!(result.is_err());
251        let err = result.unwrap_err();
252        assert!(err.is_auth_error());
253        assert!(err.to_string().contains("must be exactly 26 characters"));
254    }
255
256    #[test]
257    fn test_invalid_characters() {
258        let result = ContentApiKey::new("gggggggggggggggggggggggggg");
259        assert!(result.is_err());
260        let err = result.unwrap_err();
261        assert!(err.is_auth_error());
262        assert!(err
263            .to_string()
264            .contains("must contain only hexadecimal characters"));
265    }
266
267    #[test]
268    fn test_special_characters() {
269        let result = ContentApiKey::new("22444f78447824223cefc4806!");
270        assert!(result.is_err());
271    }
272
273    #[test]
274    fn test_as_query_param() {
275        let key = ContentApiKey::new(VALID_KEY).unwrap();
276        assert_eq!(key.as_query_param(), "key=22444f78447824223cefc48062");
277    }
278
279    #[test]
280    fn test_display() {
281        let key = ContentApiKey::new(VALID_KEY).unwrap();
282        let display = format!("{}", key);
283        assert_eq!(display, "ContentApiKey(22444f78...8062)");
284        // Should not display the full key
285        assert!(!display.contains(VALID_KEY));
286    }
287
288    #[test]
289    fn test_debug() {
290        let key = ContentApiKey::new(VALID_KEY).unwrap();
291        let debug = format!("{:?}", key);
292        // Debug should show the struct name
293        assert!(debug.contains("ContentApiKey"));
294    }
295
296    #[test]
297    fn test_as_ref() {
298        let key = ContentApiKey::new(VALID_KEY).unwrap();
299        let as_ref: &str = key.as_ref();
300        assert_eq!(as_ref, VALID_KEY);
301    }
302
303    #[test]
304    fn test_clone() {
305        let key = ContentApiKey::new(VALID_KEY).unwrap();
306        let cloned = key.clone();
307        assert_eq!(key, cloned);
308    }
309
310    #[test]
311    fn test_eq() {
312        let key1 = ContentApiKey::new(VALID_KEY).unwrap();
313        let key2 = ContentApiKey::new(VALID_KEY).unwrap();
314        assert_eq!(key1, key2);
315
316        let key3 = ContentApiKey::new("12444f78447824223cefc48062").unwrap();
317        assert_ne!(key1, key3);
318    }
319
320    #[test]
321    fn test_constants() {
322        assert_eq!(ContentApiKey::MIN_KEY_LENGTH, 26);
323        assert_eq!(ContentApiKey::MAX_KEY_LENGTH, 26);
324    }
325
326    #[test]
327    fn test_is_valid() {
328        let key = ContentApiKey::new(VALID_KEY).unwrap();
329        assert!(key.is_valid());
330    }
331
332    #[test]
333    fn test_all_hex_digits() {
334        // Test all valid hex characters
335        let key = ContentApiKey::new("0123456789abcdef0123456789").unwrap();
336        assert!(key.is_valid());
337    }
338}