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}