Skip to main content

affinidi_did_resolver_cache_sdk/
lib.rs

1/*!
2DID Universal Resolver Cache Client SDK
3
4Used to easily connect to the DID Universal Resolver Cache.
5
6# Crate features
7As this crate can be used either natively or in a WASM environment, the following features are available:
8* **local**
9  * **default** - Enables the local mode of the SDK. This is the default mode.
10* **network**
11    * Enables the network mode of the SDK. This mode requires a run-time service address to connect to.
12    * This feature is NOT supported in a WASM environment. Will cause a compile error if used in WASM.
13*/
14
15#[cfg(all(feature = "network", target_arch = "wasm32"))]
16compile_error!("Cannot enable both features at the same time");
17
18use affinidi_did_common::Document;
19use config::DIDCacheConfig;
20use errors::DIDCacheError;
21use highway::{HighwayHash, HighwayHasher};
22use moka::future::Cache;
23#[cfg(feature = "network")]
24use networking::{
25    WSRequest,
26    network::{NetworkTask, WSCommands},
27};
28#[cfg(feature = "network")]
29use std::sync::Arc;
30use std::{fmt, time::Duration};
31#[cfg(feature = "network")]
32use tokio::sync::{Mutex, mpsc};
33use tracing::debug;
34use wasm_bindgen::JsValue;
35use wasm_bindgen::prelude::*;
36
37pub mod config;
38pub mod errors;
39#[cfg(feature = "network")]
40pub mod networking;
41mod resolver;
42
43/// DID Methods supported by the DID Universal Resolver Cache
44#[derive(Clone, Debug, PartialEq, Eq, Hash)]
45#[wasm_bindgen]
46pub enum DIDMethod {
47    ETHR,
48    JWK,
49    KEY,
50    PEER,
51    PKH,
52    WEB,
53    WEBVH,
54    CHEQD,
55    SCID,
56    EXAMPLE,
57}
58
59/// Helper function to convert a DIDMethod to a string
60impl fmt::Display for DIDMethod {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        match self {
63            DIDMethod::ETHR => write!(f, "ethr"),
64            DIDMethod::JWK => write!(f, "jwk"),
65            DIDMethod::KEY => write!(f, "key"),
66            DIDMethod::PEER => write!(f, "peer"),
67            DIDMethod::PKH => write!(f, "pkh"),
68            DIDMethod::WEB => write!(f, "web"),
69            DIDMethod::WEBVH => write!(f, "webvh"),
70            DIDMethod::CHEQD => write!(f, "cheqd"),
71            DIDMethod::SCID => write!(f, "scid"),
72            DIDMethod::EXAMPLE => write!(f, "example"),
73        }
74    }
75}
76
77/// Helper function to convert a string to a DIDMethod
78impl TryFrom<String> for DIDMethod {
79    type Error = DIDCacheError;
80
81    fn try_from(value: String) -> Result<Self, Self::Error> {
82        value.as_str().try_into()
83    }
84}
85
86impl TryFrom<&str> for DIDMethod {
87    type Error = DIDCacheError;
88
89    fn try_from(value: &str) -> Result<Self, Self::Error> {
90        match value.to_lowercase().as_str() {
91            "ethr" => Ok(DIDMethod::ETHR),
92            "jwk" => Ok(DIDMethod::JWK),
93            "key" => Ok(DIDMethod::KEY),
94            "peer" => Ok(DIDMethod::PEER),
95            "pkh" => Ok(DIDMethod::PKH),
96            "web" => Ok(DIDMethod::WEB),
97            "webvh" => Ok(DIDMethod::WEBVH),
98            "cheqd" => Ok(DIDMethod::CHEQD),
99            "scid" => Ok(DIDMethod::SCID),
100            #[cfg(feature = "did_example")]
101            "example" => Ok(DIDMethod::EXAMPLE),
102            _ => Err(DIDCacheError::UnsupportedMethod(value.to_string())),
103        }
104    }
105}
106
107pub struct ResolveResponse {
108    pub did: String,
109    pub method: DIDMethod,
110    pub did_hash: [u64; 2],
111    pub doc: Document,
112    pub cache_hit: bool,
113}
114
115// ***************************************************************************
116
117/// [DIDCacheClient] is how you interact with the DID Universal Resolver Cache
118/// config: Configuration for the SDK
119/// cache: Local cache for resolved DIDs
120/// network_task: OPTIONAL: Task to handle network requests
121/// network_rx: OPTIONAL: Channel to listen for responses from the network task
122#[wasm_bindgen(getter_with_clone)]
123#[derive(Clone)]
124pub struct DIDCacheClient {
125    config: DIDCacheConfig,
126    cache: Cache<[u64; 2], Document>,
127    #[cfg(feature = "network")]
128    network_task_tx: Option<mpsc::Sender<WSCommands>>,
129    #[cfg(feature = "network")]
130    network_task_rx: Option<Arc<Mutex<mpsc::Receiver<WSCommands>>>>,
131    #[cfg(feature = "did_example")]
132    did_example_cache: did_example::DiDExampleCache,
133}
134
135impl DIDCacheClient {
136    /// Front end for resolving a DID
137    /// Will check the cache first, and if not found, will resolve the DID
138    /// Returns the initial DID, the hashed DID, and the resolved DID Document
139    /// NOTE: The DID Document id may be different to the requested DID due to the DID having been updated.
140    ///       The original DID should be in the `also_known_as` field of the DID Document.
141    pub async fn resolve(&self, did: &str) -> Result<ResolveResponse, DIDCacheError> {
142        use affinidi_did_common::DID;
143
144        // If DID's size is greater than 1KB we don't resolve it
145        if did.len() > self.config.max_did_size_in_bytes {
146            return Err(DIDCacheError::DIDError(format!(
147                "The DID size of {}bytes exceeds the limit of {1}. Please ensure the size is less than {1}.",
148                did.len(),
149                self.config.max_did_size_in_bytes
150            )));
151        }
152
153        let parts: Vec<&str> = did.split(':').collect();
154        if parts.len() < 3 {
155            return Err(DIDCacheError::DIDError(format!(
156                "did isn't to spec! did ({did})",
157            )));
158        }
159
160        let key_parts: Vec<&str> = parts.last().unwrap().split(".").collect();
161        if key_parts.len() > self.config.max_did_parts {
162            return Err(DIDCacheError::DIDError(format!(
163                "The total number of keys and/or services must be less than or equal to {:?}, but {:?} were found.",
164                self.config.max_did_parts,
165                parts.len()
166            )));
167        }
168
169        // Parse the DID string into a DID struct
170        let parsed_did: DID = did
171            .parse()
172            .map_err(|e| DIDCacheError::DIDError(format!("Failed to parse DID: {e}")))?;
173
174        let hash = DIDCacheClient::hash_did(did);
175
176        #[cfg(feature = "did_example")]
177        // Short-circuit for example DIDs
178        if parts[1] == "example"
179            && let Some(doc) = self.did_example_cache.get(did)
180        {
181            return Ok(ResolveResponse {
182                did: did.to_string(),
183                method: parts[1].try_into()?,
184                did_hash: hash,
185                doc: doc.clone(),
186                cache_hit: true,
187            });
188        }
189
190        // Check if the DID is in the cache
191        if let Some(doc) = self.cache.get(&hash).await {
192            debug!("found did ({}) in cache", did);
193            Ok(ResolveResponse {
194                did: did.to_string(),
195                method: parts[1].try_into()?,
196                did_hash: hash,
197                doc,
198                cache_hit: true,
199            })
200        } else {
201            debug!("did ({}) NOT in cache hash ({:#?})", did, hash);
202            // If the DID is not in the cache, resolve it (local or via network)
203            #[cfg(feature = "network")]
204            let doc = {
205                if self.config.service_address.is_some() {
206                    self.network_resolve(did, hash).await?
207                } else {
208                    self.local_resolve(&parsed_did).await?
209                }
210            };
211
212            #[cfg(not(feature = "network"))]
213            let doc = self.local_resolve(&parsed_did).await?;
214
215            debug!("adding did ({}) to cache ({:#?})", did, hash);
216            self.cache.insert(hash, doc.clone()).await;
217            Ok(ResolveResponse {
218                did: did.to_string(),
219                method: parts[1].try_into()?,
220                did_hash: hash,
221                doc,
222                cache_hit: false,
223            })
224        }
225    }
226
227    /// If you want to interact directly with the DID Document cache
228    /// This will return a clone of the cache (the clone is cheap, and the cache is shared)
229    /// For example, accessing cache statistics or manually inserting a DID Document
230    pub fn get_cache(&self) -> Cache<[u64; 2], Document> {
231        self.cache.clone()
232    }
233
234    /// Stops the network task if it is running and removes any resources
235    #[cfg(feature = "network")]
236    pub fn stop(&self) {
237        if let Some(tx) = self.network_task_tx.as_ref() {
238            let _ = tx.blocking_send(WSCommands::Exit);
239        }
240    }
241
242    /// Removes the specified DID from the cache
243    /// Returns the removed DID Document if it was in the cache, or None if it was not
244    pub async fn remove(&self, did: &str) -> Option<Document> {
245        self.cache.remove(&DIDCacheClient::hash_did(did)).await
246    }
247
248    /// Add a DID Document to the cache manually
249    pub async fn add_did_document(&mut self, did: &str, doc: Document) {
250        let hash = DIDCacheClient::hash_did(did);
251        debug!("manually adding did ({}) hash({:#?}) to cache", did, hash);
252        self.cache.insert(hash, doc).await;
253    }
254
255    /// Convenience function to hash a DID
256    pub fn hash_did(did: &str) -> [u64; 2] {
257        // Use a consistent Seed so it always hashes to the same value
258        HighwayHasher::default().hash128(did.as_bytes())
259    }
260}
261
262/// Following are the WASM bindings for the DIDCacheClient
263#[wasm_bindgen]
264impl DIDCacheClient {
265    /// Create a new DIDCacheClient with configuration generated from [ClientConfigBuilder](config::ClientConfigBuilder)
266    ///
267    /// Will return an error if the configuration is invalid.
268    ///
269    /// Establishes websocket connection and sets up the cache.
270    // using Self instead of DIDCacheClient leads to E0401 errors in dependent crates
271    // this is due to wasm_bindgen generated code (check via `cargo expand`)
272    pub async fn new(config: DIDCacheConfig) -> Result<DIDCacheClient, DIDCacheError> {
273        // Create the initial cache
274        let cache = Cache::builder()
275            .max_capacity(config.cache_capacity.into())
276            .time_to_live(Duration::from_secs(config.cache_ttl.into()))
277            .build();
278
279        #[cfg(feature = "network")]
280        let mut client = Self {
281            config,
282            cache,
283            network_task_tx: None,
284            network_task_rx: None,
285            #[cfg(feature = "did_example")]
286            did_example_cache: did_example::DiDExampleCache::new(),
287        };
288        #[cfg(not(feature = "network"))]
289        let client = Self {
290            config,
291            cache,
292            #[cfg(feature = "did_example")]
293            did_example_cache: did_example::DiDExampleCache::new(),
294        };
295
296        #[cfg(feature = "network")]
297        {
298            if client.config.service_address.is_some() {
299                // Running in network mode
300
301                // Channel to communicate from SDK to network task
302                let (sdk_tx, mut task_rx) = mpsc::channel(32);
303                // Channel to communicate from network task to SDK
304                let (task_tx, sdk_rx) = mpsc::channel(32);
305
306                client.network_task_tx = Some(sdk_tx);
307                client.network_task_rx = Some(Arc::new(Mutex::new(sdk_rx)));
308
309                // Start the network task
310                let _config = client.config.clone();
311                tokio::spawn(async move {
312                    let _ = NetworkTask::run(_config, &mut task_rx, &task_tx).await;
313                });
314
315                if let Some(arc_rx) = client.network_task_rx.as_ref() {
316                    // Wait for the network task to be ready
317                    let mut rx = arc_rx.lock().await;
318                    rx.recv().await.unwrap();
319                }
320            }
321        }
322
323        Ok(client)
324    }
325
326    pub async fn wasm_resolve(&self, did: &str) -> Result<JsValue, DIDCacheError> {
327        let response = self.resolve(did).await?;
328
329        match serde_wasm_bindgen::to_value(&response.doc) {
330            Ok(values) => Ok(values),
331            Err(err) => Err(DIDCacheError::DIDError(format!(
332                "Error serializing DID Document: {err}",
333            ))),
334        }
335    }
336
337    #[cfg(feature = "did_example")]
338    pub fn add_example_did(&mut self, doc: &str) -> Result<(), DIDCacheError> {
339        self.did_example_cache
340            .insert_from_string(doc)
341            .map_err(|e| DIDCacheError::DIDError(format!("Couldn't parse example DID: {e}")))
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    const DID_KEY: &str = "did:key:z6MkiToqovww7vYtxm1xNM15u9JzqzUFZ1k7s7MazYJUyAxv";
350
351    async fn basic_local_client() -> DIDCacheClient {
352        let config = config::DIDCacheConfigBuilder::default().build();
353        DIDCacheClient::new(config).await.unwrap()
354    }
355
356    #[tokio::test]
357    async fn remove_existing_cached_did() {
358        let client = basic_local_client().await;
359
360        // Resolve a DID which automatically adds it to the cache
361        let response = client.resolve(DID_KEY).await.unwrap();
362        let removed_doc = client.remove(DID_KEY).await;
363        assert_eq!(removed_doc, Some(response.doc));
364    }
365
366    #[tokio::test]
367    async fn remove_non_existing_cached_did() {
368        let client = basic_local_client().await;
369
370        // We haven't resolved the cache, so it shouldn't be in the cache
371        let removed_doc = client.remove(DID_KEY).await;
372        assert_eq!(removed_doc, None);
373    }
374}