A library for ATProtocol identities.
1//! Lexicon resolution functionality for AT Protocol.
2//!
3//! This module handles the resolution of lexicon identifiers to their corresponding
4//! schema definitions according to the AT Protocol specification.
5//!
6//! The resolution process:
7//! 1. Convert NSID to DNS name with "_lexicon" prefix
8//! 2. Perform DNS TXT lookup to get DID
9//! 3. Resolve DID to get DID document
10//! 4. Extract PDS endpoint from DID document
11//! 5. Make XRPC call to com.atproto.repo.getRecord to fetch lexicon
12
13use anyhow::Result;
14use atproto_client::{
15 client::Auth,
16 com::atproto::repo::{GetRecordResponse, get_record},
17};
18use atproto_identity::resolve::{DnsResolver, resolve_subject};
19use serde_json::Value;
20use tracing::instrument;
21
22use crate::{errors::LexiconResolveError, validation};
23
24/// Trait for lexicon resolution implementations.
25#[async_trait::async_trait]
26pub trait LexiconResolver: Send + Sync {
27 /// Resolve a lexicon NSID to its schema definition.
28 async fn resolve(&self, nsid: &str) -> Result<Value>;
29}
30
31/// Default lexicon resolver implementation using DNS and XRPC.
32#[derive(Clone)]
33pub struct DefaultLexiconResolver<R> {
34 http_client: reqwest::Client,
35 dns_resolver: R,
36}
37
38impl<R> DefaultLexiconResolver<R> {
39 /// Create a new lexicon resolver.
40 pub fn new(http_client: reqwest::Client, dns_resolver: R) -> Self {
41 Self {
42 http_client,
43 dns_resolver,
44 }
45 }
46}
47
48#[async_trait::async_trait]
49impl<R> LexiconResolver for DefaultLexiconResolver<R>
50where
51 R: DnsResolver + Send + Sync,
52{
53 #[instrument(skip(self), err)]
54 async fn resolve(&self, nsid: &str) -> Result<Value> {
55 // Step 1: Convert NSID to DNS name
56 let dns_name = validation::nsid_to_dns_name(nsid)?;
57
58 // Step 2: Perform DNS lookup to get DID
59 let did = resolve_lexicon_dns(&self.dns_resolver, &dns_name).await?;
60
61 // Step 3: Resolve DID to get DID document
62 let resolved_did = resolve_subject(&self.http_client, &self.dns_resolver, &did).await?;
63
64 // Step 4: Get PDS endpoint from DID document
65 let pds_endpoint = get_pds_from_did(&self.http_client, &resolved_did).await?;
66
67 // Step 5: Fetch lexicon from PDS
68 let lexicon =
69 fetch_lexicon_from_pds(&self.http_client, &pds_endpoint, &resolved_did, nsid).await?;
70
71 Ok(lexicon)
72 }
73}
74
75/// Resolve lexicon DID from DNS TXT records.
76#[instrument(skip(dns_resolver), err)]
77pub async fn resolve_lexicon_dns<R: DnsResolver + ?Sized>(
78 dns_resolver: &R,
79 lookup_dns: &str,
80) -> Result<String, LexiconResolveError> {
81 let txt_records = dns_resolver.resolve_txt(lookup_dns).await?;
82
83 // Look for did= prefix in TXT records
84 let dids: Vec<String> = txt_records
85 .iter()
86 .filter_map(|record| {
87 record
88 .strip_prefix("did=")
89 .or_else(|| record.strip_prefix("did:"))
90 .map(|did| {
91 // Ensure proper DID format
92 if did.starts_with("plc:") || did.starts_with("web:") {
93 format!("did:{}", did)
94 } else if did.starts_with("did:") {
95 did.to_string()
96 } else {
97 format!("did:{}", did)
98 }
99 })
100 })
101 .collect();
102
103 if dids.is_empty() {
104 return Err(LexiconResolveError::NoDIDsFound);
105 }
106
107 if dids.len() > 1 {
108 return Err(LexiconResolveError::MultipleDIDsFound);
109 }
110
111 Ok(dids[0].clone())
112}
113
114/// Get PDS endpoint from DID document.
115#[instrument(skip(http_client), err)]
116async fn get_pds_from_did(http_client: &reqwest::Client, did: &str) -> Result<String> {
117 use atproto_identity::{
118 model::Document,
119 plc,
120 resolve::{InputType, parse_input},
121 web,
122 };
123
124 // Get DID document based on DID method
125 let did_document: Document = match parse_input(did)? {
126 InputType::Plc(did) => plc::query(http_client, "plc.directory", &did).await?,
127 InputType::Web(did) => web::query(http_client, &did).await?,
128 _ => {
129 return Err(LexiconResolveError::InvalidDIDFormat {
130 did: did.to_string(),
131 }
132 .into());
133 }
134 };
135
136 // Extract PDS endpoint from service array
137 for service in &did_document.service {
138 if service.r#type == "AtprotoPersonalDataServer" {
139 return Ok(service.service_endpoint.clone());
140 }
141 }
142
143 Err(LexiconResolveError::NoPDSEndpoint.into())
144}
145
146/// Fetch lexicon schema from PDS using XRPC.
147#[instrument(skip(http_client), err)]
148async fn fetch_lexicon_from_pds(
149 http_client: &reqwest::Client,
150 pds_endpoint: &str,
151 did: &str,
152 nsid: &str,
153) -> Result<Value> {
154 // Construct the record key for the lexicon
155 // Lexicons are stored under the com.atproto.repo.lexicon collection
156 let collection = "com.atproto.lexicon.schema";
157
158 // Make XRPC call to get the lexicon record without authentication
159 let auth = Auth::None;
160 let response = get_record(
161 http_client,
162 &auth,
163 pds_endpoint,
164 did,
165 collection,
166 nsid,
167 None,
168 )
169 .await
170 .map_err(|e| LexiconResolveError::PDSFetchFailed {
171 details: e.to_string(),
172 })?;
173
174 // Extract the value from the response
175 match response {
176 GetRecordResponse::Record { value, .. } => Ok(value),
177 GetRecordResponse::Error(err) => {
178 let msg = err
179 .message
180 .or(err.error_description)
181 .or(err.error)
182 .unwrap_or_else(|| "Unknown error".to_string());
183 Err(LexiconResolveError::PDSErrorResponse {
184 nsid: nsid.to_string(),
185 message: msg,
186 }
187 .into())
188 }
189 }
190}