A better Rust ATProto crate

lexicon resolver

Orual 91a84164 ba1883b6

Changed files
+430 -4
.zed
crates
jacquard-identity
jacquard-lexgen
examples
+1 -1
.zed/settings.json
··· 10 10 "analyzerTargetDir": true 11 11 }, 12 12 "cargo": { 13 - //"allFeatures": true 13 + "allFeatures": true 14 14 } 15 15 } 16 16 }
+1
Cargo.lock
··· 2382 2382 "http", 2383 2383 "jacquard-api", 2384 2384 "jacquard-common", 2385 + "jacquard-lexicon", 2385 2386 "miette", 2386 2387 "n0-future", 2387 2388 "percent-encoding",
+6
crates/jacquard-identity/Cargo.toml
··· 23 23 bytes.workspace = true 24 24 jacquard-common = { version = "0.8", path = "../jacquard-common", features = ["reqwest-client"] } 25 25 jacquard-api = { version = "0.8", path = "../jacquard-api", default-features = false, features = ["minimal"] } 26 + jacquard-lexicon = { version = "0.8", path = "../jacquard-lexicon", default-features = false } 26 27 percent-encoding.workspace = true 27 28 reqwest.workspace = true 28 29 url.workspace = true ··· 39 40 [target.'cfg(not(target_family = "wasm"))'.dependencies] 40 41 hickory-resolver = { optional = true, version = "0.24", default-features = false, features = ["system-config", "tokio-runtime"]} 41 42 tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } 43 + 44 + [[example]] 45 + name = "resolve_lexicon" 46 + path = "../../examples/resolve_lexicon.rs" 47 + required-features = ["dns"]
+327
crates/jacquard-identity/src/lexicon_resolver.rs
··· 1 + //! Lexicon schema resolution via DNS and XRPC 2 + //! 3 + //! This module provides traits and implementations for resolving lexicon schemas at runtime: 4 + //! 1. Resolve NSID authority to DID via DNS TXT records (`_lexicon.{reversed-authority}`) 5 + //! 2. Fetch lexicon schema from `com.atproto.lexicon.schema` collection via XRPC 6 + 7 + use crate::resolver::{IdentityError, IdentityResolver}; 8 + use jacquard_common::{ 9 + IntoStatic, smol_str, 10 + types::{cid::Cid, did::Did, string::Nsid}, 11 + }; 12 + use smol_str::SmolStr; 13 + 14 + /// Resolve lexicon authority (NSID → authoritative DID) 15 + #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 16 + pub trait LexiconAuthorityResolver { 17 + /// Resolve an NSID to the authoritative DID via DNS 18 + /// 19 + /// Uses DNS TXT records at `_lexicon.{reversed-authority}`, following the 20 + /// AT Protocol lexicon authority spec. Authority segments are reversed 21 + /// (e.g., `app.bsky.feed` → query `_lexicon.feed.bsky.app`). 22 + /// 23 + /// Note: No hierarchical fallback - per the spec, only exact authority match is checked. 24 + async fn resolve_lexicon_authority( 25 + &self, 26 + nsid: &Nsid, 27 + ) -> std::result::Result<Did<'static>, LexiconResolutionError>; 28 + } 29 + 30 + /// Resolve lexicon schemas (NSID → schema document) 31 + #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 32 + pub trait LexiconSchemaResolver { 33 + /// Resolve a complete lexicon schema for an NSID 34 + async fn resolve_lexicon_schema( 35 + &self, 36 + nsid: &Nsid, 37 + ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError>; 38 + } 39 + 40 + /// A resolved lexicon schema with metadata 41 + #[derive(Debug, Clone)] 42 + pub struct ResolvedLexiconSchema<'s> { 43 + /// The NSID of the schema 44 + pub nsid: Nsid<'s>, 45 + /// DID of the repository this schema was fetched from 46 + pub repo: Did<'s>, 47 + /// Content ID of the record (for cache invalidation) 48 + pub cid: Cid<'s>, 49 + /// Parsed lexicon document 50 + pub doc: jacquard_lexicon::lexicon::LexiconDoc<'s>, 51 + } 52 + 53 + /// Error type for lexicon resolution operations 54 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 55 + #[error("{kind}")] 56 + pub struct LexiconResolutionError { 57 + #[diagnostic_source] 58 + kind: LexiconResolutionErrorKind, 59 + #[source] 60 + source: Option<Box<dyn std::error::Error + Send + Sync>>, 61 + } 62 + 63 + impl LexiconResolutionError { 64 + pub fn new( 65 + kind: LexiconResolutionErrorKind, 66 + source: Option<Box<dyn std::error::Error + Send + Sync>>, 67 + ) -> Self { 68 + Self { kind, source } 69 + } 70 + 71 + pub fn kind(&self) -> &LexiconResolutionErrorKind { 72 + &self.kind 73 + } 74 + 75 + pub fn dns_lookup_failed( 76 + authority: impl Into<SmolStr>, 77 + source: impl std::error::Error + Send + Sync + 'static, 78 + ) -> Self { 79 + Self::new( 80 + LexiconResolutionErrorKind::DnsLookupFailed { 81 + authority: authority.into(), 82 + }, 83 + Some(Box::new(source)), 84 + ) 85 + } 86 + 87 + pub fn no_did_found(authority: impl Into<SmolStr>) -> Self { 88 + Self::new( 89 + LexiconResolutionErrorKind::NoDIDFound { 90 + authority: authority.into(), 91 + }, 92 + None, 93 + ) 94 + } 95 + 96 + pub fn invalid_did(authority: impl Into<SmolStr>, value: impl Into<SmolStr>) -> Self { 97 + Self::new( 98 + LexiconResolutionErrorKind::InvalidDID { 99 + authority: authority.into(), 100 + value: value.into(), 101 + }, 102 + None, 103 + ) 104 + } 105 + 106 + pub fn dns_not_configured() -> Self { 107 + Self::new(LexiconResolutionErrorKind::DnsNotConfigured, None) 108 + } 109 + 110 + pub fn fetch_failed( 111 + nsid: impl Into<SmolStr>, 112 + source: impl std::error::Error + Send + Sync + 'static, 113 + ) -> Self { 114 + Self::new( 115 + LexiconResolutionErrorKind::FetchFailed { nsid: nsid.into() }, 116 + Some(Box::new(source)), 117 + ) 118 + } 119 + 120 + pub fn parse_failed( 121 + nsid: impl Into<SmolStr>, 122 + source: impl std::error::Error + Send + Sync + 'static, 123 + ) -> Self { 124 + Self::new( 125 + LexiconResolutionErrorKind::ParseFailed { nsid: nsid.into() }, 126 + Some(Box::new(source)), 127 + ) 128 + } 129 + 130 + pub fn invalid_collection() -> Self { 131 + Self::new(LexiconResolutionErrorKind::InvalidCollection, None) 132 + } 133 + 134 + pub fn missing_cid(nsid: impl Into<SmolStr>) -> Self { 135 + Self::new( 136 + LexiconResolutionErrorKind::MissingCID { nsid: nsid.into() }, 137 + None, 138 + ) 139 + } 140 + } 141 + 142 + impl From<IdentityError> for LexiconResolutionError { 143 + fn from(err: IdentityError) -> Self { 144 + Self::new(LexiconResolutionErrorKind::IdentityResolution(err), None) 145 + } 146 + } 147 + 148 + /// Error categories for lexicon resolution 149 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 150 + pub enum LexiconResolutionErrorKind { 151 + #[error("DNS lookup failed for authority {authority}")] 152 + #[diagnostic(code(jacquard::lexicon::dns_lookup_failed))] 153 + DnsLookupFailed { authority: SmolStr }, 154 + 155 + #[error("no DID found in DNS for authority {authority}")] 156 + #[diagnostic( 157 + code(jacquard::lexicon::no_did_found), 158 + help("ensure _lexicon.{{reversed-authority}} TXT record exists with did=...") 159 + )] 160 + NoDIDFound { authority: SmolStr }, 161 + 162 + #[error("invalid DID in DNS for authority {authority}: {value}")] 163 + #[diagnostic(code(jacquard::lexicon::invalid_did))] 164 + InvalidDID { authority: SmolStr, value: SmolStr }, 165 + 166 + #[error("DNS not configured (dns feature disabled or WASM target)")] 167 + #[diagnostic( 168 + code(jacquard::lexicon::dns_not_configured), 169 + help("enable the 'dns' feature or use a non-WASM target") 170 + )] 171 + DnsNotConfigured, 172 + 173 + #[error("failed to fetch lexicon record for {nsid}")] 174 + #[diagnostic(code(jacquard::lexicon::fetch_failed))] 175 + FetchFailed { nsid: SmolStr }, 176 + 177 + #[error("failed to parse lexicon schema for {nsid}")] 178 + #[diagnostic(code(jacquard::lexicon::parse_failed))] 179 + ParseFailed { nsid: SmolStr }, 180 + 181 + #[error("invalid collection NSID")] 182 + #[diagnostic(code(jacquard::lexicon::invalid_collection))] 183 + InvalidCollection, 184 + 185 + #[error("record missing CID for {nsid}")] 186 + #[diagnostic(code(jacquard::lexicon::missing_cid))] 187 + MissingCID { nsid: SmolStr }, 188 + 189 + #[error(transparent)] 190 + #[diagnostic(code(jacquard::lexicon::identity_resolution_failed))] 191 + IdentityResolution(#[from] crate::resolver::IdentityError), 192 + } 193 + 194 + // Implementation on JacquardResolver 195 + impl crate::JacquardResolver { 196 + /// Resolve lexicon authority via DNS 197 + /// 198 + /// Queries `_lexicon.{reversed-authority}` for a TXT record containing `did=...` 199 + #[cfg(all(feature = "dns", not(target_family = "wasm")))] 200 + async fn resolve_lexicon_authority_dns( 201 + &self, 202 + nsid: &Nsid<'_>, 203 + ) -> std::result::Result<Did<'static>, LexiconResolutionError> { 204 + let Some(dns) = &self.dns else { 205 + return Err(LexiconResolutionError::dns_not_configured()); 206 + }; 207 + 208 + // Extract and reverse authority segments 209 + let authority = nsid.domain_authority(); 210 + let reversed_authority = authority.split('.').rev().collect::<Vec<_>>().join("."); 211 + let fqdn = format!("_lexicon.{}.", reversed_authority); 212 + 213 + #[cfg(feature = "tracing")] 214 + tracing::debug!("resolving lexicon authority via DNS: {}", fqdn); 215 + 216 + let response = dns 217 + .txt_lookup(fqdn) 218 + .await 219 + .map_err(|e| LexiconResolutionError::dns_lookup_failed(authority, e))?; 220 + 221 + // Parse TXT records looking for "did=..." 222 + for txt in response.iter() { 223 + for data in txt.txt_data().iter() { 224 + let text = std::str::from_utf8(data).unwrap_or(""); 225 + if let Some(did_str) = text.strip_prefix("did=") { 226 + return Did::new_owned(did_str) 227 + .map(|d| d.into_static()) 228 + .map_err(|_| LexiconResolutionError::invalid_did(authority, did_str)); 229 + } 230 + } 231 + } 232 + 233 + Err(LexiconResolutionError::no_did_found(authority)) 234 + } 235 + } 236 + 237 + #[cfg(all(feature = "dns", not(target_family = "wasm")))] 238 + impl LexiconAuthorityResolver for crate::JacquardResolver { 239 + async fn resolve_lexicon_authority( 240 + &self, 241 + nsid: &Nsid<'_>, 242 + ) -> std::result::Result<Did<'static>, LexiconResolutionError> { 243 + self.resolve_lexicon_authority_dns(nsid).await 244 + } 245 + } 246 + 247 + #[cfg(not(all(feature = "dns", not(target_family = "wasm"))))] 248 + impl LexiconAuthorityResolver for crate::JacquardResolver { 249 + async fn resolve_lexicon_authority( 250 + &self, 251 + _nsid: &Nsid<'_>, 252 + ) -> std::result::Result<Did<'static>, LexiconResolutionError> { 253 + Err(LexiconResolutionError::dns_not_configured()) 254 + } 255 + } 256 + 257 + impl LexiconSchemaResolver for crate::JacquardResolver { 258 + async fn resolve_lexicon_schema( 259 + &self, 260 + nsid: &Nsid<'_>, 261 + ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> { 262 + use jacquard_api::com_atproto::repo::get_record::GetRecord; 263 + use jacquard_common::{IntoStatic, xrpc::XrpcExt}; 264 + 265 + // 1. Resolve authority DID via DNS 266 + let authority_did = self.resolve_lexicon_authority(nsid).await?; 267 + 268 + #[cfg(feature = "tracing")] 269 + tracing::debug!( 270 + "resolved lexicon authority {} -> {}", 271 + nsid.domain_authority(), 272 + authority_did 273 + ); 274 + 275 + // 2. Resolve DID document to get PDS endpoint 276 + let did_doc_resp = self.resolve_did_doc(&authority_did).await?; 277 + let did_doc = did_doc_resp.parse()?; 278 + let pds = did_doc 279 + .pds_endpoint() 280 + .ok_or_else(|| IdentityError::missing_pds_endpoint())?; 281 + 282 + #[cfg(feature = "tracing")] 283 + tracing::debug!("fetching lexicon {} from PDS {}", nsid, pds); 284 + 285 + // 3. Fetch lexicon record via XRPC getRecord 286 + let collection = Nsid::new("com.atproto.lexicon.schema") 287 + .map_err(|_| LexiconResolutionError::invalid_collection())?; 288 + 289 + let request = GetRecord::new() 290 + .repo(authority_did.clone()) 291 + .collection(collection.into_static()) 292 + .rkey(nsid.clone()) 293 + .build(); 294 + 295 + let response = self 296 + .xrpc(pds) 297 + .send(&request) 298 + .await 299 + .map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?; 300 + 301 + let output = response 302 + .into_output() 303 + .map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?; 304 + 305 + // 4. Parse lexicon document from value 306 + let json_str = serde_json::to_string(&output.value) 307 + .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; 308 + 309 + let doc: jacquard_lexicon::lexicon::LexiconDoc = serde_json::from_str(&json_str) 310 + .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; 311 + 312 + #[cfg(feature = "tracing")] 313 + tracing::debug!("successfully parsed lexicon schema {}", nsid); 314 + 315 + let cid = output 316 + .cid 317 + .ok_or_else(|| LexiconResolutionError::missing_cid(nsid.as_str()))? 318 + .into_static(); 319 + 320 + Ok(ResolvedLexiconSchema { 321 + nsid: nsid.clone().into_static(), 322 + repo: authority_did.into_static(), 323 + cid, 324 + doc: doc.into_static(), 325 + }) 326 + } 327 + }
+1
crates/jacquard-identity/src/lib.rs
··· 68 68 // use crate::CowStr; // not currently needed directly here 69 69 70 70 #![cfg_attr(target_arch = "wasm32", allow(unused))] 71 + pub mod lexicon_resolver; 71 72 pub mod resolver; 72 73 73 74 use crate::resolver::{
+7 -1
crates/jacquard-lexgen/Cargo.toml
··· 23 23 name = "extract-schemas" 24 24 path = "src/bin/extract_schemas.rs" 25 25 26 + [[example]] 27 + name = "extract_inventory" 28 + path = "../../examples/extract_inventory.rs" 29 + required-features = ["codegen"] 30 + 31 + 26 32 [dependencies] 27 33 clap.workspace = true 28 34 glob = "0.3" ··· 30 36 jacquard-api = { version = "0.8", path = "../jacquard-api", default-features = false, features = [ "minimal" ] } 31 37 jacquard-common = { version = "0.8", features = [ "reqwest-client" ], path = "../jacquard-common" } 32 38 jacquard-derive = { version = "0.8", path = "../jacquard-derive" } 33 - jacquard-identity = { version = "0.8", path = "../jacquard-identity" } 39 + jacquard-identity = { version = "0.8", path = "../jacquard-identity", features = ["dns"] } 34 40 jacquard-lexicon = { version = "0.8", path = "../jacquard-lexicon" } 35 41 kdl = "6" 36 42 miette = { workspace = true, features = ["fancy"] }
crates/jacquard-lexgen/examples/extract_inventory.rs examples/extract_inventory.rs
+5
crates/jacquard-lexgen/lexicons.kdl.example
··· 20 20 pattern "lexicons/**/*.json" 21 21 } 22 22 23 + // Fetch a single lexicon by NSID - will use DNS to resolve authority 24 + source "bsky-post" type="atproto" priority=30 { 25 + endpoint "app.bsky.feed.post" 26 + } 27 + 23 28 // Fetch lexicons from a Git repository 24 29 source "my-lexicons" type="git" priority=100 { 25 30 repo "https://github.com/example/my-lexicons"
+25 -2
crates/jacquard-lexgen/src/fetch/sources/atproto.rs
··· 5 5 use jacquard_common::xrpc::XrpcExt; 6 6 use jacquard_common::{CowStr, IntoStatic}; 7 7 use jacquard_identity::JacquardResolver; 8 + use jacquard_identity::lexicon_resolver::LexiconSchemaResolver; 8 9 use jacquard_identity::resolver::{IdentityResolver, ResolverOptions}; 9 10 use jacquard_lexicon::lexicon::LexiconDoc; 10 11 use miette::{Result, miette}; ··· 17 18 } 18 19 19 20 impl AtProtoSource { 21 + /// Fetch a single lexicon schema by NSID using DNS + XRPC resolution 22 + async fn fetch_single_lexicon( 23 + &self, 24 + resolver: &JacquardResolver, 25 + nsid: &Nsid<'_>, 26 + ) -> Result<HashMap<String, LexiconDoc<'_>>> { 27 + let schema = resolver 28 + .resolve_lexicon_schema(nsid) 29 + .await 30 + .map_err(|e| miette!("Failed to resolve lexicon '{}': {}", nsid, e))?; 31 + 32 + let mut lexicons = HashMap::new(); 33 + lexicons.insert(schema.nsid.to_string(), schema.doc); 34 + 35 + Ok(lexicons) 36 + } 37 + 20 38 fn parse_lexicon_record(record_data: &Record<'_>) -> Option<LexiconDoc<'static>> { 21 39 // // Extract the 'value' field from the record 22 40 // let value = match record_data { ··· 46 64 impl LexiconSource for AtProtoSource { 47 65 async fn fetch(&self) -> Result<HashMap<String, LexiconDoc<'_>>> { 48 66 let http = reqwest::Client::new(); 49 - let resolver = JacquardResolver::new(http, ResolverOptions::default()); 67 + let resolver = JacquardResolver::new_dns(http.clone(), ResolverOptions::default()); 68 + 69 + // Try parsing as NSID first (for single lexicon fetch) 70 + if let Ok(nsid) = Nsid::new(&self.endpoint) { 71 + return self.fetch_single_lexicon(&resolver, &nsid).await; 72 + } 50 73 51 - // Parse endpoint as at-identifier (handle or DID) 74 + // Otherwise parse as at-identifier (handle or DID) for bulk fetch 52 75 let identifier = AtIdentifier::new(&self.endpoint) 53 76 .map_err(|e| miette!("Invalid endpoint '{}': {}", self.endpoint, e))?; 54 77
+57
examples/resolve_lexicon.rs
··· 1 + //! Example demonstrating lexicon schema resolution via DNS + XRPC 2 + //! 3 + //! Run with: cargo run --example resolve_lexicon --features dns 4 + 5 + use jacquard_common::types::string::Nsid; 6 + use jacquard_identity::{ 7 + JacquardResolver, 8 + lexicon_resolver::{LexiconAuthorityResolver, LexiconSchemaResolver}, 9 + resolver::ResolverOptions, 10 + }; 11 + 12 + #[tokio::main] 13 + async fn main() -> miette::Result<()> { 14 + // Set up resolver with DNS enabled 15 + let http = reqwest::Client::new(); 16 + let opts = ResolverOptions::default(); 17 + let resolver = JacquardResolver::new_dns(http, opts); 18 + 19 + // Test NSID - using app.bsky.feed.post as a known lexicon 20 + let nsid = 21 + Nsid::new("app.bsky.feed.post").map_err(|e| miette::miette!("invalid NSID: {}", e))?; 22 + 23 + println!("Resolving lexicon for: {}", nsid); 24 + println!(); 25 + println!("Resolving authority via DNS..."); 26 + match resolver.resolve_lexicon_authority(&nsid).await { 27 + Ok(did) => { 28 + println!("Authority DID: {}", did); 29 + } 30 + Err(e) => { 31 + eprintln!("Failed to resolve authority: {:?}", e); 32 + return Err(e.into()); 33 + } 34 + } 35 + 36 + println!(); 37 + println!("Fetching full lexicon schema..."); 38 + match resolver.resolve_lexicon_schema(&nsid).await { 39 + Ok(schema) => { 40 + println!("Successfully fetched schema"); 41 + println!(" NSID: {}", schema.nsid); 42 + println!(" Repo: {}", schema.repo); 43 + println!(" CID: {}", schema.cid); 44 + println!(" Doc ID: {}", schema.doc.id); 45 + println!( 46 + "\nSchema:\n{}", 47 + serde_json::to_string_pretty(&schema.doc).unwrap() 48 + ) 49 + } 50 + Err(e) => { 51 + eprintln!("Failed to resolve schema: {:?}", e); 52 + return Err(e.into()); 53 + } 54 + } 55 + 56 + Ok(()) 57 + }