A better Rust ATProto crate
at main 27 kB view raw
1//! Identity resolution for the AT Protocol 2//! 3//! Jacquard's handle-to-DID and DID-to-document resolution with configurable 4//! fallback chains. 5//! 6//! ## Quick start 7//! 8//! ```no_run 9//! # async fn example() -> Result<(), Box<dyn std::error::Error>> { 10//! use jacquard_identity::{PublicResolver, resolver::IdentityResolver}; 11//! use jacquard_common::types::string::Handle; 12//! 13//! let resolver = PublicResolver::default(); 14//! 15//! // Resolve handle to DID 16//! let did = resolver.resolve_handle(&Handle::new("alice.bsky.social")?).await?; 17//! 18//! // Fetch DID document 19//! let doc_response = resolver.resolve_did_doc(&did).await?; 20//! let doc = doc_response.parse()?; // Borrow from response buffer 21//! # Ok(()) 22//! # } 23//! ``` 24//! 25//! ## Resolution fallback order 26//! 27//! **Handle → DID** (configurable via [`resolver::HandleStep`]): 28//! 1. DNS TXT record at `_atproto.{handle}` (if `dns` feature enabled) 29//! 2. HTTPS well-known at `https://{handle}/.well-known/atproto-did` 30//! 3. PDS XRPC `com.atproto.identity.resolveHandle` (if PDS configured) 31//! 4. Public API fallback (`https://public.api.bsky.app`) 32//! 5. Slingshot `resolveHandle` (if configured) 33//! 34//! **DID → Document** (configurable via [`resolver::DidStep`]): 35//! 1. `did:web` HTTPS well-known 36//! 2. PLC directory HTTP (for `did:plc`) 37//! 3. PDS XRPC `com.atproto.identity.resolveDid` (if PDS configured) 38//! 4. Slingshot mini-doc (partial document) 39//! 40//! ## Customization 41//! 42//! ``` 43//! use jacquard_identity::JacquardResolver; 44//! use jacquard_identity::resolver::{ResolverOptions, PlcSource}; 45//! 46//! let opts = ResolverOptions { 47//! plc_source: PlcSource::slingshot_default(), 48//! public_fallback_for_handle: true, 49//! validate_doc_id: true, 50//! ..Default::default() 51//! }; 52//! 53//! let resolver = JacquardResolver::new(reqwest::Client::new(), opts); 54//! #[cfg(feature = "dns")] 55//! let resolver = resolver.with_system_dns(); // Enable DNS TXT resolution 56//! ``` 57//! 58//! ## Response types 59//! 60//! Resolution methods return wrapper types that own the response buffer, allowing 61//! zero-copy parsing: 62//! 63//! - [`resolver::DidDocResponse`] - Full DID document response 64//! - [`MiniDocResponse`] - Slingshot mini-doc response (partial) 65//! 66//! Both support `.parse()` for borrowing and validation. 67 68// use crate::CowStr; // not currently needed directly here 69pub mod resolver; 70 71use crate::resolver::{ 72 DidDocResponse, DidStep, HandleStep, IdentityError, IdentityResolver, MiniDoc, PlcSource, 73 ResolverOptions, 74}; 75use bytes::Bytes; 76use jacquard_api::com_atproto::identity::resolve_did; 77use jacquard_api::com_atproto::identity::resolve_handle::ResolveHandle; 78use jacquard_common::error::TransportError; 79use jacquard_common::http_client::HttpClient; 80use jacquard_common::types::did::Did; 81use jacquard_common::types::did_doc::DidDocument; 82use jacquard_common::types::ident::AtIdentifier; 83use jacquard_common::xrpc::XrpcExt; 84use jacquard_common::{IntoStatic, types::string::Handle}; 85use percent_encoding::percent_decode_str; 86use reqwest::StatusCode; 87use std::sync::Arc; 88use url::{ParseError, Url}; 89 90#[cfg(feature = "dns")] 91use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig}; 92 93/// Default resolver implementation with configurable fallback order. 94#[derive(Clone)] 95pub struct JacquardResolver { 96 http: reqwest::Client, 97 opts: ResolverOptions, 98 #[cfg(feature = "dns")] 99 dns: Option<Arc<TokioAsyncResolver>>, 100} 101 102impl JacquardResolver { 103 /// Create a new instance of the default resolver with all options (except DNS) up front 104 pub fn new(http: reqwest::Client, opts: ResolverOptions) -> Self { 105 #[cfg(feature = "tracing")] 106 tracing::info!( 107 public_fallback = opts.public_fallback_for_handle, 108 validate_doc_id = opts.validate_doc_id, 109 plc_source = ?opts.plc_source, 110 "jacquard resolver created" 111 ); 112 113 Self { 114 http, 115 opts, 116 #[cfg(feature = "dns")] 117 dns: None, 118 } 119 } 120 121 #[cfg(feature = "dns")] 122 /// Create a new instance of the default resolver with all options, plus default DNS, up front 123 pub fn new_dns(http: reqwest::Client, opts: ResolverOptions) -> Self { 124 Self { 125 http, 126 opts, 127 dns: Some(Arc::new(TokioAsyncResolver::tokio( 128 ResolverConfig::default(), 129 Default::default(), 130 ))), 131 } 132 } 133 134 #[cfg(feature = "dns")] 135 /// Add default DNS resolution to the resolver 136 pub fn with_system_dns(mut self) -> Self { 137 self.dns = Some(Arc::new(TokioAsyncResolver::tokio( 138 ResolverConfig::default(), 139 Default::default(), 140 ))); 141 self 142 } 143 144 /// Set PLC source (PLC directory or Slingshot) 145 pub fn with_plc_source(mut self, source: PlcSource) -> Self { 146 self.opts.plc_source = source; 147 self 148 } 149 150 /// Enable/disable public unauthenticated fallback for resolveHandle 151 pub fn with_public_fallback_for_handle(mut self, enable: bool) -> Self { 152 self.opts.public_fallback_for_handle = enable; 153 self 154 } 155 156 /// Enable/disable doc id validation 157 pub fn with_validate_doc_id(mut self, enable: bool) -> Self { 158 self.opts.validate_doc_id = enable; 159 self 160 } 161 162 /// Construct the well-known HTTPS URL for a `did:web` DID. 163 /// 164 /// - `did:web:example.com` → `https://example.com/.well-known/did.json` 165 /// - `did:web:example.com:user:alice` → `https://example.com/user/alice/did.json` 166 fn did_web_url(&self, did: &Did<'_>) -> Result<Url, IdentityError> { 167 // did:web:example.com[:path:segments] 168 let s = did.as_str(); 169 let rest = s 170 .strip_prefix("did:web:") 171 .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?; 172 let mut parts = rest.split(':'); 173 let host = parts 174 .next() 175 .ok_or_else(|| IdentityError::UnsupportedDidMethod(s.to_string()))?; 176 let mut url = Url::parse(&format!("https://{host}/")).map_err(IdentityError::Url)?; 177 let path: Vec<&str> = parts.collect(); 178 if path.is_empty() { 179 url.set_path(".well-known/did.json"); 180 } else { 181 // Append path segments and did.json 182 let mut segments = url 183 .path_segments_mut() 184 .map_err(|_| IdentityError::Url(ParseError::SetHostOnCannotBeABaseUrl))?; 185 for seg in path { 186 // Minimally percent-decode each segment per spec guidance 187 let decoded = percent_decode_str(seg).decode_utf8_lossy(); 188 segments.push(&decoded); 189 } 190 segments.push("did.json"); 191 // drop segments 192 } 193 Ok(url) 194 } 195 196 #[cfg(test)] 197 fn test_did_web_url_raw(&self, s: &str) -> String { 198 let did = Did::new(s).unwrap(); 199 self.did_web_url(&did).unwrap().to_string() 200 } 201 202 async fn get_json_bytes(&self, url: Url) -> Result<(Bytes, StatusCode), IdentityError> { 203 let resp = self 204 .http 205 .get(url) 206 .send() 207 .await 208 .map_err(TransportError::from)?; 209 let status = resp.status(); 210 let buf = resp.bytes().await.map_err(TransportError::from)?; 211 Ok((buf, status)) 212 } 213 214 async fn get_text(&self, url: Url) -> Result<String, IdentityError> { 215 let resp = self 216 .http 217 .get(url) 218 .send() 219 .await 220 .map_err(TransportError::from)?; 221 if resp.status() == StatusCode::OK { 222 Ok(resp.text().await.map_err(TransportError::from)?) 223 } else { 224 Err(IdentityError::Http( 225 resp.error_for_status().unwrap_err().into(), 226 )) 227 } 228 } 229 230 #[cfg(feature = "dns")] 231 async fn dns_txt(&self, name: &str) -> Result<Vec<String>, IdentityError> { 232 let Some(dns) = &self.dns else { 233 return Ok(vec![]); 234 }; 235 let fqdn = format!("_atproto.{name}."); 236 let response = dns.txt_lookup(fqdn).await?; 237 let mut out = Vec::new(); 238 for txt in response.iter() { 239 for data in txt.txt_data().iter() { 240 out.push(String::from_utf8_lossy(data).to_string()); 241 } 242 } 243 Ok(out) 244 } 245 246 fn parse_atproto_did_body(body: &str) -> Result<Did<'static>, IdentityError> { 247 let line = body 248 .lines() 249 .find(|l| !l.trim().is_empty()) 250 .ok_or(IdentityError::InvalidWellKnown)?; 251 let did = Did::new(line.trim()).map_err(|_| IdentityError::InvalidWellKnown)?; 252 Ok(did.into_static()) 253 } 254} 255 256impl JacquardResolver { 257 /// Resolve handle to DID via a PDS XRPC call (stateless, unauth by default) 258 pub async fn resolve_handle_via_pds( 259 &self, 260 handle: &Handle<'_>, 261 ) -> Result<Did<'static>, IdentityError> { 262 let pds = match &self.opts.pds_fallback { 263 Some(u) => u.clone(), 264 None => return Err(IdentityError::InvalidWellKnown), 265 }; 266 let req = ResolveHandle::new() 267 .handle(handle.clone().into_static()) 268 .build(); 269 let resp = self 270 .http 271 .xrpc(pds) 272 .send(&req) 273 .await 274 .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 275 let out = resp 276 .parse() 277 .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 278 Did::new_owned(out.did.as_str()) 279 .map(|d| d.into_static()) 280 .map_err(|_| IdentityError::InvalidWellKnown) 281 } 282 283 /// Fetch DID document via PDS resolveDid (returns owned DidDocument) 284 pub async fn fetch_did_doc_via_pds_owned( 285 &self, 286 did: &Did<'_>, 287 ) -> Result<DidDocument<'static>, IdentityError> { 288 let pds = match &self.opts.pds_fallback { 289 Some(u) => u.clone(), 290 None => return Err(IdentityError::InvalidWellKnown), 291 }; 292 let req = resolve_did::ResolveDid::new().did(did.clone()).build(); 293 let resp = self 294 .http 295 .xrpc(pds) 296 .send(&req) 297 .await 298 .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 299 let out = resp 300 .parse() 301 .map_err(|e| IdentityError::Xrpc(e.to_string()))?; 302 let doc_json = serde_json::to_value(&out.did_doc)?; 303 let s = serde_json::to_string(&doc_json)?; 304 let doc_borrowed: DidDocument<'_> = serde_json::from_str(&s)?; 305 Ok(doc_borrowed.into_static()) 306 } 307 308 /// Fetch a minimal DID document via a Slingshot mini-doc endpoint, if your PlcSource uses Slingshot. 309 /// Returns the raw response wrapper for borrowed parsing and validation. 310 pub async fn fetch_mini_doc_via_slingshot( 311 &self, 312 did: &Did<'_>, 313 ) -> Result<DidDocResponse, IdentityError> { 314 let base = match &self.opts.plc_source { 315 PlcSource::Slingshot { base } => base.clone(), 316 _ => { 317 return Err(IdentityError::UnsupportedDidMethod( 318 "mini-doc requires Slingshot source".into(), 319 )); 320 } 321 }; 322 let mut url = base; 323 url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); 324 if let Ok(qs) = serde_html_form::to_string( 325 &resolve_did::ResolveDid::new() 326 .did(did.clone().into_static()) 327 .build(), 328 ) { 329 url.set_query(Some(&qs)); 330 } 331 let (buf, status) = self.get_json_bytes(url).await?; 332 Ok(DidDocResponse { 333 buffer: buf, 334 status, 335 requested: Some(did.clone().into_static()), 336 }) 337 } 338} 339 340impl IdentityResolver for JacquardResolver { 341 fn options(&self) -> &ResolverOptions { 342 &self.opts 343 } 344 #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self), fields(handle = %handle)))] 345 async fn resolve_handle(&self, handle: &Handle<'_>) -> Result<Did<'static>, IdentityError> { 346 let host = handle.as_str(); 347 for step in &self.opts.handle_order { 348 match step { 349 HandleStep::DnsTxt => { 350 #[cfg(feature = "dns")] 351 { 352 if let Ok(txts) = self.dns_txt(host).await { 353 for txt in txts { 354 if let Some(did_str) = txt.strip_prefix("did=") { 355 if let Ok(did) = Did::new(did_str) { 356 return Ok(did.into_static()); 357 } 358 } 359 } 360 } 361 } 362 } 363 HandleStep::HttpsWellKnown => { 364 let url = Url::parse(&format!("https://{host}/.well-known/atproto-did"))?; 365 if let Ok(text) = self.get_text(url).await { 366 if let Ok(did) = Self::parse_atproto_did_body(&text) { 367 return Ok(did); 368 } 369 } 370 } 371 HandleStep::PdsResolveHandle => { 372 // Prefer PDS XRPC via stateless client 373 if let Ok(did) = self.resolve_handle_via_pds(handle).await { 374 return Ok(did); 375 } 376 // Public unauth fallback 377 if self.opts.public_fallback_for_handle { 378 if let Ok(mut url) = Url::parse("https://public.api.bsky.app") { 379 url.set_path("/xrpc/com.atproto.identity.resolveHandle"); 380 if let Ok(qs) = serde_html_form::to_string( 381 &ResolveHandle::new().handle((*handle).clone()).build(), 382 ) { 383 url.set_query(Some(&qs)); 384 } else { 385 continue; 386 } 387 if let Ok((buf, status)) = self.get_json_bytes(url).await { 388 if status.is_success() { 389 if let Ok(val) = 390 serde_json::from_slice::<serde_json::Value>(&buf) 391 { 392 if let Some(did_str) = 393 val.get("did").and_then(|v| v.as_str()) 394 { 395 if let Ok(did) = Did::new_owned(did_str) { 396 return Ok(did.into_static()); 397 } 398 } 399 } 400 } 401 } 402 } 403 } 404 // Non-auth path: if PlcSource is Slingshot, use its resolveHandle endpoint. 405 if let PlcSource::Slingshot { base } = &self.opts.plc_source { 406 let mut url = base.clone(); 407 url.set_path("/xrpc/com.atproto.identity.resolveHandle"); 408 if let Ok(qs) = serde_html_form::to_string( 409 &ResolveHandle::new().handle((*handle).clone()).build(), 410 ) { 411 url.set_query(Some(&qs)); 412 } else { 413 continue; 414 } 415 if let Ok((buf, status)) = self.get_json_bytes(url).await { 416 if status.is_success() { 417 if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&buf) { 418 if let Some(did_str) = val.get("did").and_then(|v| v.as_str()) { 419 if let Ok(did) = Did::new_owned(did_str) { 420 return Ok(did.into_static()); 421 } 422 } 423 } 424 } 425 } 426 } 427 } 428 } 429 } 430 Err(IdentityError::InvalidWellKnown) 431 } 432 433 #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self), fields(did = %did)))] 434 async fn resolve_did_doc(&self, did: &Did<'_>) -> Result<DidDocResponse, IdentityError> { 435 let s = did.as_str(); 436 for step in &self.opts.did_order { 437 match step { 438 DidStep::DidWebHttps if s.starts_with("did:web:") => { 439 let url = self.did_web_url(did)?; 440 if let Ok((buf, status)) = self.get_json_bytes(url).await { 441 return Ok(DidDocResponse { 442 buffer: buf, 443 status, 444 requested: Some(did.clone().into_static()), 445 }); 446 } 447 } 448 DidStep::PlcHttp if s.starts_with("did:plc:") => { 449 let url = match &self.opts.plc_source { 450 PlcSource::PlcDirectory { base } => { 451 // this is odd, the join screws up with the plc directory but NOT slingshot 452 Url::parse(&format!("{}{}", base, did.as_str())).expect("Invalid URL") 453 } 454 PlcSource::Slingshot { base } => base.join(did.as_str())?, 455 }; 456 if let Ok((buf, status)) = self.get_json_bytes(url).await { 457 return Ok(DidDocResponse { 458 buffer: buf, 459 status, 460 requested: Some(did.clone().into_static()), 461 }); 462 } 463 } 464 DidStep::PdsResolveDid => { 465 // Try PDS XRPC for full DID doc 466 if let Ok(doc) = self.fetch_did_doc_via_pds_owned(did).await { 467 let buf = serde_json::to_vec(&doc).unwrap_or_default(); 468 return Ok(DidDocResponse { 469 buffer: Bytes::from(buf), 470 status: StatusCode::OK, 471 requested: Some(did.clone().into_static()), 472 }); 473 } 474 // Fallback: if Slingshot configured, return mini-doc response (partial doc) 475 if let PlcSource::Slingshot { base } = &self.opts.plc_source { 476 let url = self.slingshot_mini_doc_url(base, did.as_str())?; 477 let (buf, status) = self.get_json_bytes(url).await?; 478 return Ok(DidDocResponse { 479 buffer: buf, 480 status, 481 requested: Some(did.clone().into_static()), 482 }); 483 } 484 } 485 _ => {} 486 } 487 } 488 Err(IdentityError::UnsupportedDidMethod(s.to_string())) 489 } 490} 491 492impl HttpClient for JacquardResolver { 493 async fn send_http( 494 &self, 495 request: http::Request<Vec<u8>>, 496 ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 497 self.http.send_http(request).await 498 } 499 500 type Error = reqwest::Error; 501} 502 503/// Warnings produced during identity checks that are not fatal 504#[derive(Debug, Clone, PartialEq, Eq)] 505pub enum IdentityWarning { 506 /// The DID doc did not contain the expected handle alias under alsoKnownAs 507 HandleAliasMismatch { 508 #[allow(missing_docs)] 509 expected: Handle<'static>, 510 }, 511} 512 513impl JacquardResolver { 514 /// Resolve a handle to its DID, fetch the DID document, and return doc plus any warnings. 515 /// This applies the default equality check on the document id (error with doc if mismatch). 516 pub async fn resolve_handle_and_doc( 517 &self, 518 handle: &Handle<'_>, 519 ) -> Result<(Did<'static>, DidDocResponse, Vec<IdentityWarning>), IdentityError> { 520 let did = self.resolve_handle(handle).await?; 521 let resp = self.resolve_did_doc(&did).await?; 522 let resp_for_parse = resp.clone(); 523 let doc_borrowed = resp_for_parse.parse()?; 524 if self.opts.validate_doc_id && doc_borrowed.id.as_str() != did.as_str() { 525 return Err(IdentityError::DocIdMismatch { 526 expected: did.clone().into_static(), 527 doc: doc_borrowed.clone().into_static(), 528 }); 529 } 530 let mut warnings = Vec::new(); 531 // Check handle alias presence (soft warning) 532 let expected_alias = format!("at://{}", handle.as_str()); 533 let has_alias = doc_borrowed 534 .also_known_as 535 .as_ref() 536 .map(|v| v.iter().any(|s| s.as_ref() == expected_alias)) 537 .unwrap_or(false); 538 if !has_alias { 539 warnings.push(IdentityWarning::HandleAliasMismatch { 540 expected: handle.clone().into_static(), 541 }); 542 } 543 Ok((did, resp, warnings)) 544 } 545 546 /// Build Slingshot mini-doc URL for an identifier (handle or DID) 547 fn slingshot_mini_doc_url(&self, base: &Url, identifier: &str) -> Result<Url, IdentityError> { 548 let mut url = base.clone(); 549 url.set_path("/xrpc/com.bad-example.identity.resolveMiniDoc"); 550 url.set_query(Some(&format!( 551 "identifier={}", 552 urlencoding::Encoded::new(identifier) 553 ))); 554 Ok(url) 555 } 556 557 /// Fetch a minimal DID document via Slingshot's mini-doc endpoint using a generic at-identifier 558 pub async fn fetch_mini_doc_via_slingshot_identifier( 559 &self, 560 identifier: &AtIdentifier<'_>, 561 ) -> Result<MiniDocResponse, IdentityError> { 562 let base = match &self.opts.plc_source { 563 PlcSource::Slingshot { base } => base.clone(), 564 _ => { 565 return Err(IdentityError::UnsupportedDidMethod( 566 "mini-doc requires Slingshot source".into(), 567 )); 568 } 569 }; 570 let url = self.slingshot_mini_doc_url(&base, identifier.as_str())?; 571 let (buf, status) = self.get_json_bytes(url).await?; 572 Ok(MiniDocResponse { 573 buffer: buf, 574 status, 575 }) 576 } 577} 578 579/// Slingshot mini-doc JSON response wrapper 580#[derive(Clone)] 581pub struct MiniDocResponse { 582 buffer: Bytes, 583 status: StatusCode, 584} 585 586impl MiniDocResponse { 587 /// Parse borrowed MiniDoc 588 pub fn parse<'b>(&'b self) -> Result<MiniDoc<'b>, IdentityError> { 589 if self.status.is_success() { 590 serde_json::from_slice::<MiniDoc<'b>>(&self.buffer).map_err(IdentityError::from) 591 } else { 592 Err(IdentityError::HttpStatus(self.status)) 593 } 594 } 595} 596 597/// Resolver specialized for unauthenticated/public flows using reqwest and stateless XRPC 598pub type PublicResolver = JacquardResolver; 599 600impl Default for PublicResolver { 601 /// Build a resolver with: 602 /// - reqwest HTTP client 603 /// - Public fallbacks enabled for handle resolution 604 /// - default options (DNS enabled if compiled, public fallback for handles enabled) 605 /// 606 /// Example 607 /// ```ignore 608 /// use jacquard::identity::resolver::PublicResolver; 609 /// let resolver = PublicResolver::default(); 610 /// ``` 611 fn default() -> Self { 612 let http = reqwest::Client::new(); 613 let opts = ResolverOptions::default(); 614 let resolver = JacquardResolver::new(http, opts); 615 #[cfg(feature = "dns")] 616 let resolver = resolver.with_system_dns(); 617 resolver 618 } 619} 620 621/// Build a resolver configured to use Slingshot (`https://slingshot.microcosm.blue`) for PLC and 622/// mini-doc fallbacks, unauthenticated by default. 623pub fn slingshot_resolver_default() -> PublicResolver { 624 let http = reqwest::Client::new(); 625 let mut opts = ResolverOptions::default(); 626 opts.plc_source = PlcSource::slingshot_default(); 627 let resolver = JacquardResolver::new(http, opts); 628 #[cfg(feature = "dns")] 629 let resolver = resolver.with_system_dns(); 630 resolver 631} 632 633#[cfg(test)] 634mod tests { 635 use super::*; 636 637 #[test] 638 fn did_web_urls() { 639 let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default()); 640 assert_eq!( 641 r.test_did_web_url_raw("did:web:example.com"), 642 "https://example.com/.well-known/did.json" 643 ); 644 assert_eq!( 645 r.test_did_web_url_raw("did:web:example.com:user:alice"), 646 "https://example.com/user/alice/did.json" 647 ); 648 } 649 650 #[test] 651 fn slingshot_mini_doc_url_build() { 652 let r = JacquardResolver::new(reqwest::Client::new(), ResolverOptions::default()); 653 let base = Url::parse("https://slingshot.microcosm.blue").unwrap(); 654 let url = r.slingshot_mini_doc_url(&base, "bad-example.com").unwrap(); 655 assert_eq!( 656 url.as_str(), 657 "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=bad-example.com" 658 ); 659 } 660 661 #[test] 662 fn slingshot_mini_doc_parse_success() { 663 let buf = Bytes::from_static( 664 br#"{ 665 "did": "did:plc:hdhoaan3xa3jiuq4fg4mefid", 666 "handle": "bad-example.com", 667 "pds": "https://porcini.us-east.host.bsky.network", 668 "signing_key": "zQ3shpq1g134o7HGDb86CtQFxnHqzx5pZWknrVX2Waum3fF6j" 669}"#, 670 ); 671 let resp = MiniDocResponse { 672 buffer: buf, 673 status: StatusCode::OK, 674 }; 675 let doc = resp.parse().expect("parse mini-doc"); 676 assert_eq!(doc.did.as_str(), "did:plc:hdhoaan3xa3jiuq4fg4mefid"); 677 assert_eq!(doc.handle.as_str(), "bad-example.com"); 678 assert_eq!( 679 doc.pds.as_ref(), 680 "https://porcini.us-east.host.bsky.network" 681 ); 682 assert!(doc.signing_key.as_ref().starts_with('z')); 683 } 684 685 #[test] 686 fn slingshot_mini_doc_parse_error_status() { 687 let buf = Bytes::from_static( 688 br#"{ 689 "error": "RecordNotFound", 690 "message": "This record was deleted" 691}"#, 692 ); 693 let resp = MiniDocResponse { 694 buffer: buf, 695 status: StatusCode::BAD_REQUEST, 696 }; 697 match resp.parse() { 698 Err(IdentityError::HttpStatus(s)) => assert_eq!(s, StatusCode::BAD_REQUEST), 699 other => panic!("unexpected: {:?}", other), 700 } 701 } 702}