i18n+filtering fork - fluent-templates v2
at main 9.0 kB view raw
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}