atproto_identity/
web.rs

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