A library for ATProtocol identities.

chore: atproto-lexicon error management

Changed files
+202 -72
crates
+158
crates/atproto-lexicon/src/errors.rs
···
··· 1 + //! Error types for the atproto-lexicon crate. 2 + //! 3 + //! This module defines all error types for lexicon operations including resolution, 4 + //! validation, and schema processing. All errors follow the project's error naming 5 + //! convention using globally unique identifiers. 6 + //! 7 + //! ## Error Categories 8 + //! 9 + //! - **`LexiconResolveError`** (lexicon-resolve-1 to lexicon-resolve-6): Errors during lexicon resolution 10 + //! - **`LexiconValidationError`** (lexicon-validation-1 to lexicon-validation-8): Validation errors for NSIDs and schemas 11 + //! - **`LexiconSchemaError`** (lexicon-schema-1 to lexicon-schema-4): Schema parsing and structure errors 12 + //! 13 + //! ## Error Format 14 + //! 15 + //! All errors follow the format: `error-atproto-lexicon-<domain>-<number> <message>: <details>` 16 + 17 + use thiserror::Error; 18 + use atproto_identity::errors::ResolveError; 19 + 20 + /// Errors that can occur during lexicon resolution operations. 21 + #[derive(Debug, Error)] 22 + pub enum LexiconResolveError { 23 + /// No DIDs found when resolving a handle or identifier. 24 + #[error("error-atproto-lexicon-resolve-1 No DIDs found for resolution")] 25 + NoDIDsFound, 26 + 27 + /// Multiple DIDs found when expecting exactly one. 28 + #[error("error-atproto-lexicon-resolve-2 Multiple DIDs found: expected single DID")] 29 + MultipleDIDsFound, 30 + 31 + /// Invalid DID format encountered during resolution. 32 + #[error("error-atproto-lexicon-resolve-3 Invalid DID format: {did}")] 33 + InvalidDIDFormat { 34 + /// The invalid DID string 35 + did: String, 36 + }, 37 + 38 + /// No PDS endpoint found in DID document. 39 + #[error("error-atproto-lexicon-resolve-4 No PDS endpoint found in DID document")] 40 + NoPDSEndpoint, 41 + 42 + /// Failed to fetch lexicon from PDS. 43 + #[error("error-atproto-lexicon-resolve-5 Failed to fetch lexicon from PDS: {details}")] 44 + PDSFetchFailed { 45 + /// Details about the fetch failure 46 + details: String, 47 + }, 48 + 49 + /// Error response from PDS when fetching lexicon. 50 + #[error("error-atproto-lexicon-resolve-6 Error fetching lexicon for {nsid}: {message}")] 51 + PDSErrorResponse { 52 + /// The NSID being fetched 53 + nsid: String, 54 + /// Error message from PDS 55 + message: String, 56 + }, 57 + } 58 + 59 + /// Errors that can occur during lexicon validation. 60 + #[derive(Debug, Error)] 61 + pub enum LexiconValidationError { 62 + /// Invalid NSID format. 63 + #[error("error-atproto-lexicon-validation-1 Invalid NSID format: {details}")] 64 + InvalidNsidFormat { 65 + /// Details about what makes the NSID invalid 66 + details: String, 67 + }, 68 + 69 + /// Invalid reference format. 70 + #[error("error-atproto-lexicon-validation-2 Invalid reference format: {details}")] 71 + InvalidReferenceFormat { 72 + /// Details about what makes the reference invalid 73 + details: String, 74 + }, 75 + 76 + /// NSID has insufficient parts (requires at least 3). 77 + #[error("error-atproto-lexicon-validation-3 NSID must have at least 3 parts: {nsid}")] 78 + InsufficientNsidParts { 79 + /// The NSID with insufficient parts 80 + nsid: String, 81 + }, 82 + 83 + /// Cannot convert NSID to DNS name. 84 + #[error("error-atproto-lexicon-validation-4 Cannot convert NSID to DNS name: {nsid}")] 85 + InvalidDnsNameConversion { 86 + /// The NSID that couldn't be converted 87 + nsid: String, 88 + }, 89 + 90 + /// Empty NSID provided. 91 + #[error("error-atproto-lexicon-validation-5 Empty NSID provided")] 92 + EmptyNsid, 93 + 94 + /// NSID contains empty parts. 95 + #[error("error-atproto-lexicon-validation-6 NSID contains empty parts: {nsid}")] 96 + EmptyNsidParts { 97 + /// The NSID with empty parts 98 + nsid: String, 99 + }, 100 + 101 + /// Invalid NSID character. 102 + #[error("error-atproto-lexicon-validation-7 Invalid character in NSID: {details}")] 103 + InvalidNsidCharacter { 104 + /// Details about the invalid character 105 + details: String, 106 + }, 107 + 108 + /// Invalid NSID in schema ID field. 109 + #[error("error-atproto-lexicon-validation-8 Invalid NSID in schema ID field: {id}")] 110 + InvalidSchemaId { 111 + /// The invalid ID from the schema 112 + id: String, 113 + }, 114 + } 115 + 116 + /// Errors that can occur when processing lexicon schemas. 117 + #[derive(Debug, Error)] 118 + pub enum LexiconSchemaError { 119 + /// Lexicon schema must be an object. 120 + #[error("error-atproto-lexicon-schema-1 Lexicon schema must be an object")] 121 + NotAnObject, 122 + 123 + /// Missing required 'lexicon' version field. 124 + #[error("error-atproto-lexicon-schema-2 Missing 'lexicon' version field")] 125 + MissingLexiconVersion, 126 + 127 + /// Missing or invalid 'id' field. 128 + #[error("error-atproto-lexicon-schema-3 Missing or invalid 'id' field")] 129 + MissingOrInvalidId, 130 + 131 + /// Missing or invalid 'defs' field. 132 + #[error("error-atproto-lexicon-schema-4 Missing or invalid 'defs' field")] 133 + MissingOrInvalidDefs, 134 + } 135 + 136 + /// Errors specific to recursive lexicon resolution. 137 + #[derive(Debug, Error)] 138 + pub enum LexiconRecursiveError { 139 + /// Failed to resolve any lexicons during recursive resolution. 140 + #[error("error-atproto-lexicon-recursive-1 Failed to resolve any lexicons")] 141 + NoLexiconsResolved, 142 + } 143 + 144 + // Re-export the validation error for backwards compatibility during migration 145 + pub use LexiconValidationError as ValidationError; 146 + 147 + /// Implement conversion from ResolveError to LexiconResolveError. 148 + impl From<ResolveError> for LexiconResolveError { 149 + fn from(err: ResolveError) -> Self { 150 + match err { 151 + ResolveError::NoDIDsFound => LexiconResolveError::NoDIDsFound, 152 + ResolveError::MultipleDIDsFound => LexiconResolveError::MultipleDIDsFound, 153 + _ => LexiconResolveError::PDSFetchFailed { 154 + details: format!("DNS resolution error: {:?}", err), 155 + }, 156 + } 157 + } 158 + }
+1
crates/atproto-lexicon/src/lib.rs
··· 6 #![forbid(unsafe_code)] 7 #![warn(missing_docs)] 8 9 pub mod resolve; 10 pub mod resolve_recursive; 11 pub mod validation;
··· 6 #![forbid(unsafe_code)] 7 #![warn(missing_docs)] 8 9 + pub mod errors; 10 pub mod resolve; 11 pub mod resolve_recursive; 12 pub mod validation;
+16 -10
crates/atproto-lexicon/src/resolve.rs
··· 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] ··· 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?; ··· 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()) ··· 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 ··· 137 } 138 } 139 140 - Err(anyhow!("No PDS endpoint found in DID document")) 141 } 142 143 /// Fetch lexicon schema from PDS using XRPC. ··· 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 }
··· 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::Result; 14 use atproto_client::{ 15 client::Auth, 16 com::atproto::repo::{get_record, GetRecordResponse}, 17 }; 18 use atproto_identity::{ 19 resolve::{DnsResolver, resolve_subject}, 20 }; 21 use serde_json::Value; 22 use tracing::instrument; 23 24 + use crate::{errors::LexiconResolveError, validation}; 25 26 /// Trait for lexicon resolution implementations. 27 #[async_trait::async_trait] ··· 78 pub async fn resolve_lexicon_dns<R: DnsResolver + ?Sized>( 79 dns_resolver: &R, 80 lookup_dns: &str, 81 + ) -> Result<String, LexiconResolveError> { 82 let txt_records = dns_resolver 83 .resolve_txt(lookup_dns) 84 .await?; ··· 103 .collect(); 104 105 if dids.is_empty() { 106 + return Err(LexiconResolveError::NoDIDsFound); 107 } 108 109 if dids.len() > 1 { 110 + return Err(LexiconResolveError::MultipleDIDsFound); 111 } 112 113 Ok(dids[0].clone()) ··· 126 InputType::Web(did) => { 127 web::query(http_client, &did).await? 128 } 129 + _ => return Err(LexiconResolveError::InvalidDIDFormat { 130 + did: did.to_string(), 131 + }.into()), 132 }; 133 134 // Extract PDS endpoint from service array ··· 138 } 139 } 140 141 + Err(LexiconResolveError::NoPDSEndpoint.into()) 142 } 143 144 /// Fetch lexicon schema from PDS using XRPC. ··· 165 None, 166 ) 167 .await 168 + .map_err(|e| LexiconResolveError::PDSFetchFailed { 169 + details: e.to_string(), 170 + })?; 171 172 // Extract the value from the response 173 match response { 174 GetRecordResponse::Record { value, .. } => Ok(value), 175 GetRecordResponse::Error(err) => { 176 let msg = err.message.or(err.error_description).or(err.error).unwrap_or_else(|| "Unknown error".to_string()); 177 + Err(LexiconResolveError::PDSErrorResponse { 178 + nsid: nsid.to_string(), 179 + message: msg, 180 + }.into()) 181 } 182 } 183 }
+3 -2
crates/atproto-lexicon/src/resolve_recursive.rs
··· 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 ··· 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)
··· 5 6 use std::collections::{HashMap, HashSet}; 7 8 + use anyhow::Result; 9 use serde_json::Value; 10 use tracing::instrument; 11 12 + use crate::errors::LexiconRecursiveError; 13 use crate::resolve::LexiconResolver; 14 use crate::validation::{absolute, extract_nsid_from_ref_object}; 15 ··· 133 } 134 135 if resolved.is_empty() && self.config.include_entry { 136 + return Err(LexiconRecursiveError::NoLexiconsResolved.into()); 137 } 138 139 Ok(resolved)
+24 -60
crates/atproto-lexicon/src/validation.rs
··· 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)] ··· 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 ··· 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 ··· 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> ··· 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 }
··· 4 5 use std::fmt; 6 7 + use anyhow::Result; 8 use serde_json::Value; 9 10 + use crate::errors::{LexiconSchemaError, LexiconValidationError}; 11 12 /// Components of a parsed NSID. 13 #[derive(Debug, Clone, PartialEq)] ··· 20 } 21 22 impl NsidParts { 23 } 24 25 impl fmt::Display for NsidParts { 26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 27 + let base = self.parts.join("."); 28 + match &self.fragment { 29 + Some(fragment) => write!(f, "{}#{}", base, fragment), 30 + None => write!(f, "{}", base), 31 + } 32 } 33 } 34 ··· 186 187 // Parse the NSID part 188 if nsid_part.is_empty() { 189 + return Err(LexiconValidationError::EmptyNsid.into()); 190 } 191 192 let parts: Vec<String> = nsid_part.split('.').map(|s| s.to_string()).collect(); 193 194 // Validate parts (at least 3 components for a valid NSID) 195 if parts.len() < 3 { 196 + return Err(LexiconValidationError::InsufficientNsidParts { 197 + nsid: nsid.to_string(), 198 + }.into()); 199 } 200 201 if parts.iter().any(|p| p.is_empty()) { 202 + return Err(LexiconValidationError::EmptyNsidParts { 203 + nsid: nsid.to_string(), 204 + }.into()); 205 } 206 207 // Handle fragment ··· 231 232 // Need at least 3 parts for a valid NSID (authority + name + record_type) 233 if parsed.parts.len() < 3 { 234 + return Err(LexiconValidationError::InsufficientNsidParts { 235 + nsid: nsid.to_string(), 236 + }.into()); 237 } 238 239 // Build DNS name: _lexicon.<name>.<reversed-authority> ··· 325 /// - Well-formed definitions 326 pub fn validate_lexicon_schema(schema: &Value) -> Result<()> { 327 let obj = schema.as_object() 328 + .ok_or(LexiconSchemaError::NotAnObject)?; 329 330 // Check lexicon version 331 if !obj.contains_key("lexicon") { 332 + return Err(LexiconSchemaError::MissingLexiconVersion.into()); 333 } 334 335 // Check and validate ID 336 let id = obj.get("id") 337 .and_then(|v| v.as_str()) 338 + .ok_or(LexiconSchemaError::MissingOrInvalidId)?; 339 340 if !is_valid_nsid(id) { 341 + return Err(LexiconValidationError::InvalidSchemaId { 342 + id: id.to_string(), 343 + }.into()); 344 } 345 346 // Check defs exists and is an object 347 obj.get("defs") 348 .and_then(|v| v.as_object()) 349 + .ok_or(LexiconSchemaError::MissingOrInvalidDefs)?; 350 351 Ok(()) 352 }