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}