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}