A library for ATProtocol identities.

feature: Added document builder

Signed-off-by: Nick Gerakines <nick.gerakines@gmail.com>

Changed files
+245 -1
crates
atproto-identity
atproto-xrpcs
+232 -1
crates/atproto-identity/src/model.rs
··· 62 #[derive(Clone, Serialize, Deserialize, PartialEq)] 63 #[serde(rename_all = "camelCase")] 64 pub struct Document { 65 /// The DID identifier (e.g., "did:plc:abc123"). 66 pub id: String, 67 /// Alternative identifiers like handles and domains. ··· 78 pub extra: HashMap<String, Value>, 79 } 80 81 impl Document { 82 /// Extracts Personal Data Server endpoints from services. 83 /// Returns URLs of all AtprotoPersonalDataServer services. 84 pub fn pds_endpoints(&self) -> Vec<&str> { ··· 139 140 #[cfg(test)] 141 mod tests { 142 - use crate::model::Document; 143 144 #[test] 145 fn test_deserialize() { ··· 150 151 let document = document.unwrap(); 152 assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2"); 153 } 154 155 #[test]
··· 62 #[derive(Clone, Serialize, Deserialize, PartialEq)] 63 #[serde(rename_all = "camelCase")] 64 pub struct Document { 65 + /// JSON-LD context URLs defining the semantics of the DID document. 66 + /// Typically includes "https://www.w3.org/ns/did/v1" and method-specific contexts. 67 + #[serde(rename = "@context", default)] 68 + pub context: Vec<String>, 69 + 70 /// The DID identifier (e.g., "did:plc:abc123"). 71 pub id: String, 72 /// Alternative identifiers like handles and domains. ··· 83 pub extra: HashMap<String, Value>, 84 } 85 86 + /// Builder for constructing DID documents with a fluent API. 87 + /// Provides controlled construction with validation capabilities. 88 + #[derive(Default)] 89 + pub struct DocumentBuilder { 90 + context: Option<Vec<String>>, 91 + id: Option<String>, 92 + also_known_as: Vec<String>, 93 + service: Vec<Service>, 94 + verification_method: Vec<VerificationMethod>, 95 + extra: HashMap<String, Value>, 96 + } 97 + 98 + impl DocumentBuilder { 99 + /// Creates a new DocumentBuilder with empty fields. 100 + pub fn new() -> Self { 101 + Self::default() 102 + } 103 + 104 + /// Sets the JSON-LD context URLs for the document. 105 + pub fn context(mut self, context: Vec<String>) -> Self { 106 + self.context = Some(context); 107 + self 108 + } 109 + 110 + /// Adds a single context URL to the document. 111 + pub fn add_context(mut self, context_url: impl Into<String>) -> Self { 112 + self.context 113 + .get_or_insert_with(|| vec!["https://www.w3.org/ns/did/v1".to_string()]) 114 + .push(context_url.into()); 115 + self 116 + } 117 + 118 + /// Sets the DID identifier for the document. 119 + pub fn id(mut self, id: impl Into<String>) -> Self { 120 + self.id = Some(id.into()); 121 + self 122 + } 123 + 124 + /// Sets all alternative identifiers at once. 125 + pub fn also_known_as(mut self, aliases: Vec<String>) -> Self { 126 + self.also_known_as = aliases; 127 + self 128 + } 129 + 130 + /// Adds a single alternative identifier. 131 + pub fn add_also_known_as(mut self, alias: impl Into<String>) -> Self { 132 + self.also_known_as.push(alias.into()); 133 + self 134 + } 135 + 136 + /// Sets all services at once. 137 + pub fn services(mut self, services: Vec<Service>) -> Self { 138 + self.service = services; 139 + self 140 + } 141 + 142 + /// Adds a single service to the document. 143 + pub fn add_service(mut self, service: Service) -> Self { 144 + self.service.push(service); 145 + self 146 + } 147 + 148 + /// Convenience method to add a PDS service. 149 + pub fn add_pds_service(mut self, endpoint: impl Into<String>) -> Self { 150 + self.service.push(Service { 151 + id: "#atproto_pds".to_string(), 152 + r#type: "AtprotoPersonalDataServer".to_string(), 153 + service_endpoint: endpoint.into(), 154 + extra: HashMap::new(), 155 + }); 156 + self 157 + } 158 + 159 + /// Sets all verification methods at once. 160 + pub fn verification_methods(mut self, methods: Vec<VerificationMethod>) -> Self { 161 + self.verification_method = methods; 162 + self 163 + } 164 + 165 + /// Adds a single verification method. 166 + pub fn add_verification_method(mut self, method: VerificationMethod) -> Self { 167 + self.verification_method.push(method); 168 + self 169 + } 170 + 171 + /// Convenience method to add a Multikey verification method. 172 + pub fn add_multikey( 173 + mut self, 174 + id: impl Into<String>, 175 + controller: impl Into<String>, 176 + public_key_multibase: impl Into<String>, 177 + ) -> Self { 178 + let key_multibase = public_key_multibase.into(); 179 + let key_multibase = key_multibase 180 + .strip_prefix("did:key:") 181 + .unwrap_or(&key_multibase) 182 + .to_string(); 183 + 184 + self.verification_method.push(VerificationMethod::Multikey { 185 + id: id.into(), 186 + controller: controller.into(), 187 + public_key_multibase: key_multibase, 188 + extra: HashMap::new(), 189 + }); 190 + self 191 + } 192 + 193 + /// Adds an extra property to the document. 194 + pub fn add_extra(mut self, key: impl Into<String>, value: Value) -> Self { 195 + self.extra.insert(key.into(), value); 196 + self 197 + } 198 + 199 + /// Builds the Document, returning an error if required fields are missing. 200 + pub fn build(self) -> Result<Document, &'static str> { 201 + let id = self.id.ok_or("Document ID is required")?; 202 + 203 + // Use default context if not provided 204 + let context = self.context.unwrap_or_else(|| { 205 + vec!["https://www.w3.org/ns/did/v1".to_string()] 206 + }); 207 + 208 + Ok(Document { 209 + context, 210 + id, 211 + also_known_as: self.also_known_as, 212 + service: self.service, 213 + verification_method: self.verification_method, 214 + extra: self.extra, 215 + }) 216 + } 217 + } 218 + 219 impl Document { 220 + /// Creates a new DocumentBuilder for constructing a Document. 221 + pub fn builder() -> DocumentBuilder { 222 + DocumentBuilder::new() 223 + } 224 + 225 /// Extracts Personal Data Server endpoints from services. 226 /// Returns URLs of all AtprotoPersonalDataServer services. 227 pub fn pds_endpoints(&self) -> Vec<&str> { ··· 282 283 #[cfg(test)] 284 mod tests { 285 + use crate::model::{Document, Service}; 286 + use std::collections::HashMap; 287 288 #[test] 289 fn test_deserialize() { ··· 294 295 let document = document.unwrap(); 296 assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2"); 297 + } 298 + 299 + #[test] 300 + fn test_document_builder() { 301 + // Test basic builder 302 + let doc = Document::builder() 303 + .id("did:plc:test123") 304 + .build() 305 + .expect("Should build with just ID"); 306 + 307 + assert_eq!(doc.id, "did:plc:test123"); 308 + assert_eq!(doc.context, vec!["https://www.w3.org/ns/did/v1"]); 309 + assert!(doc.also_known_as.is_empty()); 310 + assert!(doc.service.is_empty()); 311 + assert!(doc.verification_method.is_empty()); 312 + } 313 + 314 + #[test] 315 + fn test_document_builder_full() { 316 + let doc = Document::builder() 317 + .id("did:plc:test123") 318 + .add_context("https://w3id.org/security/multikey/v1") 319 + .add_also_known_as("at://test.bsky.social") 320 + .add_also_known_as("https://test.example.com") 321 + .add_pds_service("https://pds.example.com") 322 + .add_multikey( 323 + "did:plc:test123#atproto", 324 + "did:plc:test123", 325 + "zQ3shXvCK2RyPrSLYQjBEw5CExZkUhJH3n1K2Mb9sC7JbvRMF", 326 + ) 327 + .build() 328 + .expect("Should build complete document"); 329 + 330 + assert_eq!(doc.id, "did:plc:test123"); 331 + assert_eq!(doc.context.len(), 2); 332 + assert_eq!(doc.also_known_as.len(), 2); 333 + assert_eq!(doc.service.len(), 1); 334 + assert_eq!(doc.service[0].r#type, "AtprotoPersonalDataServer"); 335 + assert_eq!(doc.verification_method.len(), 1); 336 + 337 + // Test PDS endpoint extraction 338 + let pds_endpoints = doc.pds_endpoints(); 339 + assert_eq!(pds_endpoints.len(), 1); 340 + assert_eq!(pds_endpoints[0], "https://pds.example.com"); 341 + } 342 + 343 + #[test] 344 + fn test_document_builder_with_service() { 345 + let service = Service { 346 + id: "#custom".to_string(), 347 + r#type: "CustomService".to_string(), 348 + service_endpoint: "https://custom.example.com".to_string(), 349 + extra: HashMap::new(), 350 + }; 351 + 352 + let doc = Document::builder() 353 + .id("did:web:example.com") 354 + .add_service(service) 355 + .build() 356 + .expect("Should build with custom service"); 357 + 358 + assert_eq!(doc.service.len(), 1); 359 + assert_eq!(doc.service[0].r#type, "CustomService"); 360 + } 361 + 362 + #[test] 363 + fn test_document_builder_missing_id() { 364 + let result = Document::builder() 365 + .add_also_known_as("at://test.bsky.social") 366 + .build(); 367 + 368 + assert!(result.is_err()); 369 + assert_eq!(result.unwrap_err(), "Document ID is required"); 370 + } 371 + 372 + #[test] 373 + fn test_document_builder_with_extra() { 374 + let doc = Document::builder() 375 + .id("did:plc:test123") 376 + .add_extra("customField", serde_json::json!("customValue")) 377 + .add_extra("numberField", serde_json::json!(42)) 378 + .build() 379 + .expect("Should build with extra fields"); 380 + 381 + assert_eq!(doc.extra.len(), 2); 382 + assert_eq!(doc.extra.get("customField").unwrap(), &serde_json::json!("customValue")); 383 + assert_eq!(doc.extra.get("numberField").unwrap(), &serde_json::json!(42)); 384 } 385 386 #[test]
+11
crates/atproto-identity/src/storage_lru.rs
··· 65 /// 66 /// // Create a sample document 67 /// let document = Document { 68 /// id: "did:plc:bv6ggog3tya2z3vxsub7hnal".to_string(), 69 /// also_known_as: vec!["at://alice.bsky.social".to_string()], 70 /// service: vec![], // simplified for example ··· 173 /// assert_eq!(storage.len(), 0); 174 /// 175 /// let doc1 = Document { 176 /// id: "did:plc:example1".to_string(), 177 /// also_known_as: vec![], 178 /// service: vec![], ··· 183 /// assert_eq!(storage.len(), 1); 184 /// 185 /// let doc2 = Document { 186 /// id: "did:plc:example2".to_string(), 187 /// also_known_as: vec![], 188 /// service: vec![], ··· 256 /// # tokio::runtime::Runtime::new().unwrap().block_on(async { 257 /// let storage = LruDidDocumentStorage::new(NonZeroUsize::new(100).unwrap()); 258 /// let document = Document { 259 /// id: "did:plc:example".to_string(), 260 /// also_known_as: vec![], 261 /// service: vec![], ··· 315 /// 316 /// // Add document to cache 317 /// let doc = Document { 318 /// id: "did:plc:bv6ggog3tya2z3vxsub7hnal".to_string(), 319 /// also_known_as: vec!["at://alice.bsky.social".to_string()], 320 /// service: vec![], ··· 375 /// 376 /// // Add first document 377 /// let doc1 = Document { 378 /// id: "did:plc:user1".to_string(), 379 /// also_known_as: vec!["at://alice.bsky.social".to_string()], 380 /// service: vec![], ··· 386 /// 387 /// // Add second document 388 /// let doc2 = Document { 389 /// id: "did:plc:user2".to_string(), 390 /// also_known_as: vec!["at://bob.bsky.social".to_string()], 391 /// service: vec![], ··· 397 /// 398 /// // Add third document - this will evict the least recently used entry (user1) 399 /// let doc3 = Document { 400 /// id: "did:plc:user3".to_string(), 401 /// also_known_as: vec!["at://charlie.bsky.social".to_string()], 402 /// service: vec![], ··· 462 /// 463 /// // Add a document 464 /// let document = Document { 465 /// id: "did:plc:bv6ggog3tya2z3vxsub7hnal".to_string(), 466 /// also_known_as: vec!["at://alice.bsky.social".to_string()], 467 /// service: vec![], ··· 503 504 fn create_test_document(did: &str, handle: &str) -> Document { 505 Document { 506 id: did.to_string(), 507 also_known_as: vec![format!("at://{}", handle)], 508 service: vec![], ··· 671 672 // Create a document with complex content 673 let mut complex_document = Document { 674 id: "did:plc:complex".to_string(), 675 also_known_as: vec![ 676 "at://alice.bsky.social".to_string(),
··· 65 /// 66 /// // Create a sample document 67 /// let document = Document { 68 + /// context: vec![], 69 /// id: "did:plc:bv6ggog3tya2z3vxsub7hnal".to_string(), 70 /// also_known_as: vec!["at://alice.bsky.social".to_string()], 71 /// service: vec![], // simplified for example ··· 174 /// assert_eq!(storage.len(), 0); 175 /// 176 /// let doc1 = Document { 177 + /// context: vec![], 178 /// id: "did:plc:example1".to_string(), 179 /// also_known_as: vec![], 180 /// service: vec![], ··· 185 /// assert_eq!(storage.len(), 1); 186 /// 187 /// let doc2 = Document { 188 + /// context: vec![], 189 /// id: "did:plc:example2".to_string(), 190 /// also_known_as: vec![], 191 /// service: vec![], ··· 259 /// # tokio::runtime::Runtime::new().unwrap().block_on(async { 260 /// let storage = LruDidDocumentStorage::new(NonZeroUsize::new(100).unwrap()); 261 /// let document = Document { 262 + /// context: vec![], 263 /// id: "did:plc:example".to_string(), 264 /// also_known_as: vec![], 265 /// service: vec![], ··· 319 /// 320 /// // Add document to cache 321 /// let doc = Document { 322 + /// context: vec![], 323 /// id: "did:plc:bv6ggog3tya2z3vxsub7hnal".to_string(), 324 /// also_known_as: vec!["at://alice.bsky.social".to_string()], 325 /// service: vec![], ··· 380 /// 381 /// // Add first document 382 /// let doc1 = Document { 383 + /// context: vec![], 384 /// id: "did:plc:user1".to_string(), 385 /// also_known_as: vec!["at://alice.bsky.social".to_string()], 386 /// service: vec![], ··· 392 /// 393 /// // Add second document 394 /// let doc2 = Document { 395 + /// context: vec![], 396 /// id: "did:plc:user2".to_string(), 397 /// also_known_as: vec!["at://bob.bsky.social".to_string()], 398 /// service: vec![], ··· 404 /// 405 /// // Add third document - this will evict the least recently used entry (user1) 406 /// let doc3 = Document { 407 + /// context: vec![], 408 /// id: "did:plc:user3".to_string(), 409 /// also_known_as: vec!["at://charlie.bsky.social".to_string()], 410 /// service: vec![], ··· 470 /// 471 /// // Add a document 472 /// let document = Document { 473 + /// context: vec![], 474 /// id: "did:plc:bv6ggog3tya2z3vxsub7hnal".to_string(), 475 /// also_known_as: vec!["at://alice.bsky.social".to_string()], 476 /// service: vec![], ··· 512 513 fn create_test_document(did: &str, handle: &str) -> Document { 514 Document { 515 + context: vec![], 516 id: did.to_string(), 517 also_known_as: vec![format!("at://{}", handle)], 518 service: vec![], ··· 681 682 // Create a document with complex content 683 let mut complex_document = Document { 684 + context: vec![], 685 id: "did:plc:complex".to_string(), 686 also_known_as: vec![ 687 "at://alice.bsky.social".to_string(),
+2
crates/atproto-xrpcs/src/authorization.rs
··· 253 let verification_method_id = "did:key:zQ3shXvCK2RyPrSLYQjBEw5CExZkUhJH3n1K2Mb9sC7JbvRMF"; 254 255 let document = Document { 256 id: did.to_string(), 257 also_known_as: vec![], 258 service: vec![], ··· 310 // Create mock storage 311 let storage = Arc::new(MockStorage { 312 document: Document { 313 id: "did:plc:test".to_string(), 314 also_known_as: vec![], 315 service: vec![],
··· 253 let verification_method_id = "did:key:zQ3shXvCK2RyPrSLYQjBEw5CExZkUhJH3n1K2Mb9sC7JbvRMF"; 254 255 let document = Document { 256 + context: vec![], 257 id: did.to_string(), 258 also_known_as: vec![], 259 service: vec![], ··· 311 // Create mock storage 312 let storage = Arc::new(MockStorage { 313 document: Document { 314 + context: vec![], 315 id: "did:plc:test".to_string(), 316 also_known_as: vec![], 317 service: vec![],