Skip to main content

did_resolver_cheqd/resolution/
resolver.rs

1use std::{cmp::Ordering, collections::HashMap};
2
3use chrono::{DateTime, Utc};
4use tokio::sync::Mutex;
5use tonic::transport::{Channel, ClientTlsConfig, Endpoint};
6
7// transformer helpers produce JSON values; no direct types imported here.
8use crate::{
9    error::{DidCheqdError, DidCheqdResult},
10    proto::cheqd::{
11        did::v2::{
12            QueryDidDocRequest, QueryDidDocVersionRequest,
13            query_client::QueryClient as DidQueryClient,
14        },
15        resource::v2::{
16            Metadata as CheqdResourceMetadata, QueryCollectionResourcesRequest,
17            QueryResourceRequest, query_client::QueryClient as ResourceQueryClient,
18        },
19    },
20    resolution::parser::DidCheqdParsed,
21};
22
23/// default namespace for the cheqd "mainnet". as it would appear in a DID.
24pub const MAINNET_NAMESPACE: &str = "mainnet";
25/// default gRPC URL for the cheqd "mainnet".
26pub const MAINNET_DEFAULT_GRPC: &str = "https://grpc.cheqd.net:443";
27/// default namespace for the cheqd "testnet". as it would appear in a DID.
28pub const TESTNET_NAMESPACE: &str = "testnet";
29/// default gRPC URL for the cheqd "testnet".
30pub const TESTNET_DEFAULT_GRPC: &str = "https://grpc.cheqd.network:443";
31
32/// Configuration for the [DidCheqdResolver] resolver
33pub struct DidCheqdResolverConfiguration {
34    /// Configuration for which networks are resolvable
35    pub networks: Vec<NetworkConfiguration>,
36}
37
38impl Default for DidCheqdResolverConfiguration {
39    fn default() -> Self {
40        Self {
41            networks: vec![
42                NetworkConfiguration::mainnet(),
43                NetworkConfiguration::testnet(),
44            ],
45        }
46    }
47}
48
49/// Configuration for a cheqd network. Defining details such as where to resolve DIDs from.
50pub struct NetworkConfiguration {
51    /// the cheqd nodes gRPC URL
52    pub grpc_url: String,
53    /// the namespace of the network - as it would appear in a DID (did:cheqd:namespace:123)
54    pub namespace: String,
55}
56
57impl Clone for NetworkConfiguration {
58    fn clone(&self) -> Self {
59        Self {
60            grpc_url: self.grpc_url.clone(),
61            namespace: self.namespace.clone(),
62        }
63    }
64}
65
66impl Clone for DidCheqdResolverConfiguration {
67    fn clone(&self) -> Self {
68        Self {
69            networks: self.networks.clone(),
70        }
71    }
72}
73
74impl NetworkConfiguration {
75    /// default configuration for cheqd mainnet
76    pub fn mainnet() -> Self {
77        Self {
78            grpc_url: String::from(MAINNET_DEFAULT_GRPC),
79            namespace: String::from(MAINNET_NAMESPACE),
80        }
81    }
82
83    /// default configuration for cheqd testnet
84    pub fn testnet() -> Self {
85        Self {
86            grpc_url: String::from(TESTNET_DEFAULT_GRPC),
87            namespace: String::from(TESTNET_NAMESPACE),
88        }
89    }
90}
91
92#[derive(Clone)]
93struct CheqdGrpcClient {
94    did: DidQueryClient<Channel>,
95    resources: ResourceQueryClient<Channel>,
96}
97
98pub struct DidCheqdResolver {
99    networks: Vec<NetworkConfiguration>,
100    network_clients: Mutex<HashMap<String, CheqdGrpcClient>>,
101}
102
103// Note: we intentionally avoid depending on external `did_resolver` types here.
104// This module exposes string-based resolution helpers that return proto results
105// or raw bytes + media type so callers can transform them into the desired
106// in-repo types without importing the external did_resolver crate.
107
108impl DidCheqdResolver {
109    /// Assemble a new resolver with the given config.
110    ///
111    /// [DidCheqdResolverConfiguration::default] can be used if default mainnet & testnet
112    /// configurations are suitable.
113    pub fn new(configuration: DidCheqdResolverConfiguration) -> Self {
114        Self {
115            networks: configuration.networks,
116            network_clients: Default::default(),
117        }
118    }
119
120    /// lazily get the client, initializing if not already
121    async fn client_for_network(&self, network: &str) -> DidCheqdResult<CheqdGrpcClient> {
122        let mut lock = self.network_clients.lock().await;
123        if let Some(client) = lock.get(network) {
124            return Ok(client.clone());
125        }
126
127        let network_config = self
128            .networks
129            .iter()
130            .find(|n| n.namespace == network)
131            .ok_or(DidCheqdError::NetworkNotSupported(network.to_owned()))?;
132
133        let endpoint = Endpoint::new(network_config.grpc_url.to_string())
134            .map_err(|_e| DidCheqdError::BadConfiguration("Failed to parse GRPC url".to_string()))?
135            .tls_config(ClientTlsConfig::new().with_webpki_roots())
136            .map_err(|e| DidCheqdError::TransportError(Box::new(e)))?;
137
138        // Connect to the channel
139        let channel = endpoint
140            .connect()
141            .await
142            .map_err(|e| DidCheqdError::TransportError(Box::new(e)))?;
143
144        let did_client = DidQueryClient::new(channel.clone());
145        let resource_client = ResourceQueryClient::new(channel);
146
147        let client = CheqdGrpcClient {
148            did: did_client,
149            resources: resource_client,
150        };
151
152        lock.insert(network.to_owned(), client.clone());
153
154        Ok(client)
155    }
156
157    /// Query a DID Doc by a DID string (e.g. "did:cheqd:mainnet:zF7...").
158    /// Returns the raw proto DIDDoc and an optional proto metadata object.
159    pub async fn query_did_doc_by_str(
160        &self,
161        _did_str: &str,
162        parsed_did: DidCheqdParsed,
163    ) -> DidCheqdResult<(
164        crate::proto::cheqd::did::v2::DidDoc,
165        Option<crate::proto::cheqd::did::v2::Metadata>,
166    )> {
167        // parsed.namespace is an owned String; borrow as &str for client lookup
168        let network = parsed_did.namespace.as_str();
169        let mut client = self.client_for_network(network).await?;
170
171        if parsed_did.version.is_some() {
172            let request = tonic::Request::new(QueryDidDocVersionRequest {
173                id: parsed_did.did.to_string(),
174                version: parsed_did.version.unwrap(),
175            });
176            let response = client
177                .did
178                .did_doc_version(request)
179                .await
180                .map_err(|e| DidCheqdError::NonSuccessResponse(Box::new(e)))?;
181            let query_response = response.into_inner();
182            let query_doc_res = query_response.value.ok_or(DidCheqdError::InvalidResponse(
183                "DIDDoc query did version not return a value".into(),
184            ))?;
185            let query_doc = query_doc_res.did_doc.ok_or(DidCheqdError::InvalidResponse(
186                "DIDDoc query did version not return a DIDDoc".into(),
187            ))?;
188
189            Ok((query_doc, query_doc_res.metadata))
190        } else {
191            let request = tonic::Request::new(QueryDidDocRequest {
192                id: parsed_did.did.to_string(),
193            });
194            let response = client
195                .did
196                .did_doc(request)
197                .await
198                .map_err(|e| DidCheqdError::NonSuccessResponse(Box::new(e)))?;
199            let query_response = response.into_inner();
200            let query_doc_res = query_response.value.ok_or(DidCheqdError::InvalidResponse(
201                "DIDDoc query did not return a value".into(),
202            ))?;
203            let query_doc = query_doc_res.did_doc.ok_or(DidCheqdError::InvalidResponse(
204                "DIDDoc query did not return a DIDDoc".into(),
205            ))?;
206
207            Ok((query_doc, query_doc_res.metadata))
208        }
209    }
210
211    /// Query a DID resource by a DID URL string and return raw bytes and optional
212    /// media type. Supported forms mirror the earlier functionality:
213    /// * `did:cheqd:<namespace>:<did>/resources/<resource_id>`
214    /// * `did:cheqd:<namespace>:<did>?resourceName=...&resourceType=...&resourceVersionTime=...`
215    pub async fn query_resource_by_str(
216        &self,
217        did_url: &str,
218        parsed_did: DidCheqdParsed,
219    ) -> DidCheqdResult<(Vec<u8>, Option<String>)> {
220        // borrow the owned Strings for local use
221        let network = parsed_did.namespace.as_str();
222        let did_id = parsed_did.id.as_str();
223
224        // If parser injected a resourceId (from a path like /resources/<id>), resolve by id.
225        if let Some(ref qmap) = parsed_did.query {
226            if let Some(resource_id) = qmap.get("resourceId") {
227                return self
228                    .resolve_resource_by_id(did_id, resource_id.as_str(), network)
229                    .await;
230            }
231        }
232
233        // Otherwise, if query parameters indicate name+type lookup, perform that
234        if let Some(qmap) = parsed_did.query {
235            let resource_name = qmap.get("resourceName");
236            let resource_type = qmap.get("resourceType");
237            let version_time = qmap.get("resourceVersionTime");
238
239            let (Some(resource_name), Some(resource_type)) = (resource_name, resource_type) else {
240                return Err(DidCheqdError::InvalidDidUrl(format!(
241                    "Resolver can only resolve by exact resource ID or name+type combination {did_url}"
242                )));
243            };
244
245            let version_time = match version_time {
246                Some(v) => DateTime::parse_from_rfc3339(v)
247                    .map_err(|e| DidCheqdError::InvalidDidUrl(e.to_string()))?
248                    .to_utc(),
249                None => Utc::now(),
250            };
251
252            return self
253                .resolve_resource_by_name_type_and_time(
254                    did_id,
255                    resource_name.as_str(),
256                    resource_type.as_str(),
257                    version_time,
258                    network,
259                )
260                .await;
261        }
262
263        Err(DidCheqdError::InvalidDidUrl(format!(
264            "No resource path or query present: {did_url}"
265        )))
266    }
267
268    /// Resolve a resource from a collection (did_id) and network by an exact id.
269    async fn resolve_resource_by_id(
270        &self,
271        did_id: &str,
272        resource_id: &str,
273        network: &str,
274    ) -> DidCheqdResult<(Vec<u8>, Option<String>)> {
275        let mut client = self.client_for_network(network).await?;
276        let request = QueryResourceRequest {
277            collection_id: did_id.to_owned(),
278            id: resource_id.to_owned(),
279        };
280        let response = client
281            .resources
282            .resource(request)
283            .await
284            .map_err(|e| DidCheqdError::NonSuccessResponse(Box::new(e)))?;
285
286        let query_response = response.into_inner();
287        let query_response = query_response
288            .resource
289            .ok_or(DidCheqdError::InvalidResponse(
290                "Resource query did not return a value".into(),
291            ))?;
292        let query_resource = query_response
293            .resource
294            .ok_or(DidCheqdError::InvalidResponse(
295                "Resource query did not return a resource".into(),
296            ))?;
297        let query_metadata = query_response
298            .metadata
299            .ok_or(DidCheqdError::InvalidResponse(
300                "Resource query did not return metadata".into(),
301            ))?;
302
303        let media_type =
304            (!query_metadata.media_type.trim().is_empty()).then_some(query_metadata.media_type);
305
306        Ok((query_resource.data, media_type))
307    }
308
309    /// Resolve a resource from a given collection (did_id) & network, that has a given name & type,
310    /// as of a given time.
311    async fn resolve_resource_by_name_type_and_time(
312        &self,
313        did_id: &str,
314        name: &str,
315        rtyp: &str,
316        time: DateTime<Utc>,
317        network: &str,
318    ) -> DidCheqdResult<(Vec<u8>, Option<String>)> {
319        let mut client = self.client_for_network(network).await?;
320
321        let response = client
322            .resources
323            .collection_resources(QueryCollectionResourcesRequest {
324                collection_id: did_id.to_owned(),
325                // FUTURE - pagination
326                pagination: None,
327            })
328            .await
329            .map_err(|e| DidCheqdError::NonSuccessResponse(Box::new(e)))?;
330
331        let query_response = response.into_inner();
332        let resources = query_response.resources;
333        let mut filtered: Vec<_> =
334            filter_resources_by_name_and_type(resources.iter(), name, rtyp).collect();
335        filtered.sort_by(|a, b| desc_chronological_sort_resources(a, b));
336
337        let resource_meta = find_resource_just_before_time(filtered.into_iter(), time);
338
339        let Some(meta) = resource_meta else {
340            return Err(DidCheqdError::ResourceNotFound(format!(
341                "network: {network}, collection: {did_id}, name: {name}, type: {rtyp}, time: \
342                 {time}"
343            )));
344        };
345
346        let (data, media) = self
347            .resolve_resource_by_id(did_id, &meta.id, network)
348            .await?;
349        Ok((data, media))
350    }
351}
352
353/// Filter for resources which have a matching name and type
354fn filter_resources_by_name_and_type<'a>(
355    resources: impl Iterator<Item = &'a CheqdResourceMetadata> + 'a,
356    name: &'a str,
357    rtyp: &'a str,
358) -> impl Iterator<Item = &'a CheqdResourceMetadata> + 'a {
359    resources.filter(move |r| r.name == name && r.resource_type == rtyp)
360}
361
362/// Sort resources chronologically by their created timestamps
363fn desc_chronological_sort_resources(
364    b: &CheqdResourceMetadata,
365    a: &CheqdResourceMetadata,
366) -> Ordering {
367    let (a_secs, a_ns) = a
368        .created
369        .map(|v| {
370            let v = v.normalized();
371            (v.seconds, v.nanos)
372        })
373        .unwrap_or((0, 0));
374    let (b_secs, b_ns) = b
375        .created
376        .map(|v| {
377            let v = v.normalized();
378            (v.seconds, v.nanos)
379        })
380        .unwrap_or((0, 0));
381
382    match a_secs.cmp(&b_secs) {
383        Ordering::Equal => a_ns.cmp(&b_ns),
384        res => res,
385    }
386}
387
388/// assuming `resources` is sorted by `.created` time in descending order, find
389/// the resource which is closest to `before_time`, but NOT after.
390///
391/// Returns a reference to this resource if it exists.
392///
393/// e.g.:
394/// resources: [{created: 20}, {created: 15}, {created: 10}, {created: 5}]
395/// before_time: 14
396/// returns: {created: 10}
397///
398/// resources: [{created: 20}, {created: 15}, {created: 10}, {created: 5}]
399/// before_time: 4
400/// returns: None
401fn find_resource_just_before_time<'a>(
402    resources: impl Iterator<Item = &'a CheqdResourceMetadata>,
403    before_time: DateTime<Utc>,
404) -> Option<&'a CheqdResourceMetadata> {
405    let before_epoch = before_time.timestamp();
406
407    for r in resources {
408        let Some(created) = r.created else {
409            continue;
410        };
411
412        let created_epoch = created.normalized().seconds;
413        if created_epoch < before_epoch {
414            return Some(r);
415        }
416    }
417
418    None
419}
420
421#[cfg(test)]
422mod unit_tests {
423    use crate::resolution::parser::DidCheqdParser;
424
425    use super::*;
426
427    #[tokio::test]
428    async fn test_resolve_fails_if_no_network_config() {
429        let did = "did:cheqd:devnet:Ps1ysXP2Ae6GBfxNhNQNKN";
430        let resolver = DidCheqdResolver::new(Default::default());
431        let e = resolver
432            .query_did_doc_by_str(did, DidCheqdParser::parse(did).unwrap())
433            .await
434            .unwrap_err();
435        assert!(matches!(e, DidCheqdError::NetworkNotSupported(_)));
436    }
437
438    #[tokio::test]
439    async fn test_resolve_fails_if_bad_network_uri() {
440        let did = "did:cheqd:devnet:Ps1ysXP2Ae6GBfxNhNQNKN";
441        let config = DidCheqdResolverConfiguration {
442            networks: vec![NetworkConfiguration {
443                grpc_url: "@baduri://.".into(),
444                namespace: "devnet".into(),
445            }],
446        };
447
448        let resolver = DidCheqdResolver::new(config);
449        let e = resolver
450            .query_did_doc_by_str(did, DidCheqdParser::parse(did).unwrap())
451            .await
452            .unwrap_err();
453        assert!(matches!(e, DidCheqdError::BadConfiguration(_)));
454    }
455
456    #[tokio::test]
457    async fn test_resolve_resource_fails_if_no_query() {
458        let url = "did:cheqd:mainnet:zF7rhDBfUt9d1gJPjx7s1J";
459        let resolver = DidCheqdResolver::new(Default::default());
460        let e = resolver
461            .query_resource_by_str(url, DidCheqdParser::parse(url).unwrap())
462            .await
463            .unwrap_err();
464        assert!(matches!(e, DidCheqdError::InvalidDidUrl(_)));
465    }
466
467    #[tokio::test]
468    async fn test_resolve_resource_fails_if_incomplete_query() {
469        let url = "did:cheqd:mainnet:zF7rhDBfUt9d1gJPjx7s1j?resourceName=asdf";
470        let resolver = DidCheqdResolver::new(Default::default());
471        let e = resolver
472            .query_resource_by_str(url, DidCheqdParser::parse(url).unwrap())
473            .await
474            .unwrap_err();
475        assert!(matches!(e, DidCheqdError::InvalidDidUrl(_)));
476    }
477
478    #[tokio::test]
479    async fn test_resolve_resource_fails_if_invalid_resource_time() {
480        // use epoch instead of XML DateTime
481        let url = "did:cheqd:mainnet:zF7rhDBfUt9d1gJPjx7s1J?resourceName=asdf&resourceType=fdsa&resourceVersionTime=12341234";
482        let resolver = DidCheqdResolver::new(Default::default());
483        let e = resolver
484            .query_resource_by_str(url, DidCheqdParser::parse(url).unwrap())
485            .await
486            .unwrap_err();
487        assert!(matches!(e, DidCheqdError::InvalidDidUrl(_)));
488    }
489
490    #[tokio::test]
491    async fn test_resolve_did_success() {
492        // use epoch instead of XML DateTime
493        let did = "did:cheqd:testnet:f5101dd8-447f-40a7-a9b8-700abeba389a".to_string();
494        let resolver = DidCheqdResolver::new(Default::default());
495        let res = resolver
496            .query_did_doc_by_str(&did, DidCheqdParser::parse(&did).unwrap())
497            .await;
498        println!("res: {:#?}", res);
499        assert!(res.is_ok());
500    }
501
502    #[tokio::test]
503    async fn test_resolve_resource_id_success() {
504        // use epoch instead of XML DateTime
505        let did_url = "did:cheqd:testnet:f5101dd8-447f-40a7-a9b8-700abeba389a/resources/6155f8bc-d9c9-4e83-a1bb-453744fe5438".to_string();
506        let resolver = DidCheqdResolver::new(Default::default());
507        let res = resolver
508            .query_resource_by_str(&did_url, DidCheqdParser::parse(&did_url).unwrap())
509            .await;
510        println!("res: {res:?}");
511        assert!(res.is_ok());
512    }
513
514    #[tokio::test]
515    async fn test_resolve_resource_query_success() {
516        // use epoch instead of XML DateTime
517        let did_url = "did:cheqd:testnet:f5101dd8-447f-40a7-a9b8-700abeba389a?resourceName=Patient ID 85905-Schema&resourceType=anonCredsSchema".to_string();
518        let resolver = DidCheqdResolver::new(Default::default());
519        let res = resolver
520            .query_resource_by_str(&did_url, DidCheqdParser::parse(&did_url).unwrap())
521            .await;
522        println!("res: {res:?}");
523        assert!(res.is_ok());
524    }
525
526    #[tokio::test]
527    async fn test_resolve_did_version_id() {
528        // use epoch instead of XML DateTime
529        let did = "did:cheqd:testnet:ac2b9027-ec1a-4ee2-aad1-1e316e7d6f59/versions/ff82cc93-25fd-493a-8896-9303a9c8383d".to_string();
530        let resolver = DidCheqdResolver::new(Default::default());
531        let res = resolver
532            .query_did_doc_by_str(&did, DidCheqdParser::parse(&did).unwrap())
533            .await;
534        println!("res: {res:?}");
535        assert!(res.is_ok());
536    }
537}