atproto_identity/
web.rs

1//! Web DID client for did:web resolution.
2//!
3//! Resolves did:web identifiers by converting them to HTTPS URLs and fetching
4//! DID documents from well-known locations on web servers.
5//! - **`query_hostname()`**: Direct document retrieval from hostname well-known endpoints
6//!
7//! ## URL Conversion
8//!
9//! Transforms DIDs like `did:web:example.com:path:subpath` into HTTPS URLs following
10//! the did:web specification for well-known document locations.
11
12use tracing::instrument;
13
14use super::errors::WebDIDError;
15use super::model::Document;
16
17/// Converts a did:web DID to its corresponding HTTPS URL.
18/// Transforms DID format to the expected well-known document location.
19pub fn did_web_to_url(did: &str) -> Result<String, WebDIDError> {
20    let parts = did
21        .strip_prefix("did:web:")
22        .ok_or(WebDIDError::InvalidDIDPrefix)?
23        .split(':')
24        .collect::<Vec<&str>>();
25
26    let hostname = parts.first().ok_or(WebDIDError::MissingHostname)?;
27    if hostname.is_empty() {
28        return Err(WebDIDError::MissingHostname);
29    }
30    let path_parts = &parts[1..];
31
32    let url = if path_parts.is_empty() {
33        format!("https://{}/.well-known/did.json", hostname)
34    } else {
35        format!("https://{}/{}/did.json", hostname, path_parts.join("/"))
36    };
37
38    Ok(url)
39}
40
41/// Queries a did:web DID document from its hosting location.
42/// Resolves the DID to HTTPS URL and fetches the JSON document.
43#[instrument(skip(http_client), err)]
44pub async fn query(http_client: &reqwest::Client, did: &str) -> Result<Document, WebDIDError> {
45    let url = did_web_to_url(did)?;
46
47    http_client
48        .get(&url)
49        .send()
50        .await
51        .map_err(|error| WebDIDError::HttpRequestFailed {
52            url: url.clone(),
53            error,
54        })?
55        .json::<Document>()
56        .await
57        .map_err(|error| WebDIDError::DocumentParseFailed { url, error })
58}
59
60/// Queries a DID document directly from a hostname's well-known location.
61/// Fetches from https://{hostname}/.well-known/did.json
62#[instrument(skip(http_client), err)]
63pub async fn query_hostname(
64    http_client: &reqwest::Client,
65    hostname: &str,
66) -> Result<Document, WebDIDError> {
67    let url = format!("https://{}/.well-known/did.json", hostname);
68
69    http_client
70        .get(&url)
71        .send()
72        .await
73        .map_err(|error| WebDIDError::HttpRequestFailed {
74            url: url.clone(),
75            error,
76        })?
77        .json::<Document>()
78        .await
79        .map_err(|error| WebDIDError::DocumentParseFailed { url, error })
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn test_did_web_to_url_simple_hostname() {
88        let result = did_web_to_url("did:web:example.com");
89        assert_eq!(result.unwrap(), "https://example.com/.well-known/did.json");
90    }
91
92    #[test]
93    fn test_did_web_to_url_with_path() {
94        let result = did_web_to_url("did:web:example.com:path");
95        assert_eq!(result.unwrap(), "https://example.com/path/did.json");
96    }
97
98    #[test]
99    fn test_did_web_to_url_with_nested_path() {
100        let result = did_web_to_url("did:web:example.com:path:subpath");
101        assert_eq!(result.unwrap(), "https://example.com/path/subpath/did.json");
102    }
103
104    #[test]
105    fn test_did_web_to_url_invalid_prefix() {
106        let result = did_web_to_url("did:plc:example.com");
107        assert!(matches!(result, Err(WebDIDError::InvalidDIDPrefix)));
108    }
109
110    #[test]
111    fn test_did_web_to_url_missing_hostname() {
112        let result = did_web_to_url("did:web:");
113        assert!(matches!(result, Err(WebDIDError::MissingHostname)));
114    }
115
116    #[test]
117    fn test_did_web_to_url_no_prefix() {
118        let result = did_web_to_url("example.com");
119        assert!(matches!(result, Err(WebDIDError::InvalidDIDPrefix)));
120    }
121}