atomic_lti/stores/platform_store.rs
1use crate::errors::PlatformError;
2use async_trait::async_trait;
3use serde::{Deserialize, Serialize};
4
5/// Platform configuration data
6///
7/// Contains all necessary configuration for an LMS platform (e.g., Canvas, Moodle, Blackboard).
8/// This data is typically obtained during dynamic registration or manual platform configuration.
9///
10/// # Fields
11///
12/// * `issuer` - The platform's issuer URL (e.g., "https://canvas.instructure.com")
13/// * `name` - Optional human-readable name for the platform
14/// * `jwks_url` - JWKS endpoint URL for validating the platform's JWT signatures
15/// * `token_url` - OAuth2 token endpoint URL for obtaining access tokens
16/// * `oidc_url` - OIDC authentication endpoint URL for initiating LTI launches
17///
18/// # Examples
19///
20/// ```
21/// use atomic_lti::stores::platform_store::PlatformData;
22///
23/// let platform = PlatformData {
24/// issuer: "https://canvas.instructure.com".to_string(),
25/// name: Some("Canvas LMS".to_string()),
26/// jwks_url: "https://canvas.instructure.com/api/lti/security/jwks".to_string(),
27/// token_url: "https://canvas.instructure.com/login/oauth2/token".to_string(),
28/// oidc_url: "https://canvas.instructure.com/api/lti/authorize_redirect".to_string(),
29/// };
30/// ```
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct PlatformData {
33 /// LMS platform issuer URL (e.g., "https://canvas.instructure.com")
34 pub issuer: String,
35
36 /// Human-readable platform name
37 pub name: Option<String>,
38
39 /// JWKS endpoint URL for validating platform's signatures
40 pub jwks_url: String,
41
42 /// OAuth2 token endpoint URL
43 pub token_url: String,
44
45 /// OIDC authentication endpoint URL
46 pub oidc_url: String,
47}
48
49/// Store trait for managing LTI platform configurations
50///
51/// This trait provides both legacy single-platform methods and modern CRUD operations
52/// for managing multiple platform configurations. Implementations can store platform
53/// data in various backends (database, in-memory, file system, etc.).
54///
55/// # Backward Compatibility
56///
57/// The trait maintains backward compatibility with existing single-platform methods
58/// (`get_oidc_url`, `get_jwk_server_url`, `get_token_url`) while adding full CRUD
59/// support for multi-platform scenarios.
60///
61/// # Examples
62///
63/// ```no_run
64/// use atomic_lti::stores::platform_store::{PlatformStore, PlatformData};
65/// use atomic_lti::errors::PlatformError;
66/// use async_trait::async_trait;
67///
68/// # struct MyPlatformStore;
69/// # #[async_trait]
70/// # impl PlatformStore for MyPlatformStore {
71/// # async fn get_oidc_url(&self) -> Result<String, PlatformError> { todo!() }
72/// # async fn get_jwk_server_url(&self) -> Result<String, PlatformError> { todo!() }
73/// # async fn get_token_url(&self) -> Result<String, PlatformError> { todo!() }
74/// # async fn create(&self, platform: PlatformData) -> Result<PlatformData, PlatformError> { todo!() }
75/// # async fn find_by_iss(&self, issuer: &str) -> Result<Option<PlatformData>, PlatformError> { todo!() }
76/// # async fn update(&self, issuer: &str, platform: PlatformData) -> Result<PlatformData, PlatformError> { todo!() }
77/// # async fn delete(&self, issuer: &str) -> Result<(), PlatformError> { todo!() }
78/// # async fn list(&self) -> Result<Vec<PlatformData>, PlatformError> { todo!() }
79/// # }
80/// #
81/// async fn example(store: &dyn PlatformStore) -> Result<(), PlatformError> {
82/// // Create a new platform
83/// let platform = PlatformData {
84/// issuer: "https://canvas.instructure.com".to_string(),
85/// name: Some("Canvas LMS".to_string()),
86/// jwks_url: "https://canvas.instructure.com/api/lti/security/jwks".to_string(),
87/// token_url: "https://canvas.instructure.com/login/oauth2/token".to_string(),
88/// oidc_url: "https://canvas.instructure.com/api/lti/authorize_redirect".to_string(),
89/// };
90///
91/// let created = store.create(platform).await?;
92///
93/// // Find by issuer
94/// let found = store.find_by_iss("https://canvas.instructure.com").await?;
95///
96/// // List all platforms
97/// let all_platforms = store.list().await?;
98///
99/// Ok(())
100/// }
101/// ```
102#[async_trait]
103pub trait PlatformStore: Send + Sync {
104 // ========== Backward Compatible Methods ==========
105
106 /// Get OIDC URL for the configured platform
107 ///
108 /// This method is maintained for backward compatibility with single-platform
109 /// implementations. For multi-platform scenarios, use `find_by_iss` instead.
110 ///
111 /// # Returns
112 ///
113 /// The OIDC authentication endpoint URL
114 ///
115 /// # Errors
116 ///
117 /// Returns `PlatformError::InvalidIss` if platform configuration is not found
118 async fn get_oidc_url(&self) -> Result<String, PlatformError>;
119
120 /// Get JWKS URL for the configured platform
121 ///
122 /// This method is maintained for backward compatibility with single-platform
123 /// implementations. For multi-platform scenarios, use `find_by_iss` instead.
124 ///
125 /// # Returns
126 ///
127 /// The JWKS endpoint URL for validating platform signatures
128 ///
129 /// # Errors
130 ///
131 /// Returns `PlatformError::InvalidIss` if platform configuration is not found
132 async fn get_jwk_server_url(&self) -> Result<String, PlatformError>;
133
134 /// Get token URL for the configured platform
135 ///
136 /// This method is maintained for backward compatibility with single-platform
137 /// implementations. For multi-platform scenarios, use `find_by_iss` instead.
138 ///
139 /// # Returns
140 ///
141 /// The OAuth2 token endpoint URL
142 ///
143 /// # Errors
144 ///
145 /// Returns `PlatformError::InvalidIss` if platform configuration is not found
146 async fn get_token_url(&self) -> Result<String, PlatformError>;
147
148 // ========== CRUD Operations ==========
149
150 /// Create a new platform configuration
151 ///
152 /// # Arguments
153 ///
154 /// * `platform` - The platform configuration data to create
155 ///
156 /// # Returns
157 ///
158 /// The created platform data, potentially with additional fields populated by the store
159 ///
160 /// # Errors
161 ///
162 /// Returns `PlatformError` if:
163 /// * A platform with the same issuer already exists
164 /// * The platform data is invalid
165 /// * The underlying storage operation fails
166 ///
167 /// # Examples
168 ///
169 /// ```no_run
170 /// # use atomic_lti::stores::platform_store::{PlatformStore, PlatformData};
171 /// # use atomic_lti::errors::PlatformError;
172 /// # async fn example(store: &dyn PlatformStore) -> Result<(), PlatformError> {
173 /// let platform = PlatformData {
174 /// issuer: "https://canvas.instructure.com".to_string(),
175 /// name: Some("Canvas LMS".to_string()),
176 /// jwks_url: "https://canvas.instructure.com/api/lti/security/jwks".to_string(),
177 /// token_url: "https://canvas.instructure.com/login/oauth2/token".to_string(),
178 /// oidc_url: "https://canvas.instructure.com/api/lti/authorize_redirect".to_string(),
179 /// };
180 /// let created = store.create(platform).await?;
181 /// # Ok(())
182 /// # }
183 /// ```
184 async fn create(&self, platform: PlatformData) -> Result<PlatformData, PlatformError>;
185
186 /// Find platform configuration by issuer
187 ///
188 /// # Arguments
189 ///
190 /// * `issuer` - The platform issuer URL to search for
191 ///
192 /// # Returns
193 ///
194 /// * `Some(PlatformData)` if a platform with the given issuer exists
195 /// * `None` if no platform is found
196 ///
197 /// # Errors
198 ///
199 /// Returns `PlatformError` if the underlying storage operation fails
200 ///
201 /// # Examples
202 ///
203 /// ```no_run
204 /// # use atomic_lti::stores::platform_store::PlatformStore;
205 /// # use atomic_lti::errors::PlatformError;
206 /// # async fn example(store: &dyn PlatformStore) -> Result<(), PlatformError> {
207 /// if let Some(platform) = store.find_by_iss("https://canvas.instructure.com").await? {
208 /// println!("Found platform: {:?}", platform.name);
209 /// }
210 /// # Ok(())
211 /// # }
212 /// ```
213 async fn find_by_iss(&self, issuer: &str) -> Result<Option<PlatformData>, PlatformError>;
214
215 /// Update platform configuration
216 ///
217 /// # Arguments
218 ///
219 /// * `issuer` - The issuer of the platform to update
220 /// * `platform` - The updated platform configuration data
221 ///
222 /// # Returns
223 ///
224 /// The updated platform data
225 ///
226 /// # Errors
227 ///
228 /// Returns `PlatformError` if:
229 /// * The platform does not exist
230 /// * The platform data is invalid
231 /// * The underlying storage operation fails
232 ///
233 /// # Examples
234 ///
235 /// ```no_run
236 /// # use atomic_lti::stores::platform_store::{PlatformStore, PlatformData};
237 /// # use atomic_lti::errors::PlatformError;
238 /// # async fn example(store: &dyn PlatformStore) -> Result<(), PlatformError> {
239 /// let mut platform = store.find_by_iss("https://canvas.instructure.com").await?
240 /// .expect("Platform not found");
241 /// platform.name = Some("Updated Canvas LMS".to_string());
242 /// let updated = store.update(&platform.issuer, platform).await?;
243 /// # Ok(())
244 /// # }
245 /// ```
246 async fn update(
247 &self,
248 issuer: &str,
249 platform: PlatformData,
250 ) -> Result<PlatformData, PlatformError>;
251
252 /// Delete platform configuration
253 ///
254 /// # Arguments
255 ///
256 /// * `issuer` - The issuer of the platform to delete
257 ///
258 /// # Errors
259 ///
260 /// Returns `PlatformError` if:
261 /// * The platform does not exist
262 /// * The underlying storage operation fails
263 ///
264 /// # Examples
265 ///
266 /// ```no_run
267 /// # use atomic_lti::stores::platform_store::PlatformStore;
268 /// # use atomic_lti::errors::PlatformError;
269 /// # async fn example(store: &dyn PlatformStore) -> Result<(), PlatformError> {
270 /// store.delete("https://canvas.instructure.com").await?;
271 /// # Ok(())
272 /// # }
273 /// ```
274 async fn delete(&self, issuer: &str) -> Result<(), PlatformError>;
275
276 /// List all platform configurations
277 ///
278 /// # Returns
279 ///
280 /// A vector of all platform configurations in the store
281 ///
282 /// # Errors
283 ///
284 /// Returns `PlatformError` if the underlying storage operation fails
285 ///
286 /// # Examples
287 ///
288 /// ```no_run
289 /// # use atomic_lti::stores::platform_store::PlatformStore;
290 /// # use atomic_lti::errors::PlatformError;
291 /// # async fn example(store: &dyn PlatformStore) -> Result<(), PlatformError> {
292 /// let platforms = store.list().await?;
293 /// for platform in platforms {
294 /// println!("Platform: {} ({})", platform.name.unwrap_or_default(), platform.issuer);
295 /// }
296 /// # Ok(())
297 /// # }
298 /// ```
299 async fn list(&self) -> Result<Vec<PlatformData>, PlatformError>;
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use std::collections::HashMap;
306 use std::sync::{Arc, Mutex};
307
308 /// In-memory test implementation of PlatformStore
309 #[derive(Clone)]
310 struct InMemoryPlatformStore {
311 platforms: Arc<Mutex<HashMap<String, PlatformData>>>,
312 }
313
314 impl InMemoryPlatformStore {
315 fn new() -> Self {
316 Self {
317 platforms: Arc::new(Mutex::new(HashMap::new())),
318 }
319 }
320 }
321
322 #[async_trait]
323 impl PlatformStore for InMemoryPlatformStore {
324 async fn get_oidc_url(&self) -> Result<String, PlatformError> {
325 // For backward compatibility, return the first platform's OIDC URL
326 let platforms = self.platforms.lock().unwrap();
327 platforms
328 .values()
329 .next()
330 .map(|p| p.oidc_url.clone())
331 .ok_or_else(|| PlatformError::InvalidIss("No platform configured".to_string()))
332 }
333
334 async fn get_jwk_server_url(&self) -> Result<String, PlatformError> {
335 let platforms = self.platforms.lock().unwrap();
336 platforms
337 .values()
338 .next()
339 .map(|p| p.jwks_url.clone())
340 .ok_or_else(|| PlatformError::InvalidIss("No platform configured".to_string()))
341 }
342
343 async fn get_token_url(&self) -> Result<String, PlatformError> {
344 let platforms = self.platforms.lock().unwrap();
345 platforms
346 .values()
347 .next()
348 .map(|p| p.token_url.clone())
349 .ok_or_else(|| PlatformError::InvalidIss("No platform configured".to_string()))
350 }
351
352 async fn create(&self, platform: PlatformData) -> Result<PlatformData, PlatformError> {
353 let mut platforms = self.platforms.lock().unwrap();
354 if platforms.contains_key(&platform.issuer) {
355 return Err(PlatformError::InvalidIss(format!(
356 "Platform with issuer {} already exists",
357 platform.issuer
358 )));
359 }
360 platforms.insert(platform.issuer.clone(), platform.clone());
361 Ok(platform)
362 }
363
364 async fn find_by_iss(&self, issuer: &str) -> Result<Option<PlatformData>, PlatformError> {
365 let platforms = self.platforms.lock().unwrap();
366 Ok(platforms.get(issuer).cloned())
367 }
368
369 async fn update(
370 &self,
371 issuer: &str,
372 platform: PlatformData,
373 ) -> Result<PlatformData, PlatformError> {
374 let mut platforms = self.platforms.lock().unwrap();
375 if !platforms.contains_key(issuer) {
376 return Err(PlatformError::InvalidIss(format!(
377 "Platform with issuer {} not found",
378 issuer
379 )));
380 }
381 platforms.insert(issuer.to_string(), platform.clone());
382 Ok(platform)
383 }
384
385 async fn delete(&self, issuer: &str) -> Result<(), PlatformError> {
386 let mut platforms = self.platforms.lock().unwrap();
387 platforms
388 .remove(issuer)
389 .ok_or_else(|| PlatformError::InvalidIss(format!("Platform with issuer {} not found", issuer)))?;
390 Ok(())
391 }
392
393 async fn list(&self) -> Result<Vec<PlatformData>, PlatformError> {
394 let platforms = self.platforms.lock().unwrap();
395 Ok(platforms.values().cloned().collect())
396 }
397 }
398
399 fn create_test_platform(issuer: &str) -> PlatformData {
400 PlatformData {
401 issuer: issuer.to_string(),
402 name: Some(format!("Test Platform {}", issuer)),
403 jwks_url: format!("{}/jwks", issuer),
404 token_url: format!("{}/token", issuer),
405 oidc_url: format!("{}/oidc", issuer),
406 }
407 }
408
409 #[tokio::test]
410 async fn test_create_platform() {
411 let store = InMemoryPlatformStore::new();
412 let platform = create_test_platform("https://canvas.instructure.com");
413
414 let created = store.create(platform.clone()).await.unwrap();
415 assert_eq!(created.issuer, platform.issuer);
416 assert_eq!(created.name, platform.name);
417 }
418
419 #[tokio::test]
420 async fn test_create_duplicate_platform_fails() {
421 let store = InMemoryPlatformStore::new();
422 let platform = create_test_platform("https://canvas.instructure.com");
423
424 store.create(platform.clone()).await.unwrap();
425 let result = store.create(platform).await;
426 assert!(result.is_err());
427 }
428
429 #[tokio::test]
430 async fn test_find_by_iss() {
431 let store = InMemoryPlatformStore::new();
432 let platform = create_test_platform("https://canvas.instructure.com");
433
434 store.create(platform.clone()).await.unwrap();
435
436 let found = store
437 .find_by_iss("https://canvas.instructure.com")
438 .await
439 .unwrap();
440 assert!(found.is_some());
441 assert_eq!(found.unwrap().issuer, platform.issuer);
442 }
443
444 #[tokio::test]
445 async fn test_find_by_iss_not_found() {
446 let store = InMemoryPlatformStore::new();
447
448 let found = store
449 .find_by_iss("https://nonexistent.com")
450 .await
451 .unwrap();
452 assert!(found.is_none());
453 }
454
455 #[tokio::test]
456 async fn test_update_platform() {
457 let store = InMemoryPlatformStore::new();
458 let mut platform = create_test_platform("https://canvas.instructure.com");
459
460 store.create(platform.clone()).await.unwrap();
461
462 platform.name = Some("Updated Platform".to_string());
463 let updated = store.update(&platform.issuer, platform.clone()).await.unwrap();
464 assert_eq!(updated.name, Some("Updated Platform".to_string()));
465
466 let found = store
467 .find_by_iss("https://canvas.instructure.com")
468 .await
469 .unwrap()
470 .unwrap();
471 assert_eq!(found.name, Some("Updated Platform".to_string()));
472 }
473
474 #[tokio::test]
475 async fn test_update_nonexistent_platform_fails() {
476 let store = InMemoryPlatformStore::new();
477 let platform = create_test_platform("https://canvas.instructure.com");
478
479 let issuer = platform.issuer.clone();
480 let result = store.update(&issuer, platform).await;
481 assert!(result.is_err());
482 }
483
484 #[tokio::test]
485 async fn test_delete_platform() {
486 let store = InMemoryPlatformStore::new();
487 let platform = create_test_platform("https://canvas.instructure.com");
488
489 store.create(platform.clone()).await.unwrap();
490 store.delete(&platform.issuer).await.unwrap();
491
492 let found = store.find_by_iss(&platform.issuer).await.unwrap();
493 assert!(found.is_none());
494 }
495
496 #[tokio::test]
497 async fn test_delete_nonexistent_platform_fails() {
498 let store = InMemoryPlatformStore::new();
499 let result = store.delete("https://nonexistent.com").await;
500 assert!(result.is_err());
501 }
502
503 #[tokio::test]
504 async fn test_list_platforms() {
505 let store = InMemoryPlatformStore::new();
506
507 let platform1 = create_test_platform("https://canvas.instructure.com");
508 let platform2 = create_test_platform("https://moodle.org");
509
510 store.create(platform1).await.unwrap();
511 store.create(platform2).await.unwrap();
512
513 let platforms = store.list().await.unwrap();
514 assert_eq!(platforms.len(), 2);
515 }
516
517 #[tokio::test]
518 async fn test_list_empty() {
519 let store = InMemoryPlatformStore::new();
520 let platforms = store.list().await.unwrap();
521 assert_eq!(platforms.len(), 0);
522 }
523
524 #[tokio::test]
525 async fn test_backward_compatible_methods() {
526 let store = InMemoryPlatformStore::new();
527 let platform = create_test_platform("https://canvas.instructure.com");
528
529 store.create(platform.clone()).await.unwrap();
530
531 let oidc_url = store.get_oidc_url().await.unwrap();
532 assert_eq!(oidc_url, platform.oidc_url);
533
534 let jwks_url = store.get_jwk_server_url().await.unwrap();
535 assert_eq!(jwks_url, platform.jwks_url);
536
537 let token_url = store.get_token_url().await.unwrap();
538 assert_eq!(token_url, platform.token_url);
539 }
540
541 #[tokio::test]
542 async fn test_backward_compatible_methods_no_platform() {
543 let store = InMemoryPlatformStore::new();
544
545 let result = store.get_oidc_url().await;
546 assert!(result.is_err());
547
548 let result = store.get_jwk_server_url().await;
549 assert!(result.is_err());
550
551 let result = store.get_token_url().await;
552 assert!(result.is_err());
553 }
554}