A library for ATProtocol identities.

feature: atproto-lexicon crate and atproto-lexicon-resolve tool

+19
Cargo.lock
··· 178 178 ] 179 179 180 180 [[package]] 181 + name = "atproto-lexicon" 182 + version = "0.12.0" 183 + dependencies = [ 184 + "anyhow", 185 + "async-trait", 186 + "atproto-client", 187 + "atproto-identity", 188 + "clap", 189 + "hickory-resolver", 190 + "reqwest", 191 + "serde", 192 + "serde_json", 193 + "thiserror 2.0.12", 194 + "tokio", 195 + "tracing", 196 + "zeroize", 197 + ] 198 + 199 + [[package]] 181 200 name = "atproto-oauth" 182 201 version = "0.12.0" 183 202 dependencies = [
+1 -1
Cargo.toml
··· 8 8 "crates/atproto-oauth", 9 9 "crates/atproto-record", 10 10 "crates/atproto-xrpcs-helloworld", 11 - "crates/atproto-xrpcs", 11 + "crates/atproto-xrpcs", "crates/atproto-lexicon", 12 12 ] 13 13 resolver = "3" 14 14
+46
crates/atproto-lexicon/Cargo.toml
··· 1 + [package] 2 + name = "atproto-lexicon" 3 + version = "0.12.0" 4 + description = "AT Protocol lexicon resolution and validation" 5 + readme = "README.md" 6 + homepage = "https://tangled.sh/@smokesignal.events/atproto-identity-rs" 7 + documentation = "https://docs.rs/atproto-identity" 8 + 9 + edition.workspace = true 10 + rust-version.workspace = true 11 + authors.workspace = true 12 + repository.workspace = true 13 + license.workspace = true 14 + keywords.workspace = true 15 + categories.workspace = true 16 + 17 + [[bin]] 18 + name = "atproto-lexicon-resolve" 19 + test = false 20 + bench = false 21 + doc = true 22 + required-features = ["clap", "hickory-dns"] 23 + 24 + [features] 25 + default = ["hickory-dns"] 26 + zeroize = ["dep:zeroize"] 27 + hickory-dns = ["dep:hickory-resolver"] 28 + clap = ["dep:clap"] 29 + 30 + [dependencies] 31 + atproto-identity.workspace = true 32 + atproto-client.workspace = true 33 + anyhow.workspace = true 34 + async-trait.workspace = true 35 + clap = { workspace = true, optional = true } 36 + reqwest.workspace = true 37 + serde.workspace = true 38 + serde_json.workspace = true 39 + thiserror.workspace = true 40 + tokio.workspace = true 41 + tracing.workspace = true 42 + zeroize = { workspace = true, optional = true } 43 + hickory-resolver = { workspace = true, optional = true } 44 + 45 + [lints] 46 + workspace = true
+223
crates/atproto-lexicon/README.md
··· 1 + # atproto-lexicon 2 + 3 + AT Protocol lexicon resolution and validation library for Rust. 4 + 5 + ## Overview 6 + 7 + This library provides functionality for resolving and validating AT Protocol lexicons, which define the schema and structure of AT Protocol data. It implements the full lexicon resolution chain as specified by the AT Protocol: 8 + 9 + 1. Convert NSID to DNS name with `_lexicon` prefix 10 + 2. Perform DNS TXT lookup to get the authoritative DID 11 + 3. Resolve the DID to get the DID document 12 + 4. Extract PDS endpoint from the DID document 13 + 5. Make XRPC call to fetch the lexicon schema 14 + 15 + ## Features 16 + 17 + - **Lexicon Resolution**: Resolve NSIDs to their schema definitions via DNS and XRPC 18 + - **Recursive Resolution**: Automatically resolve referenced lexicons with configurable depth limits 19 + - **NSID Validation**: Comprehensive validation of Namespace Identifiers 20 + - **Reference Extraction**: Extract and resolve lexicon references including fragment-only references 21 + - **Context-Aware Resolution**: Handle fragment-only references using lexicon ID as context 22 + - **CLI Tool**: Command-line interface for lexicon resolution 23 + 24 + ## Installation 25 + 26 + Add this to your `Cargo.toml`: 27 + 28 + ```toml 29 + [dependencies] 30 + atproto-lexicon = "0.12.0" 31 + ``` 32 + 33 + ## Usage 34 + 35 + ### Basic Lexicon Resolution 36 + 37 + ```rust 38 + use atproto_lexicon::resolve::{DefaultLexiconResolver, LexiconResolver}; 39 + use atproto_identity::resolve::HickoryDnsResolver; 40 + 41 + #[tokio::main] 42 + async fn main() -> anyhow::Result<()> { 43 + let http_client = reqwest::Client::new(); 44 + let dns_resolver = HickoryDnsResolver::create_resolver(vec![]); 45 + 46 + let resolver = DefaultLexiconResolver::new(http_client, dns_resolver); 47 + 48 + // Resolve a single lexicon 49 + let lexicon = resolver.resolve("app.bsky.feed.post").await?; 50 + println!("Resolved lexicon: {}", serde_json::to_string_pretty(&lexicon)?); 51 + 52 + Ok(()) 53 + } 54 + ``` 55 + 56 + ### Recursive Resolution 57 + 58 + ```rust 59 + use atproto_lexicon::resolve_recursive::{RecursiveLexiconResolver, RecursiveResolverConfig}; 60 + 61 + #[tokio::main] 62 + async fn main() -> anyhow::Result<()> { 63 + // ... setup resolver as above ... 64 + 65 + let config = RecursiveResolverConfig { 66 + max_depth: 5, // Maximum recursion depth 67 + include_entry: true, // Include the entry lexicon in results 68 + }; 69 + 70 + let recursive_resolver = RecursiveLexiconResolver::with_config(resolver, config); 71 + 72 + // Resolve a lexicon and all its dependencies 73 + let lexicons = recursive_resolver.resolve_recursive("app.bsky.feed.post").await?; 74 + 75 + for (nsid, schema) in lexicons { 76 + println!("Resolved {}: {} bytes", nsid, 77 + serde_json::to_string(&schema)?.len()); 78 + } 79 + 80 + Ok(()) 81 + } 82 + ``` 83 + 84 + ### NSID Validation 85 + 86 + ```rust 87 + use atproto_lexicon::validation::{ 88 + is_valid_nsid, parse_nsid, nsid_to_dns_name, absolute 89 + }; 90 + 91 + // Validate NSIDs 92 + assert!(is_valid_nsid("app.bsky.feed.post")); 93 + assert!(!is_valid_nsid("invalid")); 94 + 95 + // Parse NSID components 96 + let parts = parse_nsid("app.bsky.feed.post#reply", None)?; 97 + assert_eq!(parts.parts, vec!["app", "bsky", "feed", "post"]); 98 + assert_eq!(parts.fragment, Some("reply".to_string())); 99 + 100 + // Convert NSID to DNS name for resolution 101 + let dns_name = nsid_to_dns_name("app.bsky.feed.post")?; 102 + assert_eq!(dns_name, "_lexicon.feed.bsky.app"); 103 + 104 + // Make fragment-only references absolute 105 + assert_eq!(absolute("app.bsky.feed.post", "#reply"), "app.bsky.feed.post#reply"); 106 + assert_eq!(absolute("app.bsky.feed.post", "com.example.other"), "com.example.other"); 107 + ``` 108 + 109 + ### Extract Lexicon References 110 + 111 + ```rust 112 + use atproto_lexicon::resolve_recursive::extract_lexicon_references; 113 + use serde_json::json; 114 + 115 + let schema = json!({ 116 + "lexicon": 1, 117 + "id": "app.bsky.feed.post", 118 + "defs": { 119 + "main": { 120 + "type": "record", 121 + "record": { 122 + "type": "object", 123 + "properties": { 124 + "embed": { 125 + "type": "union", 126 + "refs": [ 127 + { "type": "ref", "ref": "app.bsky.embed.images" }, 128 + { "type": "ref", "ref": "#localref" } // Fragment reference 129 + ] 130 + } 131 + } 132 + } 133 + } 134 + } 135 + }); 136 + 137 + let references = extract_lexicon_references(&schema); 138 + // References will include: 139 + // - "app.bsky.embed.images" (external reference) 140 + // - "app.bsky.feed.post" (from #localref using the lexicon's id as context) 141 + ``` 142 + 143 + ## CLI Tool 144 + 145 + The crate includes a command-line tool for lexicon resolution: 146 + 147 + ```bash 148 + # Build with CLI support 149 + cargo build --features clap --bin atproto-lexicon-resolve 150 + 151 + # Resolve a single lexicon 152 + cargo run --features clap --bin atproto-lexicon-resolve -- app.bsky.feed.post 153 + 154 + # Pretty print the output 155 + cargo run --features clap --bin atproto-lexicon-resolve -- --pretty app.bsky.feed.post 156 + 157 + # Recursively resolve all referenced lexicons 158 + cargo run --features clap --bin atproto-lexicon-resolve -- --recursive app.bsky.feed.post 159 + 160 + # Limit recursion depth 161 + cargo run --features clap --bin atproto-lexicon-resolve -- --recursive --max-depth 3 app.bsky.feed.post 162 + 163 + # Show dependency graph 164 + cargo run --features clap --bin atproto-lexicon-resolve -- --recursive --show-deps app.bsky.feed.post 165 + 166 + # List only direct references 167 + cargo run --features clap --bin atproto-lexicon-resolve -- --list-refs app.bsky.feed.post 168 + ``` 169 + 170 + ## Module Structure 171 + 172 + - **`resolve`**: Core lexicon resolution implementation following AT Protocol specification 173 + - **`resolve_recursive`**: Recursive resolution with dependency tracking and cycle detection 174 + - **`validation`**: NSID validation, parsing, and helper functions 175 + 176 + ## Key Types 177 + 178 + ### `NsidParts` 179 + Represents a parsed NSID with its component parts and optional fragment: 180 + - `parts`: Vector of NSID components (e.g., `["app", "bsky", "feed", "post"]`) 181 + - `fragment`: Optional fragment identifier (e.g., `"reply"` for `#reply`) 182 + 183 + ### `RecursiveResolverConfig` 184 + Configuration for recursive resolution: 185 + - `max_depth`: Maximum recursion depth (default: 10) 186 + - `include_entry`: Whether to include the entry lexicon in results (default: true) 187 + 188 + ### `RecursiveResolutionResult` 189 + Detailed results from recursive resolution: 190 + - `lexicons`: HashMap of resolved lexicons by NSID 191 + - `failed`: Set of NSIDs that couldn't be resolved 192 + - `dependencies`: Dependency graph showing which lexicons reference which 193 + 194 + ## Features 195 + 196 + - **Fragment-Only Reference Resolution**: Automatically resolves fragment-only references (e.g., `#localref`) using the lexicon's `id` field as context 197 + - **Union Type Support**: Extracts references from both `ref` objects and `union` types with `refs` arrays 198 + - **DNS-based Discovery**: Implements the AT Protocol DNS-based lexicon discovery mechanism 199 + - **Cycle Detection**: Prevents infinite recursion when resolving circular dependencies 200 + - **Validation**: Comprehensive NSID validation following AT Protocol specifications 201 + 202 + ## Error Handling 203 + 204 + The library uses structured error types for different failure modes: 205 + - `ValidationError`: NSID format validation errors 206 + - `ResolveError`: DNS resolution and DID resolution errors 207 + - Network and XRPC errors are wrapped in `anyhow::Error` 208 + 209 + ## Dependencies 210 + 211 + - `atproto-identity`: For DID resolution and DNS operations 212 + - `atproto-client`: For XRPC communication 213 + - `serde_json`: For JSON schema handling 214 + - `async-trait`: For async trait definitions 215 + - `tracing`: For structured logging 216 + 217 + ## License 218 + 219 + This project is part of the atproto-identity-rs workspace. See the root LICENSE file for details. 220 + 221 + ## Contributing 222 + 223 + Contributions are welcome! Please feel free to submit a Pull Request.
+360
crates/atproto-lexicon/src/bin/atproto-lexicon-resolve.rs
··· 1 + //! CLI tool for resolving AT Protocol lexicons. 2 + //! 3 + //! This tool resolves lexicon NSIDs to their schema definitions using the 4 + //! AT Protocol lexicon resolution process, with support for recursive resolution. 5 + 6 + use std::collections::{HashMap, HashSet}; 7 + 8 + use anyhow::Result; 9 + use atproto_identity::{ 10 + config::{CertificateBundles, DnsNameservers, default_env, optional_env, version}, 11 + resolve::HickoryDnsResolver, 12 + }; 13 + use atproto_lexicon::{ 14 + resolve::{DefaultLexiconResolver, LexiconResolver}, 15 + resolve_recursive::{RecursiveLexiconResolver, RecursiveResolverConfig}, 16 + }; 17 + use clap::Parser; 18 + use serde_json::Value; 19 + 20 + /// AT Protocol Lexicon Resolution CLI 21 + #[derive(Parser)] 22 + #[command( 23 + name = "atproto-lexicon-resolve", 24 + version, 25 + about = "Resolve AT Protocol lexicon NSIDs to their schema definitions", 26 + long_about = " 27 + A command-line tool for resolving AT Protocol lexicon NSIDs (Namespace Identifiers) to their 28 + schema definitions. The resolution process follows the AT Protocol specification: 29 + 30 + 1. Convert NSID to DNS name with '_lexicon' prefix 31 + 2. Perform DNS TXT lookup to get the authoritative DID 32 + 3. Resolve the DID to get the DID document 33 + 4. Extract PDS endpoint from the DID document 34 + 5. Make XRPC call to fetch the lexicon schema 35 + 36 + Supports recursive resolution to automatically resolve all referenced lexicons. 37 + 38 + ENVIRONMENT VARIABLES: 39 + PLC_HOSTNAME PLC directory hostname (default: \"plc.directory\") 40 + USER_AGENT HTTP user agent string (default: auto-generated) 41 + CERTIFICATE_BUNDLES Colon-separated paths to additional CA certificates 42 + DNS_NAMESERVERS Comma-separated DNS nameserver addresses 43 + 44 + EXAMPLES: 45 + # Resolve a single lexicon: 46 + atproto-lexicon-resolve app.bsky.feed.post 47 + 48 + # Resolve multiple lexicons: 49 + atproto-lexicon-resolve app.bsky.feed.post app.bsky.actor.profile 50 + 51 + # Pretty print the JSON output: 52 + atproto-lexicon-resolve --pretty app.bsky.feed.post 53 + 54 + # Recursively resolve all referenced lexicons: 55 + atproto-lexicon-resolve --recursive app.bsky.feed.post 56 + 57 + # Resolve recursively with limited depth: 58 + atproto-lexicon-resolve --recursive --max-depth 3 app.bsky.feed.post 59 + 60 + # Show dependency graph: 61 + atproto-lexicon-resolve --recursive --show-deps app.bsky.feed.post 62 + 63 + # List only the NSIDs of referenced lexicons: 64 + atproto-lexicon-resolve --list-refs app.bsky.feed.post 65 + " 66 + )] 67 + struct Args { 68 + /// One or more lexicon NSIDs to resolve 69 + nsids: Vec<String>, 70 + 71 + /// Pretty print the JSON output 72 + #[arg(long)] 73 + pretty: bool, 74 + 75 + /// Output only the schema without metadata 76 + #[arg(long)] 77 + schema_only: bool, 78 + 79 + /// Recursively resolve all referenced lexicons 80 + #[arg(long, short = 'r')] 81 + recursive: bool, 82 + 83 + /// Maximum depth for recursive resolution (default: 10) 84 + #[arg(long, default_value = "10")] 85 + max_depth: usize, 86 + 87 + /// Exclude the entry lexicon from recursive results 88 + #[arg(long)] 89 + exclude_entry: bool, 90 + 91 + /// Show dependency graph for recursive resolution 92 + #[arg(long)] 93 + show_deps: bool, 94 + 95 + /// List only the NSIDs that were resolved (no schemas) 96 + #[arg(long)] 97 + list_nsids: bool, 98 + 99 + /// List only the direct references of the lexicon 100 + #[arg(long)] 101 + list_refs: bool, 102 + 103 + /// Show failed resolutions when using recursive mode 104 + #[arg(long)] 105 + show_failed: bool, 106 + 107 + /// Output format: json (default), yaml, or compact 108 + #[arg(long, default_value = "json")] 109 + format: OutputFormat, 110 + } 111 + 112 + #[derive(Debug, Clone, clap::ValueEnum)] 113 + enum OutputFormat { 114 + Json, 115 + Compact, 116 + Summary, 117 + } 118 + 119 + #[tokio::main] 120 + async fn main() -> Result<()> { 121 + let args = Args::parse(); 122 + 123 + // Configure environment variables 124 + let _plc_hostname = default_env("PLC_HOSTNAME", "plc.directory"); 125 + let certificate_bundles: CertificateBundles = optional_env("CERTIFICATE_BUNDLES").try_into()?; 126 + let default_user_agent = format!( 127 + "atproto-lexicon-rs ({}; +https://tangled.sh/@smokesignal.events/atproto-identity-rs)", 128 + version()? 129 + ); 130 + let user_agent = default_env("USER_AGENT", &default_user_agent); 131 + let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?; 132 + 133 + // Build HTTP client with certificate bundles 134 + let mut client_builder = reqwest::Client::builder(); 135 + for ca_certificate in certificate_bundles.as_ref() { 136 + let cert = std::fs::read(ca_certificate)?; 137 + let cert = reqwest::Certificate::from_pem(&cert)?; 138 + client_builder = client_builder.add_root_certificate(cert); 139 + } 140 + 141 + client_builder = client_builder.user_agent(user_agent); 142 + let http_client = client_builder.build()?; 143 + 144 + // Create DNS resolver 145 + let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref()); 146 + 147 + // Create lexicon resolver 148 + let base_resolver = DefaultLexiconResolver::new(http_client, dns_resolver); 149 + 150 + // Process each NSID 151 + for nsid in &args.nsids { 152 + if args.list_refs { 153 + // Just list direct references 154 + let recursive_resolver = RecursiveLexiconResolver::new(base_resolver.clone()); 155 + match recursive_resolver.get_direct_references(nsid).await { 156 + Ok(refs) => { 157 + if refs.is_empty() { 158 + eprintln!("{}: no references", nsid); 159 + } else { 160 + println!("{}:", nsid); 161 + let mut sorted_refs: Vec<_> = refs.into_iter().collect(); 162 + sorted_refs.sort(); 163 + for ref_nsid in sorted_refs { 164 + println!(" - {}", ref_nsid); 165 + } 166 + } 167 + } 168 + Err(err) => { 169 + eprintln!("Error getting references for {}: {}", nsid, err); 170 + } 171 + } 172 + } else if args.recursive { 173 + // Recursive resolution 174 + let config = RecursiveResolverConfig { 175 + max_depth: args.max_depth, 176 + include_entry: !args.exclude_entry, 177 + }; 178 + let recursive_resolver = RecursiveLexiconResolver::with_config(base_resolver.clone(), config); 179 + 180 + if args.show_deps || args.show_failed { 181 + // Use detailed resolution for dependency graph 182 + match recursive_resolver.resolve_with_details(nsid).await { 183 + Ok(result) => { 184 + if args.list_nsids { 185 + // Just list the NSIDs 186 + let mut nsids: Vec<_> = result.lexicons.keys().cloned().collect(); 187 + nsids.sort(); 188 + for nsid in nsids { 189 + println!("{}", nsid); 190 + } 191 + } else if args.show_deps { 192 + // Show dependency graph 193 + println!("Dependency graph for {}:", nsid); 194 + print_dependency_graph(&result.dependencies); 195 + 196 + if args.show_failed && !result.failed.is_empty() { 197 + println!("\nFailed to resolve:"); 198 + let mut failed: Vec<_> = result.failed.into_iter().collect(); 199 + failed.sort(); 200 + for nsid in failed { 201 + println!(" - {}", nsid); 202 + } 203 + } 204 + } else { 205 + // Output the resolved lexicons 206 + output_lexicons(&result.lexicons, &args)?; 207 + } 208 + } 209 + Err(err) => { 210 + eprintln!("Error recursively resolving {}: {}", nsid, err); 211 + } 212 + } 213 + } else { 214 + // Simple recursive resolution 215 + match recursive_resolver.resolve_recursive(nsid).await { 216 + Ok(lexicons) => { 217 + if args.list_nsids { 218 + // Just list the NSIDs 219 + let mut nsids: Vec<_> = lexicons.keys().cloned().collect(); 220 + nsids.sort(); 221 + for nsid in nsids { 222 + println!("{}", nsid); 223 + } 224 + } else { 225 + output_lexicons(&lexicons, &args)?; 226 + } 227 + } 228 + Err(err) => { 229 + eprintln!("Error recursively resolving {}: {}", nsid, err); 230 + } 231 + } 232 + } 233 + } else { 234 + // Single lexicon resolution 235 + match base_resolver.resolve(nsid).await { 236 + Ok(lexicon) => { 237 + let output = if args.schema_only { 238 + // Extract just the schema portion if requested 239 + lexicon.get("schema").unwrap_or(&lexicon).clone() 240 + } else { 241 + lexicon 242 + }; 243 + 244 + match args.format { 245 + OutputFormat::Json => { 246 + if args.pretty { 247 + println!("{}", serde_json::to_string_pretty(&output)?); 248 + } else { 249 + println!("{}", serde_json::to_string(&output)?); 250 + } 251 + } 252 + OutputFormat::Compact => { 253 + println!("{}", serde_json::to_string(&output)?); 254 + } 255 + OutputFormat::Summary => { 256 + print_lexicon_summary(nsid, &output); 257 + } 258 + } 259 + } 260 + Err(err) => { 261 + eprintln!("Error resolving {}: {}", nsid, err); 262 + continue; 263 + } 264 + } 265 + } 266 + } 267 + 268 + Ok(()) 269 + } 270 + 271 + /// Output multiple lexicons according to the command-line arguments 272 + fn output_lexicons(lexicons: &HashMap<String, Value>, args: &Args) -> Result<()> { 273 + match args.format { 274 + OutputFormat::Json => { 275 + // Create a single JSON object with all lexicons 276 + let output = if args.schema_only { 277 + let mut schemas = serde_json::Map::new(); 278 + for (nsid, lexicon) in lexicons { 279 + let schema = lexicon.get("schema").unwrap_or(lexicon).clone(); 280 + schemas.insert(nsid.clone(), schema); 281 + } 282 + Value::Object(schemas) 283 + } else { 284 + serde_json::to_value(lexicons)? 285 + }; 286 + 287 + if args.pretty { 288 + println!("{}", serde_json::to_string_pretty(&output)?); 289 + } else { 290 + println!("{}", serde_json::to_string(&output)?); 291 + } 292 + } 293 + OutputFormat::Compact => { 294 + println!("{}", serde_json::to_string(lexicons)?); 295 + } 296 + OutputFormat::Summary => { 297 + println!("Resolved {} lexicons:", lexicons.len()); 298 + let mut nsids: Vec<_> = lexicons.keys().cloned().collect(); 299 + nsids.sort(); 300 + for nsid in nsids { 301 + if let Some(lexicon) = lexicons.get(&nsid) { 302 + print_lexicon_summary(&nsid, lexicon); 303 + println!(); 304 + } 305 + } 306 + } 307 + } 308 + Ok(()) 309 + } 310 + 311 + /// Print a summary of a lexicon 312 + fn print_lexicon_summary(nsid: &str, lexicon: &Value) { 313 + println!("NSID: {}", nsid); 314 + 315 + // Try to extract description 316 + if let Some(desc) = lexicon.get("defs") 317 + .and_then(|d| d.get("main")) 318 + .and_then(|m| m.get("description")) 319 + .and_then(|d| d.as_str()) { 320 + println!(" Description: {}", desc); 321 + } 322 + 323 + // Count definitions 324 + if let Some(defs) = lexicon.get("defs").and_then(|d| d.as_object()) { 325 + println!(" Definitions: {}", defs.len()); 326 + 327 + // List definition types 328 + let mut def_types = HashSet::new(); 329 + for (_name, def) in defs { 330 + if let Some(type_str) = def.get("type").and_then(|t| t.as_str()) { 331 + def_types.insert(type_str); 332 + } 333 + } 334 + if !def_types.is_empty() { 335 + let mut types: Vec<_> = def_types.into_iter().collect(); 336 + types.sort(); 337 + println!(" Types: {}", types.join(", ")); 338 + } 339 + } 340 + } 341 + 342 + /// Print the dependency graph 343 + fn print_dependency_graph(deps: &HashMap<String, HashSet<String>>) { 344 + if deps.is_empty() { 345 + println!("No dependencies found."); 346 + return; 347 + } 348 + 349 + let mut sorted_deps: Vec<_> = deps.iter().collect(); 350 + sorted_deps.sort_by_key(|(nsid, _)| *nsid); 351 + 352 + for (nsid, refs) in sorted_deps { 353 + println!("{}:", nsid); 354 + let mut sorted_refs: Vec<_> = refs.iter().cloned().collect(); 355 + sorted_refs.sort(); 356 + for ref_nsid in sorted_refs { 357 + println!(" → {}", ref_nsid); 358 + } 359 + } 360 + }
+11
crates/atproto-lexicon/src/lib.rs
··· 1 + //! AT Protocol lexicon resolution and validation library. 2 + //! 3 + //! This library provides functionality for resolving and validating AT Protocol lexicons, 4 + //! which define the schema and structure of AT Protocol data. 5 + 6 + #![forbid(unsafe_code)] 7 + #![warn(missing_docs)] 8 + 9 + pub mod resolve; 10 + pub mod resolve_recursive; 11 + pub mod validation;
+178
crates/atproto-lexicon/src/resolve.rs
··· 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 + 13 + use anyhow::{anyhow, Result}; 14 + use atproto_client::{ 15 + client::Auth, 16 + com::atproto::repo::{get_record, GetRecordResponse}, 17 + }; 18 + use atproto_identity::{ 19 + errors::ResolveError, 20 + resolve::{DnsResolver, resolve_subject}, 21 + }; 22 + use serde_json::Value; 23 + use tracing::instrument; 24 + 25 + use crate::validation; 26 + 27 + /// Trait for lexicon resolution implementations. 28 + #[async_trait::async_trait] 29 + pub trait LexiconResolver: Send + Sync { 30 + /// Resolve a lexicon NSID to its schema definition. 31 + async fn resolve(&self, nsid: &str) -> Result<Value>; 32 + } 33 + 34 + /// Default lexicon resolver implementation using DNS and XRPC. 35 + #[derive(Clone)] 36 + pub struct DefaultLexiconResolver<R> { 37 + http_client: reqwest::Client, 38 + dns_resolver: R, 39 + } 40 + 41 + impl<R> DefaultLexiconResolver<R> { 42 + /// Create a new lexicon resolver. 43 + pub fn new(http_client: reqwest::Client, dns_resolver: R) -> Self { 44 + Self { 45 + http_client, 46 + dns_resolver, 47 + } 48 + } 49 + } 50 + 51 + #[async_trait::async_trait] 52 + impl<R> LexiconResolver for DefaultLexiconResolver<R> 53 + where 54 + R: DnsResolver + Send + Sync, 55 + { 56 + #[instrument(skip(self), err)] 57 + async fn resolve(&self, nsid: &str) -> Result<Value> { 58 + // Step 1: Convert NSID to DNS name 59 + let dns_name = validation::nsid_to_dns_name(nsid)?; 60 + 61 + // Step 2: Perform DNS lookup to get DID 62 + let did = resolve_lexicon_dns(&self.dns_resolver, &dns_name).await?; 63 + 64 + // Step 3: Resolve DID to get DID document 65 + let resolved_did = resolve_subject(&self.http_client, &self.dns_resolver, &did).await?; 66 + 67 + // Step 4: Get PDS endpoint from DID document 68 + let pds_endpoint = get_pds_from_did(&self.http_client, &resolved_did).await?; 69 + 70 + // Step 5: Fetch lexicon from PDS 71 + let lexicon = fetch_lexicon_from_pds(&self.http_client, &pds_endpoint, &resolved_did, nsid).await?; 72 + 73 + Ok(lexicon) 74 + } 75 + } 76 + 77 + /// Resolve lexicon DID from DNS TXT records. 78 + #[instrument(skip(dns_resolver), err)] 79 + pub async fn resolve_lexicon_dns<R: DnsResolver + ?Sized>( 80 + dns_resolver: &R, 81 + lookup_dns: &str, 82 + ) -> Result<String, ResolveError> { 83 + let txt_records = dns_resolver 84 + .resolve_txt(lookup_dns) 85 + .await?; 86 + 87 + // Look for did= prefix in TXT records 88 + let dids: Vec<String> = txt_records 89 + .iter() 90 + .filter_map(|record| { 91 + record.strip_prefix("did=") 92 + .or_else(|| record.strip_prefix("did:")) 93 + .map(|did| { 94 + // Ensure proper DID format 95 + if did.starts_with("plc:") || did.starts_with("web:") { 96 + format!("did:{}", did) 97 + } else if did.starts_with("did:") { 98 + did.to_string() 99 + } else { 100 + format!("did:{}", did) 101 + } 102 + }) 103 + }) 104 + .collect(); 105 + 106 + if dids.is_empty() { 107 + return Err(ResolveError::NoDIDsFound); 108 + } 109 + 110 + if dids.len() > 1 { 111 + return Err(ResolveError::MultipleDIDsFound); 112 + } 113 + 114 + Ok(dids[0].clone()) 115 + } 116 + 117 + /// Get PDS endpoint from DID document. 118 + #[instrument(skip(http_client), err)] 119 + async fn get_pds_from_did(http_client: &reqwest::Client, did: &str) -> Result<String> { 120 + use atproto_identity::{plc, web, model::Document, resolve::{parse_input, InputType}}; 121 + 122 + // Get DID document based on DID method 123 + let did_document: Document = match parse_input(did)? { 124 + InputType::Plc(did) => { 125 + plc::query(http_client, "plc.directory", &did).await? 126 + } 127 + InputType::Web(did) => { 128 + web::query(http_client, &did).await? 129 + } 130 + _ => return Err(anyhow!("Invalid DID format: {}", did)), 131 + }; 132 + 133 + // Extract PDS endpoint from service array 134 + for service in &did_document.service { 135 + if service.r#type == "AtprotoPersonalDataServer" { 136 + return Ok(service.service_endpoint.clone()); 137 + } 138 + } 139 + 140 + Err(anyhow!("No PDS endpoint found in DID document")) 141 + } 142 + 143 + /// Fetch lexicon schema from PDS using XRPC. 144 + #[instrument(skip(http_client), err)] 145 + async fn fetch_lexicon_from_pds( 146 + http_client: &reqwest::Client, 147 + pds_endpoint: &str, 148 + did: &str, 149 + nsid: &str, 150 + ) -> Result<Value> { 151 + // Construct the record key for the lexicon 152 + // Lexicons are stored under the com.atproto.repo.lexicon collection 153 + let collection = "com.atproto.lexicon.schema"; 154 + 155 + // Make XRPC call to get the lexicon record without authentication 156 + let auth = Auth::None; 157 + let response = get_record( 158 + http_client, 159 + &auth, 160 + pds_endpoint, 161 + did, 162 + collection, 163 + nsid, 164 + None, 165 + ) 166 + .await 167 + .map_err(|e| anyhow!("Failed to fetch lexicon from PDS: {}", e))?; 168 + 169 + // Extract the value from the response 170 + match response { 171 + GetRecordResponse::Record { value, .. } => Ok(value), 172 + GetRecordResponse::Error(err) => { 173 + let msg = err.message.or(err.error_description).or(err.error).unwrap_or_else(|| "Unknown error".to_string()); 174 + Err(anyhow!("Error fetching lexicon for {}: {}", nsid, msg)) 175 + } 176 + } 177 + } 178 +
+534
crates/atproto-lexicon/src/resolve_recursive.rs
··· 1 + //! Recursive lexicon resolution functionality for AT Protocol. 2 + //! 3 + //! This module provides recursive resolution of lexicons, following references 4 + //! within lexicon schemas to resolve all dependent lexicons up to a specified depth. 5 + 6 + use std::collections::{HashMap, HashSet}; 7 + 8 + use anyhow::{anyhow, Result}; 9 + use serde_json::Value; 10 + use tracing::instrument; 11 + 12 + use crate::resolve::LexiconResolver; 13 + use crate::validation::{absolute, extract_nsid_from_ref_object}; 14 + 15 + /// Configuration for recursive lexicon resolution. 16 + #[derive(Debug, Clone)] 17 + pub struct RecursiveResolverConfig { 18 + /// Maximum depth for recursive resolution (0 = only resolve the entry lexicon). 19 + pub max_depth: usize, 20 + /// Whether to include the entry lexicon in the results. 21 + pub include_entry: bool, 22 + } 23 + 24 + impl Default for RecursiveResolverConfig { 25 + fn default() -> Self { 26 + Self { 27 + max_depth: 10, 28 + include_entry: true, 29 + } 30 + } 31 + } 32 + 33 + /// A lexicon resolver that recursively resolves referenced lexicons. 34 + pub struct RecursiveLexiconResolver<R> { 35 + /// The underlying lexicon resolver. 36 + resolver: R, 37 + /// Configuration for recursive resolution. 38 + config: RecursiveResolverConfig, 39 + } 40 + 41 + impl<R> RecursiveLexiconResolver<R> { 42 + /// Create a new recursive lexicon resolver with default configuration. 43 + pub fn new(resolver: R) -> Self { 44 + Self { 45 + resolver, 46 + config: RecursiveResolverConfig::default(), 47 + } 48 + } 49 + 50 + /// Create a new recursive lexicon resolver with custom configuration. 51 + pub fn with_config(resolver: R, config: RecursiveResolverConfig) -> Self { 52 + Self { resolver, config } 53 + } 54 + 55 + /// Set the maximum depth for recursive resolution. 56 + pub fn set_max_depth(&mut self, max_depth: usize) { 57 + self.config.max_depth = max_depth; 58 + } 59 + 60 + /// Set whether to include the entry lexicon in the results. 61 + pub fn set_include_entry(&mut self, include_entry: bool) { 62 + self.config.include_entry = include_entry; 63 + } 64 + } 65 + 66 + impl<R> RecursiveLexiconResolver<R> 67 + where 68 + R: LexiconResolver, 69 + { 70 + /// Recursively resolve a lexicon and all its referenced lexicons. 71 + /// 72 + /// Returns a HashMap where keys are NSIDs and values are the resolved lexicon schemas. 73 + #[instrument(skip(self), err)] 74 + pub async fn resolve_recursive(&self, entry_nsid: &str) -> Result<HashMap<String, Value>> { 75 + let mut resolved = HashMap::new(); 76 + let mut visited = HashSet::new(); 77 + let mut to_resolve = HashSet::new(); 78 + 79 + // Start with the entry lexicon 80 + to_resolve.insert(entry_nsid.to_string()); 81 + 82 + // Resolve lexicons level by level 83 + for depth in 0..=self.config.max_depth { 84 + if to_resolve.is_empty() { 85 + break; 86 + } 87 + 88 + let current_batch = to_resolve.clone(); 89 + to_resolve.clear(); 90 + 91 + for nsid in current_batch { 92 + // Skip if already visited 93 + if visited.contains(&nsid) { 94 + continue; 95 + } 96 + visited.insert(nsid.clone()); 97 + 98 + // Skip the entry lexicon if configured to exclude it 99 + if !self.config.include_entry && nsid == entry_nsid && depth == 0 { 100 + // Still need to extract references from it 101 + match self.resolver.resolve(&nsid).await { 102 + Ok(lexicon) => { 103 + let refs = extract_lexicon_references(&lexicon); 104 + to_resolve.extend(refs); 105 + } 106 + Err(e) => { 107 + tracing::warn!(error = ?e, nsid = %nsid, "Failed to resolve lexicon"); 108 + continue; 109 + } 110 + } 111 + continue; 112 + } 113 + 114 + // Resolve the lexicon 115 + match self.resolver.resolve(&nsid).await { 116 + Ok(lexicon) => { 117 + // Extract references for next level 118 + if depth < self.config.max_depth { 119 + let refs = extract_lexicon_references(&lexicon); 120 + to_resolve.extend(refs); 121 + } 122 + 123 + // Store the resolved lexicon 124 + resolved.insert(nsid.clone(), lexicon); 125 + } 126 + Err(e) => { 127 + tracing::warn!(error = ?e, nsid = %nsid, "Failed to resolve lexicon"); 128 + continue; 129 + } 130 + } 131 + } 132 + } 133 + 134 + if resolved.is_empty() && self.config.include_entry { 135 + return Err(anyhow!("Failed to resolve any lexicons")); 136 + } 137 + 138 + Ok(resolved) 139 + } 140 + 141 + /// Resolve a lexicon and return only its direct references. 142 + #[instrument(skip(self), err)] 143 + pub async fn get_direct_references(&self, nsid: &str) -> Result<HashSet<String>> { 144 + let lexicon = self.resolver.resolve(nsid).await?; 145 + Ok(extract_lexicon_references(&lexicon)) 146 + } 147 + } 148 + 149 + /// Extract all lexicon references from a lexicon schema. 150 + /// 151 + /// Looks for: 152 + /// - Objects with `"type": "ref"` and extracts the `"ref"` field value 153 + /// - Objects with `"type": "union"` and extracts NSIDs from the `"refs"` array 154 + /// - Handles fragment-only references using the lexicon's `id` field as context 155 + #[instrument(skip(value))] 156 + pub fn extract_lexicon_references(value: &Value) -> HashSet<String> { 157 + // Extract the lexicon's ID to use as context for fragment-only references 158 + let context = value 159 + .as_object() 160 + .and_then(|obj| obj.get("id")) 161 + .and_then(|id| id.as_str()) 162 + .map(|s| s.to_string()); 163 + 164 + let mut references = HashSet::new(); 165 + extract_references_recursive(value, &mut references, context.as_deref()); 166 + references 167 + } 168 + 169 + /// Recursively extract references from a JSON value with optional context. 170 + fn extract_references_recursive(value: &Value, references: &mut HashSet<String>, context: Option<&str>) { 171 + match value { 172 + Value::Object(map) => { 173 + // Check if this is a reference object 174 + if let Some(type_val) = map.get("type") { 175 + if let Some(type_str) = type_val.as_str() { 176 + if type_str == "ref" { 177 + // Handle ref objects with context for fragment-only refs 178 + if let Some(ref_val) = map.get("ref").and_then(|v| v.as_str()) { 179 + let absolute_ref = if let Some(ctx) = context { 180 + absolute(ctx, ref_val) 181 + } else { 182 + ref_val.to_string() 183 + }; 184 + 185 + // Now extract the NSID from the absolute reference 186 + if let Some(nsid) = extract_nsid_from_ref_object(&serde_json::json!({ 187 + "type": "ref", 188 + "ref": absolute_ref 189 + }).as_object().unwrap()) { 190 + references.insert(nsid); 191 + } 192 + } 193 + return; // Don't recurse further into ref objects 194 + } else if type_str == "union" { 195 + // Handle union objects with context for fragment-only refs 196 + if let Some(refs_val) = map.get("refs") { 197 + if let Some(refs_array) = refs_val.as_array() { 198 + for ref_item in refs_array { 199 + let ref_str = if let Some(s) = ref_item.as_str() { 200 + s 201 + } else if let Some(obj) = ref_item.as_object() { 202 + if let Some(ref_val) = obj.get("ref").and_then(|v| v.as_str()) { 203 + ref_val 204 + } else { 205 + continue; 206 + } 207 + } else { 208 + continue; 209 + }; 210 + 211 + // Make fragment-only references absolute 212 + let absolute_ref = if let Some(ctx) = context { 213 + absolute(ctx, ref_str) 214 + } else { 215 + ref_str.to_string() 216 + }; 217 + 218 + // Extract NSID from the absolute reference (stripping fragment) 219 + let nsid = if let Some(hash_pos) = absolute_ref.find('#') { 220 + &absolute_ref[..hash_pos] 221 + } else { 222 + &absolute_ref 223 + }; 224 + 225 + // Validate it's a proper NSID 226 + if nsid.contains('.') && !nsid.is_empty() { 227 + references.insert(nsid.to_string()); 228 + } 229 + } 230 + } 231 + } 232 + return; // Don't recurse further into union objects 233 + } 234 + } 235 + } 236 + 237 + // Otherwise, recursively check all values in the object 238 + for (_key, val) in map.iter() { 239 + extract_references_recursive(val, references, context); 240 + } 241 + } 242 + Value::Array(arr) => { 243 + // Recursively check all elements in the array 244 + for val in arr { 245 + extract_references_recursive(val, references, context); 246 + } 247 + } 248 + _ => { 249 + // Primitive values don't contain references 250 + } 251 + } 252 + } 253 + 254 + /// Result of recursive lexicon resolution. 255 + #[derive(Debug, Clone)] 256 + pub struct RecursiveResolutionResult { 257 + /// The resolved lexicons, keyed by NSID. 258 + pub lexicons: HashMap<String, Value>, 259 + /// NSIDs that were referenced but could not be resolved. 260 + pub failed: HashSet<String>, 261 + /// The dependency graph showing which lexicons reference which. 262 + pub dependencies: HashMap<String, HashSet<String>>, 263 + } 264 + 265 + impl<R> RecursiveLexiconResolver<R> 266 + where 267 + R: LexiconResolver, 268 + { 269 + /// Recursively resolve a lexicon with detailed results. 270 + /// 271 + /// This provides more information than `resolve_recursive`, including 272 + /// failed resolutions and the dependency graph. 273 + #[instrument(skip(self), err)] 274 + pub async fn resolve_with_details(&self, entry_nsid: &str) -> Result<RecursiveResolutionResult> { 275 + let mut lexicons = HashMap::new(); 276 + let mut failed = HashSet::new(); 277 + let mut dependencies = HashMap::new(); 278 + let mut visited = HashSet::new(); 279 + let mut to_resolve = HashSet::new(); 280 + 281 + // Start with the entry lexicon 282 + to_resolve.insert(entry_nsid.to_string()); 283 + 284 + // Resolve lexicons level by level 285 + for depth in 0..=self.config.max_depth { 286 + if to_resolve.is_empty() { 287 + break; 288 + } 289 + 290 + let current_batch = to_resolve.clone(); 291 + to_resolve.clear(); 292 + 293 + for nsid in current_batch { 294 + // Skip if already visited 295 + if visited.contains(&nsid) { 296 + continue; 297 + } 298 + visited.insert(nsid.clone()); 299 + 300 + // Resolve the lexicon 301 + match self.resolver.resolve(&nsid).await { 302 + Ok(lexicon) => { 303 + // Extract references 304 + let refs = extract_lexicon_references(&lexicon); 305 + 306 + // Record dependencies 307 + if !refs.is_empty() { 308 + dependencies.insert(nsid.clone(), refs.clone()); 309 + } 310 + 311 + // Add references to resolve queue (if within depth limit) 312 + if depth < self.config.max_depth { 313 + to_resolve.extend(refs); 314 + } 315 + 316 + // Store the resolved lexicon (if configured to include it) 317 + if self.config.include_entry || nsid != entry_nsid || depth > 0 { 318 + lexicons.insert(nsid.clone(), lexicon); 319 + } 320 + } 321 + Err(e) => { 322 + tracing::warn!(error = ?e, nsid = %nsid, "Failed to resolve lexicon"); 323 + failed.insert(nsid.clone()); 324 + continue; 325 + } 326 + } 327 + } 328 + } 329 + 330 + Ok(RecursiveResolutionResult { 331 + lexicons, 332 + failed, 333 + dependencies, 334 + }) 335 + } 336 + } 337 + 338 + #[cfg(test)] 339 + mod tests { 340 + use super::*; 341 + 342 + #[test] 343 + fn test_extract_references() { 344 + let schema = serde_json::json!({ 345 + "lexicon": 1, 346 + "id": "app.bsky.feed.post", 347 + "defs": { 348 + "main": { 349 + "type": "record", 350 + "record": { 351 + "type": "object", 352 + "properties": { 353 + "text": { 354 + "type": "string" 355 + }, 356 + "embed": { 357 + "type": "union", 358 + "refs": [ 359 + { "type": "ref", "ref": "app.bsky.embed.images" }, 360 + { "type": "ref", "ref": "app.bsky.embed.external" }, 361 + { "type": "ref", "ref": "#localref" } 362 + ] 363 + } 364 + } 365 + } 366 + } 367 + } 368 + }); 369 + 370 + let refs = extract_lexicon_references(&schema); 371 + 372 + assert!(refs.contains("app.bsky.embed.images")); 373 + assert!(refs.contains("app.bsky.embed.external")); 374 + // Fragment-only reference #localref should be resolved to app.bsky.feed.post 375 + // (using the lexicon's id as context) 376 + assert!(refs.contains("app.bsky.feed.post")); 377 + assert_eq!(refs.len(), 3); 378 + } 379 + 380 + #[test] 381 + fn test_extract_nested_references() { 382 + let schema = serde_json::json!({ 383 + "defs": { 384 + "main": { 385 + "type": "object", 386 + "properties": { 387 + "nested": { 388 + "type": "object", 389 + "properties": { 390 + "ref1": { "type": "ref", "ref": "com.example.schema1" }, 391 + "array": { 392 + "type": "array", 393 + "items": { 394 + "type": "union", 395 + "refs": [ 396 + { "type": "ref", "ref": "#localref" }, 397 + { "type": "ref", "ref": "com.example.schema3" } 398 + ] 399 + } 400 + } 401 + } 402 + } 403 + } 404 + } 405 + } 406 + }); 407 + 408 + let refs = extract_lexicon_references(&schema); 409 + 410 + assert!(refs.contains("com.example.schema1")); 411 + assert!(refs.contains("com.example.schema3")); 412 + // Without an id field, fragment-only references cannot be resolved 413 + assert_eq!(refs.len(), 2); 414 + } 415 + 416 + #[test] 417 + fn test_fragment_only_with_context() { 418 + // Test that fragment-only references are properly resolved when lexicon has an ID 419 + let schema = serde_json::json!({ 420 + "lexicon": 1, 421 + "id": "com.example.myschema", 422 + "defs": { 423 + "main": { 424 + "type": "object", 425 + "properties": { 426 + "directRef": { "type": "ref", "ref": "#localDefinition" }, 427 + "unionRefs": { 428 + "type": "union", 429 + "refs": [ 430 + "#main", 431 + "#otherDef", 432 + "external.schema.type" 433 + ] 434 + }, 435 + "nestedRef": { 436 + "type": "object", 437 + "properties": { 438 + "field": { "type": "ref", "ref": "#nested" } 439 + } 440 + } 441 + } 442 + } 443 + } 444 + }); 445 + 446 + let refs = extract_lexicon_references(&schema); 447 + 448 + // Fragment-only references should all resolve to com.example.myschema 449 + assert!(refs.contains("com.example.myschema")); 450 + assert!(refs.contains("external.schema.type")); 451 + assert_eq!(refs.len(), 2); 452 + } 453 + 454 + #[test] 455 + fn test_skip_invalid_references() { 456 + let schema = serde_json::json!({ 457 + "defs": { 458 + "main": { 459 + "refs": [ 460 + { "type": "ref", "ref": "valid.schema.name" }, 461 + { "type": "ref", "ref": "invalid" }, // No dots - should be skipped 462 + { "type": "ref", "ref": "#localref" }, // Fragment-only, no ID context - should be skipped 463 + { "type": "string", "ref": "not.a.ref" }, // Wrong type - should be skipped 464 + ] 465 + } 466 + } 467 + }); 468 + 469 + let refs = extract_lexicon_references(&schema); 470 + 471 + assert!(refs.contains("valid.schema.name")); 472 + // Only valid.schema.name should be extracted (no ID field, so #localref is skipped) 473 + assert_eq!(refs.len(), 1); 474 + } 475 + 476 + #[test] 477 + fn test_extract_union_references() { 478 + let schema = serde_json::json!({ 479 + "defs": { 480 + "main": { 481 + "type": "union", 482 + "refs": [ 483 + "community.lexicon.calendar.event#uri", 484 + "community.lexicon.location.address", 485 + "community.lexicon.location.fsq", 486 + "community.lexicon.location.geo", 487 + "community.lexicon.location.hthree" 488 + ] 489 + } 490 + } 491 + }); 492 + 493 + let refs = extract_lexicon_references(&schema); 494 + 495 + // NSIDs should be extracted without fragment identifiers 496 + assert!(refs.contains("community.lexicon.calendar.event")); 497 + assert!(refs.contains("community.lexicon.location.address")); 498 + assert!(refs.contains("community.lexicon.location.fsq")); 499 + assert!(refs.contains("community.lexicon.location.geo")); 500 + assert!(refs.contains("community.lexicon.location.hthree")); 501 + assert_eq!(refs.len(), 5); 502 + } 503 + 504 + #[test] 505 + fn test_extract_mixed_union_references() { 506 + let schema = serde_json::json!({ 507 + "defs": { 508 + "main": { 509 + "type": "union", 510 + "refs": [ 511 + "app.bsky.feed.post", 512 + { "type": "ref", "ref": "app.bsky.actor.profile" }, 513 + "#app.bsky.graph.follow", // Fragment-only, no ID context - should be skipped 514 + "invalid", // No dots - should be skipped 515 + ] 516 + }, 517 + "other": { 518 + "type": "ref", 519 + "ref": "app.bsky.embed.images" 520 + } 521 + } 522 + }); 523 + 524 + let refs = extract_lexicon_references(&schema); 525 + 526 + assert!(refs.contains("app.bsky.feed.post")); 527 + assert!(refs.contains("app.bsky.actor.profile")); 528 + assert!(refs.contains("app.bsky.embed.images")); 529 + // #app.bsky.graph.follow is fragment-only with no ID context, should not be included 530 + assert!(!refs.contains("app.bsky.graph.follow")); 531 + assert!(!refs.contains("invalid")); 532 + assert_eq!(refs.len(), 3); 533 + } 534 + }
+637
crates/atproto-lexicon/src/validation.rs
··· 1 + //! Lexicon validation functionality for AT Protocol. 2 + //! 3 + //! This module provides validation of lexicon NSIDs, references, and schemas. 4 + 5 + use std::fmt; 6 + 7 + use anyhow::{anyhow, Result}; 8 + use serde_json::Value; 9 + use thiserror::Error; 10 + 11 + /// Errors that can occur during lexicon validation. 12 + #[derive(Error, Debug)] 13 + pub enum ValidationError { 14 + /// Invalid NSID format. 15 + #[error("Invalid NSID format: {0}")] 16 + InvalidNsidFormat(String), 17 + 18 + /// Invalid reference format. 19 + #[error("Invalid reference format: {0}")] 20 + InvalidReferenceFormat(String), 21 + 22 + /// NSID has too few parts. 23 + #[error("NSID must have at least 3 parts: {0}")] 24 + InsufficientNsidParts(String), 25 + 26 + /// Invalid DNS name conversion. 27 + #[error("Cannot convert NSID to DNS name: {0}")] 28 + InvalidDnsNameConversion(String), 29 + } 30 + 31 + /// Components of a parsed NSID. 32 + #[derive(Debug, Clone, PartialEq)] 33 + pub struct NsidParts { 34 + /// The parts in original order (e.g., ["app", "bsky", "feed", "post"] for "app.bsky.feed.post") 35 + pub parts: Vec<String>, 36 + 37 + /// The optional fragment identifier (e.g., "uri" for "community.lexicon.calendar.event#uri") 38 + pub fragment: Option<String>, 39 + } 40 + 41 + impl NsidParts { 42 + /// Serializes the NSID parts back to a string. 43 + /// 44 + /// Joins the parts with dots and appends the fragment with '#' if present. 45 + /// 46 + /// # Examples 47 + /// ``` 48 + /// use atproto_lexicon::validation::NsidParts; 49 + /// 50 + /// let parts = NsidParts { 51 + /// parts: vec!["app".to_string(), "bsky".to_string(), "feed".to_string(), "post".to_string()], 52 + /// fragment: None, 53 + /// }; 54 + /// assert_eq!(parts.to_string(), "app.bsky.feed.post"); 55 + /// 56 + /// let parts_with_fragment = NsidParts { 57 + /// parts: vec!["app".to_string(), "bsky".to_string(), "feed".to_string(), "post".to_string()], 58 + /// fragment: Some("reply".to_string()), 59 + /// }; 60 + /// assert_eq!(parts_with_fragment.to_string(), "app.bsky.feed.post#reply"); 61 + /// ``` 62 + pub fn to_string(&self) -> String { 63 + let base = self.parts.join("."); 64 + match &self.fragment { 65 + Some(fragment) => format!("{}#{}", base, fragment), 66 + None => base, 67 + } 68 + } 69 + } 70 + 71 + impl fmt::Display for NsidParts { 72 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 73 + write!(f, "{}", self.to_string()) 74 + } 75 + } 76 + 77 + /// Validates if a string is a valid NSID. 78 + /// 79 + /// A valid NSID must: 80 + /// - Contain at least one dot 81 + /// - Have at least 3 parts when split by dots 82 + /// - Not be empty 83 + pub fn is_valid_nsid(nsid: &str) -> bool { 84 + if nsid.is_empty() { 85 + return false; 86 + } 87 + 88 + let parts: Vec<&str> = nsid.split('.').collect(); 89 + parts.len() >= 3 && parts.iter().all(|p| !p.is_empty()) 90 + } 91 + 92 + /// Validates if a string is a valid NSID reference. 93 + /// 94 + /// This accepts: 95 + /// - Regular NSIDs (e.g., "app.bsky.feed.post") 96 + /// - NSIDs with fragment identifiers (e.g., "app.bsky.feed.post#uri") 97 + /// 98 + /// This rejects: 99 + /// - Fragment-only references (e.g., "#localref") 100 + /// - Empty strings 101 + /// - Invalid NSIDs without dots 102 + pub fn is_valid_reference(reference: &str) -> bool { 103 + extract_nsid_from_reference(reference).is_some() 104 + } 105 + 106 + /// Extracts a clean NSID from a reference string. 107 + /// 108 + /// Handles: 109 + /// - Regular NSIDs (e.g., "app.bsky.feed.post") 110 + /// - NSIDs with fragment identifiers (e.g., "app.bsky.feed.post#uri" -> "app.bsky.feed.post") 111 + /// 112 + /// Returns None for: 113 + /// - Fragment-only references (e.g., "#localref") 114 + /// - Invalid NSIDs without dots 115 + /// - Empty strings 116 + pub fn extract_nsid_from_reference(reference: &str) -> Option<String> { 117 + if reference.is_empty() { 118 + return None; 119 + } 120 + 121 + // Fragment-only references (starting with #) are not NSIDs 122 + if reference.starts_with('#') { 123 + return None; 124 + } 125 + 126 + // Extract the NSID part (before any fragment identifier) 127 + let nsid = if let Some(hash_pos) = reference.find('#') { 128 + &reference[..hash_pos] 129 + } else { 130 + reference 131 + }; 132 + 133 + // Validate the NSID part 134 + if nsid.is_empty() || !nsid.contains('.') { 135 + return None; 136 + } 137 + 138 + Some(nsid.to_string()) 139 + } 140 + 141 + /// Converts a potentially relative NSID reference to an absolute one. 142 + /// 143 + /// If the NSID starts with '#' (fragment-only), it concatenates the context with the NSID. 144 + /// Otherwise, it returns the NSID as-is. 145 + /// 146 + /// # Examples 147 + /// ``` 148 + /// use atproto_lexicon::validation::absolute; 149 + /// 150 + /// assert_eq!(absolute("app.bsky.feed.post", "#reply"), "app.bsky.feed.post#reply"); 151 + /// assert_eq!(absolute("app.bsky.feed.post", "com.example.other"), "com.example.other"); 152 + /// assert_eq!(absolute("app.bsky.feed.post", "#main"), "app.bsky.feed.post#main"); 153 + /// ``` 154 + pub fn absolute(context: &str, nsid: &str) -> String { 155 + if nsid.starts_with('#') { 156 + format!("{}{}", context, nsid) 157 + } else { 158 + nsid.to_string() 159 + } 160 + } 161 + 162 + /// Parses an NSID into its component parts, optionally with context. 163 + /// 164 + /// # Parameters 165 + /// - `nsid`: The NSID or fragment reference to parse 166 + /// - `context`: Optional context NSID for resolving fragment-only references 167 + /// 168 + /// # Behavior 169 + /// - If `nsid` starts with "#" and no context: returns empty parts with fragment 170 + /// - If `nsid` starts with "#" and context provided: uses context for parts, nsid (without #) for fragment 171 + /// - Otherwise: splits on "#" to separate NSID from fragment, then splits NSID on "." for parts 172 + /// - The special fragment "main" is treated as None 173 + /// 174 + /// # Examples 175 + /// ``` 176 + /// use atproto_lexicon::validation::parse_nsid; 177 + /// 178 + /// // Regular NSID 179 + /// let parts = parse_nsid("app.bsky.feed.post", None).unwrap(); 180 + /// assert_eq!(parts.parts, vec!["app", "bsky", "feed", "post"]); 181 + /// assert_eq!(parts.fragment, None); 182 + /// 183 + /// // NSID with fragment 184 + /// let parts = parse_nsid("app.bsky.feed.post#uri", None).unwrap(); 185 + /// assert_eq!(parts.parts, vec!["app", "bsky", "feed", "post"]); 186 + /// assert_eq!(parts.fragment, Some("uri".to_string())); 187 + /// 188 + /// // Fragment-only without context 189 + /// let parts = parse_nsid("#localref", None).unwrap(); 190 + /// assert_eq!(parts.parts, Vec::<String>::new()); 191 + /// assert_eq!(parts.fragment, Some("localref".to_string())); 192 + /// 193 + /// // Fragment-only with context 194 + /// let parts = parse_nsid("#localref", Some("app.bsky.feed.post".to_string())).unwrap(); 195 + /// assert_eq!(parts.parts, vec!["app", "bsky", "feed", "post"]); 196 + /// assert_eq!(parts.fragment, Some("localref".to_string())); 197 + /// 198 + /// // "main" fragment is treated as None 199 + /// let parts = parse_nsid("app.bsky.feed.post#main", None).unwrap(); 200 + /// assert_eq!(parts.parts, vec!["app", "bsky", "feed", "post"]); 201 + /// assert_eq!(parts.fragment, None); 202 + /// ``` 203 + pub fn parse_nsid(nsid: &str, context: Option<String>) -> Result<NsidParts> { 204 + // Handle fragment-only references 205 + if nsid.starts_with('#') { 206 + let fragment_str = &nsid[1..]; 207 + let fragment = if fragment_str == "main" || fragment_str.is_empty() { 208 + None 209 + } else { 210 + Some(fragment_str.to_string()) 211 + }; 212 + 213 + let parts = if let Some(ctx) = context { 214 + ctx.split('.').map(|s| s.to_string()).collect() 215 + } else { 216 + Vec::new() 217 + }; 218 + 219 + return Ok(NsidParts { parts, fragment }); 220 + } 221 + 222 + // Split on '#' to separate NSID from fragment 223 + let (nsid_part, fragment_part) = if let Some(hash_pos) = nsid.find('#') { 224 + (&nsid[..hash_pos], Some(&nsid[hash_pos + 1..])) 225 + } else { 226 + (nsid, None) 227 + }; 228 + 229 + // Parse the NSID part 230 + if nsid_part.is_empty() { 231 + return Err(ValidationError::InvalidNsidFormat("Empty NSID".to_string()).into()); 232 + } 233 + 234 + let parts: Vec<String> = nsid_part.split('.').map(|s| s.to_string()).collect(); 235 + 236 + // Validate parts (at least 3 components for a valid NSID) 237 + if parts.len() < 3 { 238 + return Err(ValidationError::InsufficientNsidParts(nsid.to_string()).into()); 239 + } 240 + 241 + if parts.iter().any(|p| p.is_empty()) { 242 + return Err(ValidationError::InvalidNsidFormat(format!("NSID contains empty parts: {}", nsid)).into()); 243 + } 244 + 245 + // Handle fragment 246 + let fragment = match fragment_part { 247 + Some("main") | Some("") => None, 248 + Some(frag) => Some(frag.to_string()), 249 + None => None, 250 + }; 251 + 252 + Ok(NsidParts { parts, fragment }) 253 + } 254 + 255 + /// Converts an NSID to a DNS name for lexicon resolution. 256 + /// 257 + /// The conversion reverses the authority parts and prepends "_lexicon". 258 + /// 259 + /// # Example 260 + /// ``` 261 + /// use atproto_lexicon::validation::nsid_to_dns_name; 262 + /// assert_eq!( 263 + /// nsid_to_dns_name("app.bsky.feed.post").unwrap(), 264 + /// "_lexicon.feed.bsky.app" 265 + /// ); 266 + /// ``` 267 + pub fn nsid_to_dns_name(nsid: &str) -> Result<String> { 268 + let parsed = parse_nsid(nsid, None)?; 269 + 270 + // Need at least 3 parts for a valid NSID (authority + name + record_type) 271 + if parsed.parts.len() < 3 { 272 + return Err(ValidationError::InvalidNsidFormat( 273 + format!("NSID must have at least 3 parts: {}", nsid) 274 + ).into()); 275 + } 276 + 277 + // Build DNS name: _lexicon.<name>.<reversed-authority> 278 + let mut dns_parts = vec!["_lexicon".to_string()]; 279 + 280 + // The name is the second-to-last part 281 + let name_idx = parsed.parts.len() - 2; 282 + dns_parts.push(parsed.parts[name_idx].clone()); 283 + 284 + // Add authority parts in reverse order (all parts except the last two) 285 + for i in (0..name_idx).rev() { 286 + dns_parts.push(parsed.parts[i].clone()); 287 + } 288 + 289 + Ok(dns_parts.join(".")) 290 + } 291 + 292 + /// Checks if a JSON object represents a reference type. 293 + /// 294 + /// A reference object has `"type": "ref"` and a `"ref"` field. 295 + pub fn is_reference_object(obj: &serde_json::Map<String, Value>) -> bool { 296 + matches!( 297 + obj.get("type"), 298 + Some(Value::String(type_val)) if type_val == "ref" 299 + ) && obj.contains_key("ref") 300 + } 301 + 302 + /// Checks if a JSON object represents a union type. 303 + /// 304 + /// A union object has `"type": "union"` and a `"refs"` array field. 305 + pub fn is_union_object(obj: &serde_json::Map<String, Value>) -> bool { 306 + matches!( 307 + obj.get("type"), 308 + Some(Value::String(type_val)) if type_val == "union" 309 + ) && matches!(obj.get("refs"), Some(Value::Array(_))) 310 + } 311 + 312 + /// Extracts an NSID from a reference object. 313 + /// 314 + /// Returns None if the object is not a valid reference or the NSID is invalid. 315 + pub fn extract_nsid_from_ref_object(obj: &serde_json::Map<String, Value>) -> Option<String> { 316 + if !is_reference_object(obj) { 317 + return None; 318 + } 319 + 320 + if let Some(Value::String(ref_val)) = obj.get("ref") { 321 + extract_nsid_from_reference(ref_val) 322 + } else { 323 + None 324 + } 325 + } 326 + 327 + /// Extracts NSIDs from a union object's refs array. 328 + /// 329 + /// Handles both direct string references and nested reference objects. 330 + pub fn extract_nsids_from_union_object(obj: &serde_json::Map<String, Value>) -> Vec<String> { 331 + if !is_union_object(obj) { 332 + return Vec::new(); 333 + } 334 + 335 + let mut nsids = Vec::new(); 336 + 337 + if let Some(Value::Array(refs_array)) = obj.get("refs") { 338 + for ref_item in refs_array { 339 + match ref_item { 340 + Value::String(ref_str) => { 341 + if let Some(nsid) = extract_nsid_from_reference(ref_str) { 342 + nsids.push(nsid); 343 + } 344 + } 345 + Value::Object(ref_obj) => { 346 + if let Some(nsid) = extract_nsid_from_ref_object(ref_obj) { 347 + nsids.push(nsid); 348 + } 349 + } 350 + _ => {} 351 + } 352 + } 353 + } 354 + 355 + nsids 356 + } 357 + 358 + /// Validates a complete lexicon schema. 359 + /// 360 + /// Checks for: 361 + /// - Required fields (lexicon version, id, defs) 362 + /// - Valid NSID in the id field 363 + /// - Well-formed definitions 364 + pub fn validate_lexicon_schema(schema: &Value) -> Result<()> { 365 + let obj = schema.as_object() 366 + .ok_or_else(|| anyhow!("Lexicon schema must be an object"))?; 367 + 368 + // Check lexicon version 369 + if !obj.contains_key("lexicon") { 370 + return Err(anyhow!("Missing 'lexicon' version field")); 371 + } 372 + 373 + // Check and validate ID 374 + let id = obj.get("id") 375 + .and_then(|v| v.as_str()) 376 + .ok_or_else(|| anyhow!("Missing or invalid 'id' field"))?; 377 + 378 + if !is_valid_nsid(id) { 379 + return Err(ValidationError::InvalidNsidFormat(id.to_string()).into()); 380 + } 381 + 382 + // Check defs exists and is an object 383 + obj.get("defs") 384 + .and_then(|v| v.as_object()) 385 + .ok_or_else(|| anyhow!("Missing or invalid 'defs' field"))?; 386 + 387 + Ok(()) 388 + } 389 + 390 + #[cfg(test)] 391 + mod tests { 392 + use super::*; 393 + 394 + #[test] 395 + fn test_is_valid_nsid() { 396 + assert!(is_valid_nsid("app.bsky.feed.post")); 397 + assert!(is_valid_nsid("com.example.service.method")); 398 + assert!(is_valid_nsid("a.b.c")); 399 + 400 + assert!(!is_valid_nsid("app.bsky")); // Too few parts 401 + assert!(!is_valid_nsid("app")); // Too few parts 402 + assert!(!is_valid_nsid("")); // Empty 403 + assert!(!is_valid_nsid("app..feed.post")); // Empty part 404 + } 405 + 406 + #[test] 407 + fn test_extract_nsid_from_reference() { 408 + // Valid NSID 409 + assert_eq!( 410 + extract_nsid_from_reference("app.bsky.feed.post"), 411 + Some("app.bsky.feed.post".to_string()) 412 + ); 413 + 414 + // NSID with fragment identifier 415 + assert_eq!( 416 + extract_nsid_from_reference("app.bsky.feed.post#uri"), 417 + Some("app.bsky.feed.post".to_string()) 418 + ); 419 + 420 + // Fragment-only references should return None 421 + assert_eq!(extract_nsid_from_reference("#app.bsky.feed.post"), None); 422 + assert_eq!(extract_nsid_from_reference("#localref"), None); 423 + assert_eq!(extract_nsid_from_reference("#"), None); 424 + 425 + // Invalid formats 426 + assert_eq!(extract_nsid_from_reference("#app.bsky.feed.post#uri"), None); // Starts with # 427 + assert_eq!(extract_nsid_from_reference("invalid"), None); // No dots 428 + assert_eq!(extract_nsid_from_reference(""), None); // Empty 429 + assert_eq!(extract_nsid_from_reference("#com.example#foo"), None); // Multiple fragments 430 + } 431 + 432 + #[test] 433 + fn test_absolute() { 434 + // Fragment-only references should be made absolute with context 435 + assert_eq!(absolute("app.bsky.feed.post", "#reply"), "app.bsky.feed.post#reply"); 436 + assert_eq!(absolute("com.example.schema", "#main"), "com.example.schema#main"); 437 + assert_eq!(absolute("a.b.c", "#"), "a.b.c#"); 438 + 439 + // Already absolute NSIDs should be returned as-is 440 + assert_eq!(absolute("app.bsky.feed.post", "com.example.other"), "com.example.other"); 441 + assert_eq!(absolute("app.bsky.feed.post", "app.bsky.actor.profile"), "app.bsky.actor.profile"); 442 + assert_eq!(absolute("ignored.context", "app.bsky.feed.post#uri"), "app.bsky.feed.post#uri"); 443 + } 444 + 445 + #[test] 446 + fn test_parse_nsid() { 447 + // Basic 4-part NSID 448 + let parts = parse_nsid("app.bsky.feed.post", None).unwrap(); 449 + assert_eq!(parts.parts, vec!["app", "bsky", "feed", "post"]); 450 + assert_eq!(parts.fragment, None); 451 + 452 + // 4-part NSID with different authority 453 + let parts = parse_nsid("com.example.service.method", None).unwrap(); 454 + assert_eq!(parts.parts, vec!["com", "example", "service", "method"]); 455 + assert_eq!(parts.fragment, None); 456 + 457 + // NSID with fragment 458 + let parts = parse_nsid("app.bsky.feed.post#reply", None).unwrap(); 459 + assert_eq!(parts.parts, vec!["app", "bsky", "feed", "post"]); 460 + assert_eq!(parts.fragment, Some("reply".to_string())); 461 + 462 + // "main" fragment should be treated as None 463 + let parts = parse_nsid("app.bsky.feed.post#main", None).unwrap(); 464 + assert_eq!(parts.parts, vec!["app", "bsky", "feed", "post"]); 465 + assert_eq!(parts.fragment, None); 466 + 467 + // Fragment-only without context 468 + let parts = parse_nsid("#reply", None).unwrap(); 469 + assert_eq!(parts.parts, Vec::<String>::new()); 470 + assert_eq!(parts.fragment, Some("reply".to_string())); 471 + 472 + // Fragment-only with context 473 + let parts = parse_nsid("#reply", Some("app.bsky.feed.post".to_string())).unwrap(); 474 + assert_eq!(parts.parts, vec!["app", "bsky", "feed", "post"]); 475 + assert_eq!(parts.fragment, Some("reply".to_string())); 476 + 477 + // Too few parts 478 + assert!(parse_nsid("app.bsky", None).is_err()); 479 + assert!(parse_nsid("", None).is_err()); 480 + } 481 + 482 + #[test] 483 + fn test_nsid_parts_serialization() { 484 + // Basic NSID without fragment 485 + let parts = NsidParts { 486 + parts: vec!["app".to_string(), "bsky".to_string(), "feed".to_string(), "post".to_string()], 487 + fragment: None, 488 + }; 489 + assert_eq!(parts.to_string(), "app.bsky.feed.post"); 490 + assert_eq!(format!("{}", parts), "app.bsky.feed.post"); // Test Display trait 491 + 492 + // NSID with fragment 493 + let parts_with_fragment = NsidParts { 494 + parts: vec!["com".to_string(), "example".to_string(), "schema".to_string(), "type".to_string()], 495 + fragment: Some("reply".to_string()), 496 + }; 497 + assert_eq!(parts_with_fragment.to_string(), "com.example.schema.type#reply"); 498 + assert_eq!(format!("{}", parts_with_fragment), "com.example.schema.type#reply"); 499 + 500 + // Empty parts with fragment (edge case from fragment-only parsing) 501 + let fragment_only = NsidParts { 502 + parts: vec![], 503 + fragment: Some("localref".to_string()), 504 + }; 505 + assert_eq!(fragment_only.to_string(), "#localref"); 506 + 507 + // Round-trip test: parse and serialize 508 + let original = "app.bsky.feed.post#main"; 509 + let parsed = parse_nsid(original, None).unwrap(); 510 + // Note: "main" is treated as None, so this won't round-trip exactly 511 + assert_eq!(parsed.to_string(), "app.bsky.feed.post"); 512 + 513 + let original_with_fragment = "app.bsky.feed.post#reply"; 514 + let parsed_with_fragment = parse_nsid(original_with_fragment, None).unwrap(); 515 + assert_eq!(parsed_with_fragment.to_string(), original_with_fragment); 516 + } 517 + 518 + #[test] 519 + fn test_nsid_to_dns_name() { 520 + assert_eq!( 521 + nsid_to_dns_name("app.bsky.feed.post").unwrap(), 522 + "_lexicon.feed.bsky.app" 523 + ); 524 + 525 + assert_eq!( 526 + nsid_to_dns_name("com.atproto.repo.getRecord").unwrap(), 527 + "_lexicon.repo.atproto.com" 528 + ); 529 + 530 + assert_eq!( 531 + nsid_to_dns_name("org.example.deeply.nested.service.action").unwrap(), 532 + "_lexicon.service.nested.deeply.example.org" 533 + ); 534 + 535 + // "main" fragment doesn't affect DNS name generation 536 + assert_eq!( 537 + nsid_to_dns_name("app.bsky.feed.main").unwrap(), 538 + "_lexicon.feed.bsky.app" 539 + ); 540 + 541 + assert!(nsid_to_dns_name("app.bsky").is_err()); 542 + } 543 + 544 + #[test] 545 + fn test_reference_object_detection() { 546 + let ref_obj = serde_json::json!({ 547 + "type": "ref", 548 + "ref": "app.bsky.feed.post" 549 + }); 550 + assert!(is_reference_object(ref_obj.as_object().unwrap())); 551 + 552 + let not_ref = serde_json::json!({ 553 + "type": "string", 554 + "ref": "app.bsky.feed.post" 555 + }); 556 + assert!(!is_reference_object(not_ref.as_object().unwrap())); 557 + 558 + let missing_ref = serde_json::json!({ 559 + "type": "ref" 560 + }); 561 + assert!(!is_reference_object(missing_ref.as_object().unwrap())); 562 + } 563 + 564 + #[test] 565 + fn test_union_object_detection() { 566 + let union_obj = serde_json::json!({ 567 + "type": "union", 568 + "refs": ["app.bsky.feed.post", "app.bsky.actor.profile"] 569 + }); 570 + assert!(is_union_object(union_obj.as_object().unwrap())); 571 + 572 + let not_union = serde_json::json!({ 573 + "type": "ref", 574 + "refs": ["app.bsky.feed.post"] 575 + }); 576 + assert!(!is_union_object(not_union.as_object().unwrap())); 577 + 578 + let invalid_refs = serde_json::json!({ 579 + "type": "union", 580 + "refs": "not-an-array" 581 + }); 582 + assert!(!is_union_object(invalid_refs.as_object().unwrap())); 583 + } 584 + 585 + #[test] 586 + fn test_extract_nsids_from_union() { 587 + let union_obj = serde_json::json!({ 588 + "type": "union", 589 + "refs": [ 590 + "app.bsky.feed.post", 591 + "#app.bsky.actor.profile", // Fragment-only, should be skipped 592 + "app.bsky.graph.follow#uri", // NSID with fragment 593 + { "type": "ref", "ref": "app.bsky.embed.images" }, 594 + "invalid" // No dots, should be skipped 595 + ] 596 + }); 597 + 598 + let nsids = extract_nsids_from_union_object(union_obj.as_object().unwrap()); 599 + assert_eq!(nsids.len(), 3); 600 + assert!(nsids.contains(&"app.bsky.feed.post".to_string())); 601 + assert!(nsids.contains(&"app.bsky.graph.follow".to_string())); // Fragment removed 602 + assert!(nsids.contains(&"app.bsky.embed.images".to_string())); 603 + // #app.bsky.actor.profile is fragment-only, so it's not included 604 + assert!(!nsids.contains(&"app.bsky.actor.profile".to_string())); 605 + } 606 + 607 + #[test] 608 + fn test_validate_lexicon_schema() { 609 + let valid_schema = serde_json::json!({ 610 + "lexicon": 1, 611 + "id": "app.bsky.feed.post", 612 + "defs": { 613 + "main": {} 614 + } 615 + }); 616 + assert!(validate_lexicon_schema(&valid_schema).is_ok()); 617 + 618 + let missing_lexicon = serde_json::json!({ 619 + "id": "app.bsky.feed.post", 620 + "defs": {} 621 + }); 622 + assert!(validate_lexicon_schema(&missing_lexicon).is_err()); 623 + 624 + let invalid_id = serde_json::json!({ 625 + "lexicon": 1, 626 + "id": "invalid", 627 + "defs": {} 628 + }); 629 + assert!(validate_lexicon_schema(&invalid_id).is_err()); 630 + 631 + let missing_defs = serde_json::json!({ 632 + "lexicon": 1, 633 + "id": "app.bsky.feed.post" 634 + }); 635 + assert!(validate_lexicon_schema(&missing_defs).is_err()); 636 + } 637 + }
-20
crates/atproto-oauth/src/dpop.rs
··· 10 10 use elliptic_curve::JwkEcKey; 11 11 use reqwest::header::HeaderValue; 12 12 use reqwest_chain::Chainer; 13 - use serde::Deserialize; 14 13 use ulid::Ulid; 15 14 16 15 use crate::{ ··· 19 18 jwt::{Claims, Header, JoseClaims, mint}, 20 19 pkce::challenge, 21 20 }; 22 - 23 - /// Simple error response structure for parsing OAuth error responses. 24 - #[cfg_attr(debug_assertions, derive(Debug))] 25 - #[derive(Clone, Deserialize)] 26 - struct SimpleError { 27 - /// The error code or message returned by the OAuth server. 28 - pub error: Option<String>, 29 - } 30 - 31 - /// Display implementation for SimpleError that shows the error message or "unknown". 32 - impl std::fmt::Display for SimpleError { 33 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 34 - if let Some(value) = &self.error { 35 - write!(f, "{}", value) 36 - } else { 37 - write!(f, "unknown") 38 - } 39 - } 40 - } 41 21 42 22 /// Retry middleware for handling DPoP nonce challenges in HTTP requests. 43 23 ///