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 issuer = platform.issuer.clone();
243 /// let updated = store.update(&issuer, platform).await?;
244 /// # Ok(())
245 /// # }
246 /// ```
247 async fn update(
248 &self,
249 issuer: &str,
250 platform: PlatformData,
251 ) -> Result<PlatformData, PlatformError>;
252
253 /// Delete platform configuration
254 ///
255 /// # Arguments
256 ///
257 /// * `issuer` - The issuer of the platform to delete
258 ///
259 /// # Errors
260 ///
261 /// Returns `PlatformError` if:
262 /// * The platform does not exist
263 /// * The underlying storage operation fails
264 ///
265 /// # Examples
266 ///
267 /// ```no_run
268 /// # use atomic_lti::stores::platform_store::PlatformStore;
269 /// # use atomic_lti::errors::PlatformError;
270 /// # async fn example(store: &dyn PlatformStore) -> Result<(), PlatformError> {
271 /// store.delete("https://canvas.instructure.com").await?;
272 /// # Ok(())
273 /// # }
274 /// ```
275 async fn delete(&self, issuer: &str) -> Result<(), PlatformError>;
276
277 /// List all platform configurations
278 ///
279 /// # Returns
280 ///
281 /// A vector of all platform configurations in the store
282 ///
283 /// # Errors
284 ///
285 /// Returns `PlatformError` if the underlying storage operation fails
286 ///
287 /// # Examples
288 ///
289 /// ```no_run
290 /// # use atomic_lti::stores::platform_store::PlatformStore;
291 /// # use atomic_lti::errors::PlatformError;
292 /// # async fn example(store: &dyn PlatformStore) -> Result<(), PlatformError> {
293 /// let platforms = store.list().await?;
294 /// for platform in platforms {
295 /// println!("Platform: {} ({})", platform.name.unwrap_or_default(), platform.issuer);
296 /// }
297 /// # Ok(())
298 /// # }
299 /// ```
300 async fn list(&self) -> Result<Vec<PlatformData>, PlatformError>;
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use std::collections::HashMap;
307 use std::sync::{Arc, Mutex};
308
309 /// In-memory test implementation of PlatformStore
310 #[derive(Clone)]
311 struct InMemoryPlatformStore {
312 platforms: Arc<Mutex<HashMap<String, PlatformData>>>,
313 }
314
315 impl InMemoryPlatformStore {
316 fn new() -> Self {
317 Self {
318 platforms: Arc::new(Mutex::new(HashMap::new())),
319 }
320 }
321 }
322
323 #[async_trait]
324 impl PlatformStore for InMemoryPlatformStore {
325 async fn get_oidc_url(&self) -> Result<String, PlatformError> {
326 // For backward compatibility, return the first platform's OIDC URL
327 let platforms = self.platforms.lock().unwrap();
328 platforms
329 .values()
330 .next()
331 .map(|p| p.oidc_url.clone())
332 .ok_or_else(|| PlatformError::InvalidIss("No platform configured".to_string()))
333 }
334
335 async fn get_jwk_server_url(&self) -> Result<String, PlatformError> {
336 let platforms = self.platforms.lock().unwrap();
337 platforms
338 .values()
339 .next()
340 .map(|p| p.jwks_url.clone())
341 .ok_or_else(|| PlatformError::InvalidIss("No platform configured".to_string()))
342 }
343
344 async fn get_token_url(&self) -> Result<String, PlatformError> {
345 let platforms = self.platforms.lock().unwrap();
346 platforms
347 .values()
348 .next()
349 .map(|p| p.token_url.clone())
350 .ok_or_else(|| PlatformError::InvalidIss("No platform configured".to_string()))
351 }
352
353 async fn create(&self, platform: PlatformData) -> Result<PlatformData, PlatformError> {
354 let mut platforms = self.platforms.lock().unwrap();
355 if platforms.contains_key(&platform.issuer) {
356 return Err(PlatformError::InvalidIss(format!(
357 "Platform with issuer {} already exists",
358 platform.issuer
359 )));
360 }
361 platforms.insert(platform.issuer.clone(), platform.clone());
362 Ok(platform)
363 }
364
365 async fn find_by_iss(&self, issuer: &str) -> Result<Option<PlatformData>, PlatformError> {
366 let platforms = self.platforms.lock().unwrap();
367 Ok(platforms.get(issuer).cloned())
368 }
369
370 async fn update(
371 &self,
372 issuer: &str,
373 platform: PlatformData,
374 ) -> Result<PlatformData, PlatformError> {
375 let mut platforms = self.platforms.lock().unwrap();
376 if !platforms.contains_key(issuer) {
377 return Err(PlatformError::InvalidIss(format!(
378 "Platform with issuer {} not found",
379 issuer
380 )));
381 }
382 platforms.insert(issuer.to_string(), platform.clone());
383 Ok(platform)
384 }
385
386 async fn delete(&self, issuer: &str) -> Result<(), PlatformError> {
387 let mut platforms = self.platforms.lock().unwrap();
388 platforms
389 .remove(issuer)
390 .ok_or_else(|| PlatformError::InvalidIss(format!("Platform with issuer {} not found", issuer)))?;
391 Ok(())
392 }
393
394 async fn list(&self) -> Result<Vec<PlatformData>, PlatformError> {
395 let platforms = self.platforms.lock().unwrap();
396 Ok(platforms.values().cloned().collect())
397 }
398 }
399
400 fn create_test_platform(issuer: &str) -> PlatformData {
401 PlatformData {
402 issuer: issuer.to_string(),
403 name: Some(format!("Test Platform {}", issuer)),
404 jwks_url: format!("{}/jwks", issuer),
405 token_url: format!("{}/token", issuer),
406 oidc_url: format!("{}/oidc", issuer),
407 }
408 }
409
410 #[tokio::test]
411 async fn test_create_platform() {
412 let store = InMemoryPlatformStore::new();
413 let platform = create_test_platform("https://canvas.instructure.com");
414
415 let created = store.create(platform.clone()).await.unwrap();
416 assert_eq!(created.issuer, platform.issuer);
417 assert_eq!(created.name, platform.name);
418 }
419
420 #[tokio::test]
421 async fn test_create_duplicate_platform_fails() {
422 let store = InMemoryPlatformStore::new();
423 let platform = create_test_platform("https://canvas.instructure.com");
424
425 store.create(platform.clone()).await.unwrap();
426 let result = store.create(platform).await;
427 assert!(result.is_err());
428 }
429
430 #[tokio::test]
431 async fn test_find_by_iss() {
432 let store = InMemoryPlatformStore::new();
433 let platform = create_test_platform("https://canvas.instructure.com");
434
435 store.create(platform.clone()).await.unwrap();
436
437 let found = store
438 .find_by_iss("https://canvas.instructure.com")
439 .await
440 .unwrap();
441 assert!(found.is_some());
442 assert_eq!(found.unwrap().issuer, platform.issuer);
443 }
444
445 #[tokio::test]
446 async fn test_find_by_iss_not_found() {
447 let store = InMemoryPlatformStore::new();
448
449 let found = store
450 .find_by_iss("https://nonexistent.com")
451 .await
452 .unwrap();
453 assert!(found.is_none());
454 }
455
456 #[tokio::test]
457 async fn test_update_platform() {
458 let store = InMemoryPlatformStore::new();
459 let mut platform = create_test_platform("https://canvas.instructure.com");
460
461 store.create(platform.clone()).await.unwrap();
462
463 platform.name = Some("Updated Platform".to_string());
464 let updated = store.update(&platform.issuer, platform.clone()).await.unwrap();
465 assert_eq!(updated.name, Some("Updated Platform".to_string()));
466
467 let found = store
468 .find_by_iss("https://canvas.instructure.com")
469 .await
470 .unwrap()
471 .unwrap();
472 assert_eq!(found.name, Some("Updated Platform".to_string()));
473 }
474
475 #[tokio::test]
476 async fn test_update_nonexistent_platform_fails() {
477 let store = InMemoryPlatformStore::new();
478 let platform = create_test_platform("https://canvas.instructure.com");
479
480 let issuer = platform.issuer.clone();
481 let result = store.update(&issuer, platform).await;
482 assert!(result.is_err());
483 }
484
485 #[tokio::test]
486 async fn test_delete_platform() {
487 let store = InMemoryPlatformStore::new();
488 let platform = create_test_platform("https://canvas.instructure.com");
489
490 store.create(platform.clone()).await.unwrap();
491 store.delete(&platform.issuer).await.unwrap();
492
493 let found = store.find_by_iss(&platform.issuer).await.unwrap();
494 assert!(found.is_none());
495 }
496
497 #[tokio::test]
498 async fn test_delete_nonexistent_platform_fails() {
499 let store = InMemoryPlatformStore::new();
500 let result = store.delete("https://nonexistent.com").await;
501 assert!(result.is_err());
502 }
503
504 #[tokio::test]
505 async fn test_list_platforms() {
506 let store = InMemoryPlatformStore::new();
507
508 let platform1 = create_test_platform("https://canvas.instructure.com");
509 let platform2 = create_test_platform("https://moodle.org");
510
511 store.create(platform1).await.unwrap();
512 store.create(platform2).await.unwrap();
513
514 let platforms = store.list().await.unwrap();
515 assert_eq!(platforms.len(), 2);
516 }
517
518 #[tokio::test]
519 async fn test_list_empty() {
520 let store = InMemoryPlatformStore::new();
521 let platforms = store.list().await.unwrap();
522 assert_eq!(platforms.len(), 0);
523 }
524
525 #[tokio::test]
526 async fn test_backward_compatible_methods() {
527 let store = InMemoryPlatformStore::new();
528 let platform = create_test_platform("https://canvas.instructure.com");
529
530 store.create(platform.clone()).await.unwrap();
531
532 let oidc_url = store.get_oidc_url().await.unwrap();
533 assert_eq!(oidc_url, platform.oidc_url);
534
535 let jwks_url = store.get_jwk_server_url().await.unwrap();
536 assert_eq!(jwks_url, platform.jwks_url);
537
538 let token_url = store.get_token_url().await.unwrap();
539 assert_eq!(token_url, platform.token_url);
540 }
541
542 #[tokio::test]
543 async fn test_backward_compatible_methods_no_platform() {
544 let store = InMemoryPlatformStore::new();
545
546 let result = store.get_oidc_url().await;
547 assert!(result.is_err());
548
549 let result = store.get_jwk_server_url().await;
550 assert!(result.is_err());
551
552 let result = store.get_token_url().await;
553 assert!(result.is_err());
554 }
555}