forked from
smokesignal.events/smokesignal
i18n+filtering fork - fluent-templates v2
1pub mod model {
2
3 use serde::Deserialize;
4 use serde_json::Value;
5 use std::collections::HashMap;
6
7 #[derive(Clone, Deserialize, Debug)]
8 #[serde(rename_all = "camelCase")]
9 pub struct Service {
10 pub id: String,
11
12 pub r#type: String,
13
14 pub service_endpoint: String,
15 }
16
17 #[derive(Clone, Deserialize, Debug)]
18 #[serde(tag = "type", rename_all = "camelCase")]
19 pub enum VerificationMethod {
20 Multikey {
21 id: String,
22 controller: String,
23 public_key_multibase: String,
24 },
25
26 #[serde(untagged)]
27 Other {
28 #[serde(flatten)]
29 extra: HashMap<String, Value>,
30 },
31 }
32
33 #[derive(Clone, Deserialize, Debug)]
34 #[serde(rename_all = "camelCase")]
35 pub struct Document {
36 pub id: String,
37 pub also_known_as: Vec<String>,
38 pub service: Vec<Service>,
39 }
40
41 impl Document {
42 pub fn pds_endpoint(&self) -> Option<&str> {
43 self.service
44 .iter()
45 .find(|service| service.r#type == "AtprotoPersonalDataServer")
46 .map(|service| service.service_endpoint.as_str())
47 }
48
49 pub fn primary_handle(&self) -> Option<&str> {
50 self.also_known_as.first().map(|handle| {
51 if let Some(trimmed) = handle.strip_prefix("at://") {
52 trimmed
53 } else {
54 handle.as_str()
55 }
56 })
57 }
58 }
59
60 #[cfg(test)]
61 mod tests {
62 use crate::did::model::Document;
63
64 #[test]
65 fn test_deserialize() {
66 let document = serde_json::from_str::<Document>(
67 r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1","https://w3id.org/security/suites/secp256k1-2019/v1"],"id":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","alsoKnownAs":["at://ngerakines.me","at://nick.gerakines.net","at://nick.thegem.city","https://github.com/ngerakines","https://ngerakines.me/","dns:ngerakines.me"],"verificationMethod":[{"id":"did:plc:cbkjy5n7bk3ax2wplmtjofq2#atproto","type":"Multikey","controller":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","publicKeyMultibase":"zQ3shXvCK2RyPrSLYQjBEw5CExZkUhJH3n1K2Mb9sC7JbvRMF"}],"service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.cauda.cloud"}]}"##,
68 );
69 assert!(document.is_ok());
70
71 let document = document.unwrap();
72 assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
73 }
74
75 #[test]
76 fn test_deserialize_unsupported_verification_method() {
77 let documents = vec![
78 r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1","https://w3id.org/security/suites/secp256k1-2019/v1"],"id":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","alsoKnownAs":["at://ngerakines.me","at://nick.gerakines.net","at://nick.thegem.city","https://github.com/ngerakines","https://ngerakines.me/","dns:ngerakines.me"],"verificationMethod":[{"id":"did:plc:cbkjy5n7bk3ax2wplmtjofq2#atproto","type":"Ed25519VerificationKey2020","controller":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","publicKeyMultibase":"zQ3shXvCK2RyPrSLYQjBEw5CExZkUhJH3n1K2Mb9sC7JbvRMF"}],"service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.cauda.cloud"}]}"##,
79 r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1","https://w3id.org/security/suites/secp256k1-2019/v1"],"id":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","alsoKnownAs":["at://ngerakines.me","at://nick.gerakines.net","at://nick.thegem.city","https://github.com/ngerakines","https://ngerakines.me/","dns:ngerakines.me"],"verificationMethod":[{"id": "did:example:123#_Qq0UL2Fq651Q0Fjd6TvnYE-faHiOpRlPVQcY_-tA4A","type": "JsonWebKey2020","controller": "did:example:123","publicKeyJwk": {"crv": "Ed25519","x": "VCpo2LMLhn6iWku8MKvSLg2ZAoC-nlOyPVQaO3FxVeQ","kty": "OKP","kid": "_Qq0UL2Fq651Q0Fjd6TvnYE-faHiOpRlPVQcY_-tA4A"}}],"service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.cauda.cloud"}]}"##,
80 ];
81 for document in documents {
82 let document = serde_json::from_str::<Document>(document);
83 assert!(document.is_ok());
84
85 let document = document.unwrap();
86 assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
87 }
88 }
89 }
90}
91
92pub mod plc {
93 use anyhow::Result;
94 use thiserror::Error;
95
96 use super::model::Document;
97
98 /// Error types that can occur when working with PLC DIDs
99 #[derive(Debug, Error)]
100 pub enum PLCDIDError {
101 /// Occurs when the HTTP request to fetch the DID document fails
102 #[error("error-did-plc-1 HTTP request failed: {url} {error}")]
103 HttpRequestFailed {
104 /// The URL that was requested
105 url: String,
106 /// The underlying HTTP error
107 error: reqwest::Error,
108 },
109
110 /// Occurs when the DID document cannot be parsed from the HTTP response
111 #[error("error-did-plc-2 Failed to parse DID document: {url} {error}")]
112 DocumentParseFailed {
113 /// The URL that was requested
114 url: String,
115 /// The underlying parse error
116 error: reqwest::Error,
117 },
118 }
119
120 pub async fn query(
121 http_client: &reqwest::Client,
122 plc_hostname: &str,
123 did: &str,
124 ) -> Result<Document> {
125 let url = format!("https://{}/{}", plc_hostname, did);
126
127 http_client
128 .get(&url)
129 .send()
130 .await
131 .map_err(|error| PLCDIDError::HttpRequestFailed {
132 url: url.clone(),
133 error,
134 })?
135 .json::<Document>()
136 .await
137 .map_err(|error| PLCDIDError::DocumentParseFailed { url, error })
138 .map_err(Into::into)
139 }
140}
141
142pub mod web {
143 use anyhow::Result;
144 use thiserror::Error;
145
146 use super::model::Document;
147
148 /// Error types that can occur when working with Web DIDs
149 #[derive(Debug, Error)]
150 pub enum WebDIDError {
151 /// Occurs when the DID is missing the 'did:web:' prefix
152 #[error("error-did-web-1 Invalid DID format: missing 'did:web:' prefix")]
153 InvalidDIDPrefix,
154
155 /// Occurs when the DID is missing a hostname component
156 #[error("error-did-web-2 Invalid DID format: missing hostname component")]
157 MissingHostname,
158
159 /// Occurs when the HTTP request to fetch the DID document fails
160 #[error("error-did-web-3 HTTP request failed: {url} {error}")]
161 HttpRequestFailed {
162 /// The URL that was requested
163 url: String,
164 /// The underlying HTTP error
165 error: reqwest::Error,
166 },
167
168 /// Occurs when the DID document cannot be parsed from the HTTP response
169 #[error("error-did-web-4 Failed to parse DID document: {url} {error}")]
170 DocumentParseFailed {
171 /// The URL that was requested
172 url: String,
173 /// The underlying parse error
174 error: reqwest::Error,
175 },
176 }
177
178 pub async fn query(http_client: &reqwest::Client, did: &str) -> Result<Document> {
179 // Parse DID and extract hostname and path components
180 let mut parts = did
181 .strip_prefix("did:web:")
182 .ok_or(WebDIDError::InvalidDIDPrefix)?
183 .split(':')
184 .collect::<Vec<&str>>();
185
186 let hostname = parts.pop().ok_or(WebDIDError::MissingHostname)?;
187
188 // Construct URL based on whether path components exist
189 let url = if parts.is_empty() {
190 format!("https://{}/.well-known/did.json", hostname)
191 } else {
192 format!("https://{}/{}/did.json", hostname, parts.join("/"))
193 };
194
195 // Fetch and parse document
196 http_client
197 .get(&url)
198 .send()
199 .await
200 .map_err(|error| WebDIDError::HttpRequestFailed {
201 url: url.clone(),
202 error,
203 })?
204 .json::<Document>()
205 .await
206 .map_err(|error| WebDIDError::DocumentParseFailed { url, error })
207 .map_err(Into::into)
208 }
209
210 pub async fn query_hostname(http_client: &reqwest::Client, hostname: &str) -> Result<Document> {
211 let url = format!("https://{}/.well-known/did.json", hostname);
212
213 tracing::debug!(?url, "query_hostname");
214
215 http_client
216 .get(&url)
217 .send()
218 .await
219 .map_err(|error| WebDIDError::HttpRequestFailed {
220 url: url.clone(),
221 error,
222 })?
223 .json::<Document>()
224 .await
225 .map_err(|error| WebDIDError::DocumentParseFailed { url, error })
226 .map_err(Into::into)
227 }
228}