+232
-1
crates/atproto-identity/src/model.rs
+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
+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(),