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}