1use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9
10#[cfg_attr(debug_assertions, derive(Debug))]
13#[derive(Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "camelCase")]
15pub struct Service {
16 pub id: String,
18 pub r#type: String,
20 pub service_endpoint: String,
22
23 #[serde(flatten)]
25 pub extra: HashMap<String, Value>,
26}
27
28#[cfg_attr(debug_assertions, derive(Debug))]
31#[derive(Clone, Serialize, Deserialize, PartialEq)]
32#[serde(tag = "type")]
33pub enum VerificationMethod {
34 Multikey {
36 id: String,
38 controller: String,
40
41 #[serde(rename = "publicKeyMultibase")]
43 public_key_multibase: String,
44
45 #[serde(flatten)]
47 extra: HashMap<String, Value>,
48 },
49
50 #[serde(untagged)]
52 Other {
53 #[serde(flatten)]
55 extra: HashMap<String, Value>,
56 },
57}
58
59#[cfg_attr(debug_assertions, derive(Debug))]
62#[derive(Clone, Serialize, Deserialize, PartialEq)]
63#[serde(rename_all = "camelCase")]
64pub struct Document {
65 #[serde(rename = "@context", default)]
68 pub context: Vec<String>,
69
70 pub id: String,
72 pub also_known_as: Vec<String>,
74 pub service: Vec<Service>,
76
77 #[serde(alias = "verificationMethod")]
79 pub verification_method: Vec<VerificationMethod>,
80
81 #[serde(flatten)]
83 pub extra: HashMap<String, Value>,
84}
85
86#[derive(Default)]
89pub 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
98impl DocumentBuilder {
99 pub fn new() -> Self {
101 Self::default()
102 }
103
104 pub fn context(mut self, context: Vec<String>) -> Self {
106 self.context = Some(context);
107 self
108 }
109
110 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 pub fn id(mut self, id: impl Into<String>) -> Self {
120 self.id = Some(id.into());
121 self
122 }
123
124 pub fn also_known_as(mut self, aliases: Vec<String>) -> Self {
126 self.also_known_as = aliases;
127 self
128 }
129
130 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 pub fn services(mut self, services: Vec<Service>) -> Self {
138 self.service = services;
139 self
140 }
141
142 pub fn add_service(mut self, service: Service) -> Self {
144 self.service.push(service);
145 self
146 }
147
148 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 pub fn verification_methods(mut self, methods: Vec<VerificationMethod>) -> Self {
161 self.verification_method = methods;
162 self
163 }
164
165 pub fn add_verification_method(mut self, method: VerificationMethod) -> Self {
167 self.verification_method.push(method);
168 self
169 }
170
171 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 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 pub fn build(self) -> Result<Document, &'static str> {
201 let id = self.id.ok_or("Document ID is required")?;
202
203 let context = self
205 .context
206 .unwrap_or_else(|| vec!["https://www.w3.org/ns/did/v1".to_string()]);
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
219impl Document {
220 pub fn builder() -> DocumentBuilder {
222 DocumentBuilder::new()
223 }
224
225 pub fn pds_endpoints(&self) -> Vec<&str> {
228 self.service
229 .iter()
230 .filter_map(|service| {
231 if service.r#type == "AtprotoPersonalDataServer" {
232 Some(service.service_endpoint.as_str())
233 } else {
234 None
235 }
236 })
237 .collect()
238 }
239
240 pub fn handles(&self) -> Option<&str> {
243 self.also_known_as.first().map(|handle| {
244 if let Some(trimmed) = handle.strip_prefix("at://") {
245 trimmed
246 } else {
247 handle.as_str()
248 }
249 })
250 }
251
252 pub fn did_keys(&self) -> Vec<&str> {
255 self.verification_method
256 .iter()
257 .filter_map(|verification_method| match verification_method {
258 VerificationMethod::Multikey {
259 public_key_multibase,
260 ..
261 } => Some(public_key_multibase.as_str()),
262 VerificationMethod::Other { extra: _ } => None,
263 })
264 .collect()
265 }
266}
267
268#[cfg_attr(debug_assertions, derive(Debug))]
271#[derive(Clone, Deserialize, Serialize)]
272pub struct Handle {
273 pub did: String,
275 pub handle: String,
277 pub pds: String,
279 pub verification_methods: Vec<String>,
281}
282
283#[cfg(test)]
284mod tests {
285 use crate::model::{Document, Service};
286 use std::collections::HashMap;
287
288 #[test]
289 fn test_deserialize() {
290 let document = serde_json::from_str::<Document>(
291 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"}]}"##,
292 );
293 assert!(document.is_ok());
294
295 let document = document.unwrap();
296 assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
297 }
298
299 #[test]
300 fn test_document_builder() {
301 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 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!(
383 doc.extra.get("customField").unwrap(),
384 &serde_json::json!("customValue")
385 );
386 assert_eq!(
387 doc.extra.get("numberField").unwrap(),
388 &serde_json::json!(42)
389 );
390 }
391
392 #[test]
393 fn test_deserialize_unsupported_verification_method() {
394 let documents = vec![
395 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"}]}"##,
396 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"}]}"##,
397 ];
398 for document in documents {
399 let document = serde_json::from_str::<Document>(document);
400 assert!(document.is_ok());
401
402 let document = document.unwrap();
403 assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2");
404 }
405 }
406}