A library for ATProtocol identities.

feature: Added lexicon community lexicons

Signed-off-by: Nick Gerakines <nick.gerakines@gmail.com>

+1
Cargo.lock
··· 496 checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" 497 dependencies = [ 498 "num-traits", 499 ] 500 501 [[package]]
··· 496 checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" 497 dependencies = [ 498 "num-traits", 499 + "serde", 500 ] 501 502 [[package]]
+1 -1
crates/atproto-oauth/src/jwk.rs
··· 731 732 // Ensure reasonable performance (should complete in well under a second) 733 assert!( 734 - duration.as_millis() < 5000, 735 "Performance test took too long: {:?}", 736 duration 737 );
··· 731 732 // Ensure reasonable performance (should complete in well under a second) 733 assert!( 734 + duration.as_millis() < 10000, 735 "Performance test took too long: {:?}", 736 duration 737 );
+1 -1
crates/atproto-record/Cargo.toml
··· 39 thiserror.workspace = true 40 41 tokio = { workspace = true, optional = true } 42 - chrono = {version = "0.4.41", default-features = false, features = ["std", "now"]} 43 clap = { workspace = true, optional = true } 44 45 [features]
··· 39 thiserror.workspace = true 40 41 tokio = { workspace = true, optional = true } 42 + chrono = {version = "0.4.41", default-features = false, features = ["std", "now", "serde"]} 43 clap = { workspace = true, optional = true } 44 45 [features]
+2 -2
crates/atproto-record/src/aturi.rs
··· 2 //! 3 //! This module provides functionality to parse and validate AT Protocol URIs (AT-URIs), 4 //! which are used to uniquely identify records within the AT Protocol ecosystem. 5 - //! 6 //! ## AT-URI Format 7 //! 8 //! AT-URIs follow the format: `at://authority/collection/record_key` ··· 43 /// 44 /// AT-URIs uniquely identify records within AT Protocol repositories by combining 45 /// three essential components: authority (DID), collection (NSID), and record key. 46 - /// 47 /// This struct provides validated access to these components after successful parsing 48 /// and implements `Display` for reconstructing the original URI format. 49 #[cfg_attr(debug_assertions, derive(Debug))]
··· 2 //! 3 //! This module provides functionality to parse and validate AT Protocol URIs (AT-URIs), 4 //! which are used to uniquely identify records within the AT Protocol ecosystem. 5 + //! 6 //! ## AT-URI Format 7 //! 8 //! AT-URIs follow the format: `at://authority/collection/record_key` ··· 43 /// 44 /// AT-URIs uniquely identify records within AT Protocol repositories by combining 45 /// three essential components: authority (DID), collection (NSID), and record key. 46 + /// 47 /// This struct provides validated access to these components after successful parsing 48 /// and implements `Display` for reconstructing the original URI format. 49 #[cfg_attr(debug_assertions, derive(Debug))]
+52
crates/atproto-record/src/bytes.rs
···
··· 1 + //! Byte array serialization utilities for AT Protocol records. 2 + //! 3 + //! This module provides specialized Serde serialization and deserialization functions 4 + //! for byte arrays (`Vec<u8>`), handling base64 encoding as required by the AT Protocol 5 + //! lexicon specifications for binary data fields. 6 + //! 7 + //! ## Usage 8 + //! 9 + //! The `format` module is designed to be used with Serde's `#[serde(with = "...")]` attribute 10 + //! on fields that contain binary data: 11 + //! 12 + //! ```ignore 13 + //! use serde::{Deserialize, Serialize}; 14 + //! 15 + //! #[derive(Serialize, Deserialize)] 16 + //! struct Signature { 17 + //! #[serde(rename = "$bytes", with = "atproto_record::bytes::format")] 18 + //! signature: Vec<u8>, 19 + //! } 20 + //! ``` 21 + //! 22 + //! ## Base64 Encoding 23 + //! 24 + //! The serialization uses standard base64 encoding (RFC 4648) with padding, 25 + //! compatible with the `$bytes` field format used throughout AT Protocol. 26 + 27 + /// Base64 serialization format for byte arrays. 28 + /// 29 + /// This module provides serde serialization/deserialization for `Vec<u8>` values, 30 + /// encoding them as base64 strings during JSON serialization and decoding them 31 + /// back to byte vectors during deserialization. This is the standard format 32 + /// for binary data in AT Protocol lexicon structures. 33 + pub mod format { 34 + use serde::{Deserialize, Serialize}; 35 + use serde::{Deserializer, Serializer}; 36 + 37 + use base64::{Engine, engine::general_purpose::STANDARD}; 38 + 39 + /// Serializes a byte vector to a base64 encoded string. 40 + pub fn serialize<S: Serializer>(value: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error> { 41 + let encoded_value = STANDARD.encode(&value); 42 + String::serialize(&encoded_value, serializer) 43 + } 44 + 45 + /// Deserializes a base64 encoded string to a byte vector. 46 + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> { 47 + let encoded_value = String::deserialize(d)?; 48 + STANDARD 49 + .decode(encoded_value) 50 + .map_err(|e| serde::de::Error::custom(e)) 51 + } 52 + }
+57
crates/atproto-record/src/lexicon/com_atproto_repo.rs
···
··· 1 + //! Core AT Protocol repository types. 2 + //! 3 + //! This module contains fundamental types for the AT Protocol repository 4 + //! system, including strong references that combine URIs with content 5 + //! identifiers for immutable referencing. 6 + 7 + use crate::typed::{LexiconType, TypedLexicon}; 8 + use serde::{Deserialize, Serialize}; 9 + 10 + /// The namespace identifier for strong references 11 + pub const STRONG_REF_NSID: &str = "com.atproto.repo.strongRef"; 12 + 13 + /// Strong reference to an AT Protocol record. 14 + /// 15 + /// A strong reference combines an AT URI with a CID (Content Identifier), 16 + /// providing both a location and a content hash. This ensures that the 17 + /// reference points to a specific, immutable version of a record. 18 + /// 19 + /// Strong references are commonly used to: 20 + /// - Reference specific versions of records 21 + /// - Create immutable links between records 22 + /// - Ensure content integrity through CID verification 23 + /// 24 + /// # Example 25 + /// 26 + /// ```ignore 27 + /// use atproto_record::lexicon::com::atproto::repo::{StrongRef, TypedStrongRef}; 28 + /// 29 + /// let strong_ref = StrongRef { 30 + /// uri: "at://did:plc:example/app.bsky.feed.post/3k4duaz5vfs2b".to_string(), 31 + /// cid: "bafyreib55ro5klxlwfxc5hzfonpdog6donvqvwdvjbloffqrmkenbqgpde".to_string(), 32 + /// }; 33 + /// 34 + /// // Use TypedStrongRef for automatic $type field handling 35 + /// let typed_ref = TypedStrongRef::new(strong_ref); 36 + /// ``` 37 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 38 + pub struct StrongRef { 39 + /// AT URI pointing to a specific record 40 + /// Format: at://[did]/[collection]/[rkey] 41 + pub uri: String, 42 + /// Content Identifier (CID) of the record 43 + /// This ensures the reference points to a specific, immutable version 44 + pub cid: String, 45 + } 46 + 47 + impl LexiconType for StrongRef { 48 + fn lexicon_type() -> &'static str { 49 + STRONG_REF_NSID 50 + } 51 + } 52 + 53 + /// Type alias for StrongRef with automatic $type field handling. 54 + /// 55 + /// This type wrapper ensures that the `$type` field is automatically 56 + /// added during serialization and validated during deserialization. 57 + pub type TypedStrongRef = TypedLexicon<StrongRef>;
+652
crates/atproto-record/src/lexicon/community_lexicon_attestation.rs
···
··· 1 + //! Attestation and signature types for AT Protocol. 2 + //! 3 + //! This module provides types for cryptographic signatures and attestations 4 + //! that can be attached to records to prove authenticity, authorization, 5 + //! or other properties. 6 + 7 + use std::collections::HashMap; 8 + 9 + use serde::{Deserialize, Serialize}; 10 + 11 + use crate::lexicon::Bytes; 12 + use crate::lexicon::com_atproto_repo::TypedStrongRef; 13 + use crate::typed::{LexiconType, TypedLexicon}; 14 + 15 + /// The namespace identifier for signatures 16 + pub const NSID: &str = "community.lexicon.attestation.signature"; 17 + 18 + /// Enum that can hold either a signature reference or an inline signature. 19 + /// 20 + /// This type allows signatures to be either embedded directly in a record 21 + /// or referenced via a strong reference. This flexibility enables: 22 + /// - Inline signatures for immediate verification 23 + /// - Referenced signatures for deduplication and separate storage 24 + /// 25 + /// # Example 26 + /// 27 + /// ```ignore 28 + /// use atproto_record::lexicon::community::lexicon::attestation::{SignatureOrRef, create_typed_signature}; 29 + /// use atproto_record::lexicon::Bytes; 30 + /// 31 + /// // Inline signature 32 + /// let inline = SignatureOrRef::Inline(create_typed_signature( 33 + /// "did:plc:issuer".to_string(), 34 + /// Bytes { bytes: b"signature".to_vec() }, 35 + /// )); 36 + /// 37 + /// // Referenced signature 38 + /// let reference = SignatureOrRef::Reference(typed_strong_ref); 39 + /// ``` 40 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] 41 + #[serde(untagged)] 42 + pub enum SignatureOrRef { 43 + /// A reference to a signature stored elsewhere 44 + Reference(TypedStrongRef), 45 + /// An inline signature 46 + Inline(TypedSignature), 47 + } 48 + 49 + /// A vector of signatures that can be either inline or referenced. 50 + /// 51 + /// This type alias is commonly used in records that support multiple 52 + /// signatures, such as badges, awards, and RSVPs. 53 + pub type Signatures = Vec<SignatureOrRef>; 54 + 55 + /// Cryptographic signature structure. 56 + /// 57 + /// Represents a signature created by an issuer (identified by DID) over 58 + /// some data. The signature can be used to verify authenticity, authorization, 59 + /// or other properties of the signed content. 60 + /// 61 + /// # Fields 62 + /// 63 + /// - `issuer`: DID of the entity that created the signature 64 + /// - `signature`: The actual signature bytes 65 + /// - `extra`: Additional fields that may be present in the signature 66 + /// 67 + /// # Example 68 + /// 69 + /// ```ignore 70 + /// use atproto_record::lexicon::community::lexicon::attestation::Signature; 71 + /// use atproto_record::lexicon::Bytes; 72 + /// use std::collections::HashMap; 73 + /// 74 + /// let sig = Signature { 75 + /// issuer: "did:plc:example".to_string(), 76 + /// signature: Bytes { bytes: b"signature_bytes".to_vec() }, 77 + /// extra: HashMap::new(), 78 + /// }; 79 + /// ``` 80 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] 81 + pub struct Signature { 82 + /// DID of the entity that created this signature 83 + pub issuer: String, 84 + 85 + /// The cryptographic signature bytes 86 + pub signature: Bytes, 87 + 88 + /// Additional fields for extensibility 89 + #[serde(flatten)] 90 + pub extra: HashMap<String, serde_json::Value>, 91 + } 92 + 93 + impl LexiconType for Signature { 94 + fn lexicon_type() -> &'static str { 95 + NSID 96 + } 97 + 98 + /// Type field is optional for signatures since they may appear without it 99 + fn type_required() -> bool { 100 + false 101 + } 102 + } 103 + 104 + /// Type alias for Signature with automatic $type field handling. 105 + /// 106 + /// This wrapper ensures proper serialization/deserialization of the 107 + /// `$type` field when present. 108 + pub type TypedSignature = TypedLexicon<Signature>; 109 + 110 + /// Helper function to create a typed signature. 111 + /// 112 + /// Creates a new signature with the TypedLexicon wrapper, ensuring 113 + /// proper `$type` field handling. 114 + /// 115 + /// # Arguments 116 + /// 117 + /// * `issuer` - DID of the signature issuer 118 + /// * `signature` - The signature bytes 119 + /// 120 + /// # Example 121 + /// 122 + /// ```ignore 123 + /// use atproto_record::lexicon::community::lexicon::attestation::create_typed_signature; 124 + /// use atproto_record::lexicon::Bytes; 125 + /// 126 + /// let sig = create_typed_signature( 127 + /// "did:plc:issuer".to_string(), 128 + /// Bytes { bytes: b"sig_data".to_vec() }, 129 + /// ); 130 + /// ``` 131 + pub fn create_typed_signature(issuer: String, signature: Bytes) -> TypedSignature { 132 + TypedLexicon::new(Signature { 133 + issuer, 134 + signature, 135 + extra: HashMap::new(), 136 + }) 137 + } 138 + 139 + #[cfg(test)] 140 + mod tests { 141 + use super::*; 142 + use crate::lexicon::com_atproto_repo::StrongRef; 143 + use serde_json::json; 144 + 145 + #[test] 146 + fn test_signature_deserialization() { 147 + let json_str = r#"{ 148 + "$type": "community.lexicon.attestation.signature", 149 + "issuer": "did:plc:test123", 150 + "signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="} 151 + }"#; 152 + 153 + let signature: Signature = serde_json::from_str(json_str).unwrap(); 154 + 155 + assert_eq!(signature.issuer, "did:plc:test123"); 156 + assert_eq!(signature.signature.bytes, b"test signature"); 157 + // The $type field will be captured in extra due to #[serde(flatten)] 158 + assert_eq!(signature.extra.len(), 1); 159 + assert!(signature.extra.contains_key("$type")); 160 + } 161 + 162 + #[test] 163 + fn test_signature_deserialization_with_extra_fields() { 164 + let json_str = r#"{ 165 + "$type": "community.lexicon.attestation.signature", 166 + "issuer": "did:plc:test123", 167 + "signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="}, 168 + "issuedAt": "2024-01-01T00:00:00.000Z", 169 + "purpose": "verification" 170 + }"#; 171 + 172 + let signature: Signature = serde_json::from_str(json_str).unwrap(); 173 + 174 + assert_eq!(signature.issuer, "did:plc:test123"); 175 + assert_eq!(signature.signature.bytes, b"test signature"); 176 + // 3 extra fields: $type, issuedAt, purpose 177 + assert_eq!(signature.extra.len(), 3); 178 + assert!(signature.extra.contains_key("$type")); 179 + assert_eq!( 180 + signature.extra.get("issuedAt").unwrap(), 181 + "2024-01-01T00:00:00.000Z" 182 + ); 183 + assert_eq!(signature.extra.get("purpose").unwrap(), "verification"); 184 + } 185 + 186 + #[test] 187 + fn test_signature_serialization() { 188 + let mut extra = HashMap::new(); 189 + extra.insert("custom_field".to_string(), json!("custom_value")); 190 + 191 + let signature = Signature { 192 + issuer: "did:plc:serializer".to_string(), 193 + signature: Bytes { 194 + bytes: b"hello world".to_vec(), 195 + }, 196 + extra, 197 + }; 198 + 199 + let json = serde_json::to_value(&signature).unwrap(); 200 + 201 + // Without custom Serialize impl, $type is not automatically added 202 + assert!(!json.as_object().unwrap().contains_key("$type")); 203 + assert_eq!(json["issuer"], "did:plc:serializer"); 204 + // "hello world" base64 encoded is "aGVsbG8gd29ybGQ=" 205 + assert_eq!(json["signature"]["$bytes"], "aGVsbG8gd29ybGQ="); 206 + assert_eq!(json["custom_field"], "custom_value"); 207 + } 208 + 209 + #[test] 210 + fn test_signature_round_trip() { 211 + let original = Signature { 212 + issuer: "did:plc:roundtrip".to_string(), 213 + signature: Bytes { 214 + bytes: b"round trip test".to_vec(), 215 + }, 216 + extra: HashMap::new(), 217 + }; 218 + 219 + // Serialize to JSON 220 + let json = serde_json::to_string(&original).unwrap(); 221 + 222 + // Deserialize back 223 + let deserialized: Signature = serde_json::from_str(&json).unwrap(); 224 + 225 + assert_eq!(original.issuer, deserialized.issuer); 226 + assert_eq!(original.signature.bytes, deserialized.signature.bytes); 227 + // Without the custom Serialize impl, no $type is added 228 + // so the round-trip preserves the empty extra map 229 + assert_eq!(deserialized.extra.len(), 0); 230 + } 231 + 232 + #[test] 233 + fn test_signature_with_complex_extra_fields() { 234 + let mut extra = HashMap::new(); 235 + extra.insert("timestamp".to_string(), json!(1234567890)); 236 + extra.insert( 237 + "metadata".to_string(), 238 + json!({ 239 + "version": "1.0", 240 + "algorithm": "ES256" 241 + }), 242 + ); 243 + extra.insert("tags".to_string(), json!(["tag1", "tag2", "tag3"])); 244 + 245 + let signature = Signature { 246 + issuer: "did:plc:complex".to_string(), 247 + signature: Bytes { 248 + bytes: vec![0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA], 249 + }, 250 + extra, 251 + }; 252 + 253 + let json = serde_json::to_value(&signature).unwrap(); 254 + 255 + // Without custom Serialize impl, $type is not automatically added 256 + assert!(!json.as_object().unwrap().contains_key("$type")); 257 + assert_eq!(json["issuer"], "did:plc:complex"); 258 + assert_eq!(json["timestamp"], 1234567890); 259 + assert_eq!(json["metadata"]["version"], "1.0"); 260 + assert_eq!(json["metadata"]["algorithm"], "ES256"); 261 + assert_eq!(json["tags"], json!(["tag1", "tag2", "tag3"])); 262 + } 263 + 264 + #[test] 265 + fn test_empty_signature() { 266 + let signature = Signature { 267 + issuer: String::new(), 268 + signature: Bytes { bytes: Vec::new() }, 269 + extra: HashMap::new(), 270 + }; 271 + 272 + let json = serde_json::to_value(&signature).unwrap(); 273 + 274 + // Without custom Serialize impl, $type is not automatically added 275 + assert!(!json.as_object().unwrap().contains_key("$type")); 276 + assert_eq!(json["issuer"], ""); 277 + assert_eq!(json["signature"]["$bytes"], ""); // Empty bytes encode to empty string 278 + } 279 + 280 + #[test] 281 + fn test_signatures_vec_serialization() { 282 + // Test with plain Vec<Signature> for basic signature serialization 283 + let signatures: Vec<Signature> = vec![ 284 + Signature { 285 + issuer: "did:plc:first".to_string(), 286 + signature: Bytes { 287 + bytes: b"first".to_vec(), 288 + }, 289 + extra: HashMap::new(), 290 + }, 291 + Signature { 292 + issuer: "did:plc:second".to_string(), 293 + signature: Bytes { 294 + bytes: b"second".to_vec(), 295 + }, 296 + extra: HashMap::new(), 297 + }, 298 + ]; 299 + 300 + let json = serde_json::to_value(&signatures).unwrap(); 301 + 302 + assert!(json.is_array()); 303 + assert_eq!(json.as_array().unwrap().len(), 2); 304 + assert_eq!(json[0]["issuer"], "did:plc:first"); 305 + assert_eq!(json[1]["issuer"], "did:plc:second"); 306 + } 307 + 308 + #[test] 309 + fn test_signatures_as_signature_or_ref_vec() { 310 + // Test the new Signatures type with inline signatures 311 + let signatures: Signatures = vec![ 312 + SignatureOrRef::Inline(create_typed_signature( 313 + "did:plc:first".to_string(), 314 + Bytes { 315 + bytes: b"first".to_vec(), 316 + }, 317 + )), 318 + SignatureOrRef::Inline(create_typed_signature( 319 + "did:plc:second".to_string(), 320 + Bytes { 321 + bytes: b"second".to_vec(), 322 + }, 323 + )), 324 + ]; 325 + 326 + let json = serde_json::to_value(&signatures).unwrap(); 327 + 328 + assert!(json.is_array()); 329 + assert_eq!(json.as_array().unwrap().len(), 2); 330 + assert_eq!(json[0]["$type"], "community.lexicon.attestation.signature"); 331 + assert_eq!(json[0]["issuer"], "did:plc:first"); 332 + assert_eq!(json[1]["$type"], "community.lexicon.attestation.signature"); 333 + assert_eq!(json[1]["issuer"], "did:plc:second"); 334 + } 335 + 336 + #[test] 337 + fn test_typed_signature_serialization() { 338 + let typed_sig = create_typed_signature( 339 + "did:plc:typed".to_string(), 340 + Bytes { 341 + bytes: b"typed signature".to_vec(), 342 + }, 343 + ); 344 + 345 + let json = serde_json::to_value(&typed_sig).unwrap(); 346 + 347 + assert_eq!(json["$type"], "community.lexicon.attestation.signature"); 348 + assert_eq!(json["issuer"], "did:plc:typed"); 349 + // "typed signature" base64 encoded 350 + assert_eq!(json["signature"]["$bytes"], "dHlwZWQgc2lnbmF0dXJl"); 351 + } 352 + 353 + #[test] 354 + fn test_typed_signature_deserialization() { 355 + let json = json!({ 356 + "$type": "community.lexicon.attestation.signature", 357 + "issuer": "did:plc:typed", 358 + "signature": {"$bytes": "dHlwZWQgc2lnbmF0dXJl"} 359 + }); 360 + 361 + let typed_sig: TypedSignature = serde_json::from_value(json).unwrap(); 362 + 363 + assert_eq!(typed_sig.inner.issuer, "did:plc:typed"); 364 + assert_eq!(typed_sig.inner.signature.bytes, b"typed signature"); 365 + assert!(typed_sig.has_type_field()); 366 + assert!(typed_sig.validate().is_ok()); 367 + } 368 + 369 + #[test] 370 + fn test_typed_signature_without_type_field() { 371 + let json = json!({ 372 + "issuer": "did:plc:notype", 373 + "signature": {"$bytes": "bm8gdHlwZQ=="} // "no type" in base64 374 + }); 375 + 376 + let typed_sig: TypedSignature = serde_json::from_value(json).unwrap(); 377 + 378 + assert_eq!(typed_sig.inner.issuer, "did:plc:notype"); 379 + assert_eq!(typed_sig.inner.signature.bytes, b"no type"); 380 + assert!(!typed_sig.has_type_field()); 381 + // Validation should still pass because type_required() returns false for Signature 382 + assert!(typed_sig.validate().is_ok()); 383 + } 384 + 385 + #[test] 386 + fn test_typed_signature_with_extra_fields() { 387 + let mut sig = Signature { 388 + issuer: "did:plc:extra".to_string(), 389 + signature: Bytes { 390 + bytes: b"extra test".to_vec(), 391 + }, 392 + extra: HashMap::new(), 393 + }; 394 + sig.extra 395 + .insert("customField".to_string(), json!("customValue")); 396 + sig.extra.insert("timestamp".to_string(), json!(1234567890)); 397 + 398 + let typed_sig = TypedLexicon::new(sig); 399 + 400 + let json = serde_json::to_value(&typed_sig).unwrap(); 401 + 402 + assert_eq!(json["$type"], "community.lexicon.attestation.signature"); 403 + assert_eq!(json["issuer"], "did:plc:extra"); 404 + assert_eq!(json["customField"], "customValue"); 405 + assert_eq!(json["timestamp"], 1234567890); 406 + } 407 + 408 + #[test] 409 + fn test_typed_signature_round_trip() { 410 + let original = Signature { 411 + issuer: "did:plc:roundtrip2".to_string(), 412 + signature: Bytes { 413 + bytes: b"round trip typed".to_vec(), 414 + }, 415 + extra: HashMap::new(), 416 + }; 417 + 418 + let typed = TypedLexicon::new(original.clone()); 419 + 420 + let json = serde_json::to_string(&typed).unwrap(); 421 + let deserialized: TypedSignature = serde_json::from_str(&json).unwrap(); 422 + 423 + assert_eq!(deserialized.inner.issuer, original.issuer); 424 + assert_eq!(deserialized.inner.signature.bytes, original.signature.bytes); 425 + assert!(deserialized.has_type_field()); 426 + } 427 + 428 + #[test] 429 + fn test_typed_signatures_vec() { 430 + let typed_sigs: Vec<TypedSignature> = vec![ 431 + create_typed_signature( 432 + "did:plc:first".to_string(), 433 + Bytes { 434 + bytes: b"first".to_vec(), 435 + }, 436 + ), 437 + create_typed_signature( 438 + "did:plc:second".to_string(), 439 + Bytes { 440 + bytes: b"second".to_vec(), 441 + }, 442 + ), 443 + ]; 444 + 445 + let json = serde_json::to_value(&typed_sigs).unwrap(); 446 + 447 + assert!(json.is_array()); 448 + assert_eq!(json[0]["$type"], "community.lexicon.attestation.signature"); 449 + assert_eq!(json[0]["issuer"], "did:plc:first"); 450 + assert_eq!(json[1]["$type"], "community.lexicon.attestation.signature"); 451 + assert_eq!(json[1]["issuer"], "did:plc:second"); 452 + } 453 + 454 + #[test] 455 + fn test_plain_vs_typed_signature() { 456 + // Plain Signature doesn't include $type field 457 + let plain_sig = Signature { 458 + issuer: "did:plc:plain".to_string(), 459 + signature: Bytes { 460 + bytes: b"plain sig".to_vec(), 461 + }, 462 + extra: HashMap::new(), 463 + }; 464 + 465 + let plain_json = serde_json::to_value(&plain_sig).unwrap(); 466 + assert!(!plain_json.as_object().unwrap().contains_key("$type")); 467 + 468 + // TypedSignature automatically includes $type field 469 + let typed_sig = TypedLexicon::new(plain_sig.clone()); 470 + let typed_json = serde_json::to_value(&typed_sig).unwrap(); 471 + assert_eq!( 472 + typed_json["$type"], 473 + "community.lexicon.attestation.signature" 474 + ); 475 + 476 + // Both have the same core data 477 + assert_eq!(plain_json["issuer"], typed_json["issuer"]); 478 + assert_eq!(plain_json["signature"], typed_json["signature"]); 479 + } 480 + 481 + #[test] 482 + fn test_signature_or_ref_inline() { 483 + // Test inline signature 484 + let inline_sig = create_typed_signature( 485 + "did:plc:inline".to_string(), 486 + Bytes { 487 + bytes: b"inline signature".to_vec(), 488 + }, 489 + ); 490 + 491 + let sig_or_ref = SignatureOrRef::Inline(inline_sig); 492 + 493 + // Serialize 494 + let json = serde_json::to_value(&sig_or_ref).unwrap(); 495 + assert_eq!(json["$type"], "community.lexicon.attestation.signature"); 496 + assert_eq!(json["issuer"], "did:plc:inline"); 497 + assert_eq!(json["signature"]["$bytes"], "aW5saW5lIHNpZ25hdHVyZQ=="); // "inline signature" in base64 498 + 499 + // Deserialize 500 + let deserialized: SignatureOrRef = serde_json::from_value(json.clone()).unwrap(); 501 + match deserialized { 502 + SignatureOrRef::Inline(sig) => { 503 + assert_eq!(sig.inner.issuer, "did:plc:inline"); 504 + assert_eq!(sig.inner.signature.bytes, b"inline signature"); 505 + } 506 + _ => panic!("Expected inline signature"), 507 + } 508 + } 509 + 510 + #[test] 511 + fn test_signature_or_ref_reference() { 512 + // Test reference to signature 513 + let strong_ref = StrongRef { 514 + uri: "at://did:plc:repo/community.lexicon.attestation.signature/abc123".to_string(), 515 + cid: "bafyreisigref123".to_string(), 516 + }; 517 + let typed_ref = TypedLexicon::new(strong_ref); 518 + 519 + let sig_or_ref = SignatureOrRef::Reference(typed_ref); 520 + 521 + // Serialize 522 + let json = serde_json::to_value(&sig_or_ref).unwrap(); 523 + assert_eq!(json["$type"], "com.atproto.repo.strongRef"); 524 + assert_eq!( 525 + json["uri"], 526 + "at://did:plc:repo/community.lexicon.attestation.signature/abc123" 527 + ); 528 + assert_eq!(json["cid"], "bafyreisigref123"); 529 + 530 + // Deserialize 531 + let deserialized: SignatureOrRef = serde_json::from_value(json.clone()).unwrap(); 532 + match deserialized { 533 + SignatureOrRef::Reference(ref_) => { 534 + assert_eq!( 535 + ref_.uri, 536 + "at://did:plc:repo/community.lexicon.attestation.signature/abc123" 537 + ); 538 + assert_eq!(ref_.cid, "bafyreisigref123"); 539 + } 540 + _ => panic!("Expected reference"), 541 + } 542 + } 543 + 544 + #[test] 545 + fn test_signatures_mixed_vector() { 546 + // Create a vector with both inline and referenced signatures 547 + let signatures: Signatures = vec![ 548 + // Inline signature 549 + SignatureOrRef::Inline(create_typed_signature( 550 + "did:plc:signer1".to_string(), 551 + Bytes { 552 + bytes: b"sig1".to_vec(), 553 + }, 554 + )), 555 + // Referenced signature 556 + SignatureOrRef::Reference(TypedLexicon::new(StrongRef { 557 + uri: "at://did:plc:repo/community.lexicon.attestation.signature/sig2".to_string(), 558 + cid: "bafyreisig2".to_string(), 559 + })), 560 + // Another inline signature 561 + SignatureOrRef::Inline(create_typed_signature( 562 + "did:plc:signer3".to_string(), 563 + Bytes { 564 + bytes: b"sig3".to_vec(), 565 + }, 566 + )), 567 + ]; 568 + 569 + // Serialize 570 + let json = serde_json::to_value(&signatures).unwrap(); 571 + assert!(json.is_array()); 572 + let array = json.as_array().unwrap(); 573 + assert_eq!(array.len(), 3); 574 + 575 + // First element should be inline signature 576 + assert_eq!(array[0]["$type"], "community.lexicon.attestation.signature"); 577 + assert_eq!(array[0]["issuer"], "did:plc:signer1"); 578 + 579 + // Second element should be reference 580 + assert_eq!(array[1]["$type"], "com.atproto.repo.strongRef"); 581 + assert_eq!( 582 + array[1]["uri"], 583 + "at://did:plc:repo/community.lexicon.attestation.signature/sig2" 584 + ); 585 + 586 + // Third element should be inline signature 587 + assert_eq!(array[2]["$type"], "community.lexicon.attestation.signature"); 588 + assert_eq!(array[2]["issuer"], "did:plc:signer3"); 589 + 590 + // Deserialize back 591 + let deserialized: Signatures = serde_json::from_value(json).unwrap(); 592 + assert_eq!(deserialized.len(), 3); 593 + 594 + // Verify each element 595 + match &deserialized[0] { 596 + SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.issuer, "did:plc:signer1"), 597 + _ => panic!("Expected inline signature at index 0"), 598 + } 599 + 600 + match &deserialized[1] { 601 + SignatureOrRef::Reference(ref_) => { 602 + assert_eq!( 603 + ref_.uri, 604 + "at://did:plc:repo/community.lexicon.attestation.signature/sig2" 605 + ) 606 + } 607 + _ => panic!("Expected reference at index 1"), 608 + } 609 + 610 + match &deserialized[2] { 611 + SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.issuer, "did:plc:signer3"), 612 + _ => panic!("Expected inline signature at index 2"), 613 + } 614 + } 615 + 616 + #[test] 617 + fn test_signature_or_ref_deserialization_from_json() { 618 + // Test deserializing from raw JSON strings 619 + 620 + // Inline signature JSON 621 + let inline_json = r#"{ 622 + "$type": "community.lexicon.attestation.signature", 623 + "issuer": "did:plc:testinline", 624 + "signature": {"$bytes": "aGVsbG8="} 625 + }"#; 626 + 627 + let inline_deser: SignatureOrRef = serde_json::from_str(inline_json).unwrap(); 628 + match inline_deser { 629 + SignatureOrRef::Inline(sig) => { 630 + assert_eq!(sig.inner.issuer, "did:plc:testinline"); 631 + assert_eq!(sig.inner.signature.bytes, b"hello"); 632 + } 633 + _ => panic!("Expected inline signature"), 634 + } 635 + 636 + // Reference JSON 637 + let ref_json = r#"{ 638 + "$type": "com.atproto.repo.strongRef", 639 + "uri": "at://did:plc:test/collection/record", 640 + "cid": "bafyreicid" 641 + }"#; 642 + 643 + let ref_deser: SignatureOrRef = serde_json::from_str(ref_json).unwrap(); 644 + match ref_deser { 645 + SignatureOrRef::Reference(ref_) => { 646 + assert_eq!(ref_.uri, "at://did:plc:test/collection/record"); 647 + assert_eq!(ref_.cid, "bafyreicid"); 648 + } 649 + _ => panic!("Expected reference"), 650 + } 651 + } 652 + }
+364
crates/atproto-record/src/lexicon/community_lexicon_badge.rs
···
··· 1 + //! Badge and award types for AT Protocol. 2 + //! 3 + //! This module provides types for badge definitions and awards in the AT Protocol. 4 + //! Badges can be defined with names, descriptions, and optional images, then awarded 5 + //! to users with cryptographic signatures for verification. 6 + 7 + use std::collections::HashMap; 8 + 9 + use chrono::{DateTime, Utc}; 10 + use serde::{Deserialize, Serialize}; 11 + 12 + use crate::lexicon::{ 13 + TypedBlob, com_atproto_repo::TypedStrongRef, community_lexicon_attestation::Signatures, 14 + }; 15 + use crate::typed::{LexiconType, TypedLexicon}; 16 + 17 + /// The namespace identifier for badge definitions 18 + pub const DEFINITION_NSID: &str = "community.lexicon.badge.definition"; 19 + /// The namespace identifier for badge awards 20 + pub const AWARD_NSID: &str = "community.lexicon.badge.award"; 21 + 22 + /// Badge definition structure. 23 + /// 24 + /// Defines a badge that can be awarded to users. Badges have a name, 25 + /// description, and optional visual representation. 26 + /// 27 + /// # Example 28 + /// 29 + /// ```ignore 30 + /// use atproto_record::lexicon::community::lexicon::badge::{Definition, TypedDefinition}; 31 + /// use std::collections::HashMap; 32 + /// 33 + /// let badge = Definition { 34 + /// name: "Early Adopter".to_string(), 35 + /// description: "Joined the platform in the first month".to_string(), 36 + /// image: None, 37 + /// extra: HashMap::new(), 38 + /// }; 39 + /// 40 + /// let typed_badge = TypedDefinition::new(badge); 41 + /// ``` 42 + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] 43 + pub struct Definition { 44 + /// Name of the badge 45 + pub name: String, 46 + /// Description of what the badge represents 47 + pub description: String, 48 + 49 + /// Optional badge image 50 + #[serde(skip_serializing_if = "Option::is_none")] 51 + pub image: Option<TypedBlob>, 52 + 53 + /// Extension fields for forward compatibility 54 + #[serde(flatten)] 55 + pub extra: HashMap<String, serde_json::Value>, 56 + } 57 + 58 + impl LexiconType for Definition { 59 + fn lexicon_type() -> &'static str { 60 + DEFINITION_NSID 61 + } 62 + } 63 + 64 + /// Type alias for Definition with automatic $type field handling 65 + pub type TypedDefinition = TypedLexicon<Definition>; 66 + 67 + /// Badge award structure. 68 + /// 69 + /// Represents the awarding of a badge to a specific user (identified by DID). 70 + /// Awards include the badge reference, recipient, issue date, and optional 71 + /// signatures for verification. 72 + /// 73 + /// # Example 74 + /// 75 + /// ```ignore 76 + /// use atproto_record::lexicon::community::lexicon::badge::{Award, TypedAward}; 77 + /// use atproto_record::lexicon::com::atproto::repo::{StrongRef, TypedStrongRef}; 78 + /// use chrono::Utc; 79 + /// use std::collections::HashMap; 80 + /// 81 + /// let award = Award { 82 + /// badge: TypedStrongRef::new(StrongRef { 83 + /// uri: "at://did:plc:issuer/community.lexicon.badge.definition/badge123".to_string(), 84 + /// cid: "bafyreicid123".to_string(), 85 + /// }), 86 + /// did: "did:plc:recipient".to_string(), 87 + /// issued: Utc::now(), 88 + /// signatures: vec![], 89 + /// extra: HashMap::new(), 90 + /// }; 91 + /// 92 + /// let typed_award = TypedAward::new(award); 93 + /// ``` 94 + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] 95 + pub struct Award { 96 + /// Reference to the badge definition being awarded 97 + pub badge: TypedStrongRef, 98 + /// DID of the recipient 99 + pub did: String, 100 + /// When the badge was awarded 101 + pub issued: DateTime<Utc>, 102 + 103 + /// Optional signatures for verification 104 + #[serde(skip_serializing_if = "Vec::is_empty", default)] 105 + pub signatures: Signatures, 106 + 107 + /// Extension fields for forward compatibility 108 + #[serde(flatten)] 109 + pub extra: HashMap<String, serde_json::Value>, 110 + } 111 + 112 + impl LexiconType for Award { 113 + fn lexicon_type() -> &'static str { 114 + AWARD_NSID 115 + } 116 + } 117 + 118 + /// Type alias for Award with automatic $type field handling 119 + pub type TypedAward = TypedLexicon<Award>; 120 + 121 + #[cfg(test)] 122 + mod tests { 123 + use crate::lexicon::com_atproto_repo::StrongRef; 124 + use crate::lexicon::{Blob, Link}; 125 + 126 + use super::*; 127 + use anyhow::Result; 128 + 129 + #[test] 130 + fn test_deserialize_badge_definition() -> Result<()> { 131 + let json = r#"{ 132 + "name": "Bug Squasher", 133 + "$type": "community.lexicon.badge.definition", 134 + "image": { 135 + "$type": "blob", 136 + "ref": { 137 + "$link": "bafkreigb3vxlgckp66o2bffnwapagshpt2xdcgdltuzjymapobvgunxmfi" 138 + }, 139 + "mimeType": "image/png", 140 + "size": 177111 141 + }, 142 + "description": "You've helped squash Smoke Signal bugs." 143 + }"#; 144 + 145 + let typed_def: TypedDefinition = serde_json::from_str(json)?; 146 + let definition = typed_def.inner; 147 + 148 + assert_eq!(definition.name, "Bug Squasher"); 149 + assert_eq!( 150 + definition.description, 151 + "You've helped squash Smoke Signal bugs." 152 + ); 153 + 154 + assert!(definition.image.is_some()); 155 + if let Some(typed_blob) = definition.image { 156 + // The blob is now wrapped in TypedBlob, so we access via .inner 157 + let img = typed_blob.inner; 158 + assert_eq!(img.mime_type, "image/png"); 159 + assert_eq!(img.size, 177111); 160 + assert_eq!( 161 + img.ref_.link, 162 + "bafkreigb3vxlgckp66o2bffnwapagshpt2xdcgdltuzjymapobvgunxmfi" 163 + ); 164 + } 165 + 166 + Ok(()) 167 + } 168 + 169 + #[test] 170 + fn test_deserialize_badge_definition_without_image() -> Result<()> { 171 + let json = r#"{ 172 + "name": "Text Badge", 173 + "$type": "community.lexicon.badge.definition", 174 + "description": "A badge without an image." 175 + }"#; 176 + 177 + let typed_def: TypedDefinition = serde_json::from_str(json)?; 178 + let definition = typed_def.inner; 179 + 180 + assert_eq!(definition.name, "Text Badge"); 181 + assert_eq!(definition.description, "A badge without an image."); 182 + assert!(definition.image.is_none()); 183 + 184 + Ok(()) 185 + } 186 + 187 + #[test] 188 + fn test_serialize_badge_definition() -> Result<()> { 189 + let definition = Definition { 190 + name: "Test Badge".to_string(), 191 + description: "A test badge".to_string(), 192 + image: Some(TypedLexicon::new(Blob { 193 + ref_: Link { 194 + link: "bafkreitest123".to_string(), 195 + }, 196 + mime_type: "image/png".to_string(), 197 + size: 12345, 198 + })), 199 + extra: HashMap::new(), 200 + }; 201 + let typed_def = TypedLexicon::new(definition); 202 + 203 + let json = serde_json::to_string_pretty(&typed_def)?; 204 + 205 + // Verify it contains the expected fields 206 + assert!(json.contains("\"$type\": \"community.lexicon.badge.definition\"")); 207 + assert!(json.contains("\"name\": \"Test Badge\"")); 208 + assert!(json.contains("\"description\": \"A test badge\"")); 209 + assert!(json.contains("\"$link\": \"bafkreitest123\"")); 210 + assert!(json.contains("\"mimeType\": \"image/png\"")); 211 + assert!(json.contains("\"size\": 12345")); 212 + 213 + Ok(()) 214 + } 215 + 216 + #[test] 217 + fn test_deserialize_badge_award() -> Result<()> { 218 + let json = r#"{ 219 + "$type": "community.lexicon.badge.award", 220 + "badge": { 221 + "$type": "com.atproto.repo.strongRef", 222 + "cid": "bafyreiansi5jdnsam57ouyzk7zf5vatvzo7narb5o5z3zm7e5lhd4iw5d4", 223 + "uri": "at://did:plc:tgudj2fjm77pzkuawquqhsxm/community.lexicon.badge.definition/3lqt67gc2i32c" 224 + }, 225 + "did": "did:plc:cbkjy5n7bk3ax2wplmtjofq2", 226 + "issued": "2025-06-08T22:10:55.000Z", 227 + "signatures": [] 228 + }"#; 229 + 230 + let typed_award: TypedAward = serde_json::from_str(json)?; 231 + let award = typed_award.inner; 232 + 233 + assert_eq!(award.did, "did:plc:cbkjy5n7bk3ax2wplmtjofq2"); 234 + assert_eq!(award.issued.to_rfc3339(), "2025-06-08T22:10:55+00:00"); 235 + assert!(award.signatures.is_empty()); 236 + 237 + // badge is a TypedStrongRef, so we access the inner StrongRef 238 + let badge_ref = &award.badge.inner; 239 + assert_eq!( 240 + badge_ref.cid, 241 + "bafyreiansi5jdnsam57ouyzk7zf5vatvzo7narb5o5z3zm7e5lhd4iw5d4" 242 + ); 243 + assert_eq!( 244 + badge_ref.uri, 245 + "at://did:plc:tgudj2fjm77pzkuawquqhsxm/community.lexicon.badge.definition/3lqt67gc2i32c" 246 + ); 247 + 248 + Ok(()) 249 + } 250 + 251 + #[test] 252 + fn test_serialize_badge_award() -> Result<()> { 253 + use chrono::TimeZone; 254 + 255 + let badge_ref = StrongRef { 256 + uri: "at://did:plc:test/community.lexicon.badge.definition/abc123".to_string(), 257 + cid: "bafyreicidtest123".to_string(), 258 + }; 259 + let award = Award { 260 + badge: TypedLexicon::new(badge_ref), 261 + did: "did:plc:recipient123".to_string(), 262 + issued: Utc.with_ymd_and_hms(2025, 6, 8, 22, 10, 55).unwrap(), 263 + signatures: vec![], 264 + extra: HashMap::new(), 265 + }; 266 + let typed_award = TypedLexicon::new(award); 267 + 268 + let json = serde_json::to_string_pretty(&typed_award)?; 269 + 270 + // Verify it contains the expected fields 271 + assert!(json.contains("\"$type\": \"community.lexicon.badge.award\"")); 272 + assert!(json.contains("\"did\": \"did:plc:recipient123\"")); 273 + assert!(json.contains("\"issued\": \"2025-06-08T22:10:55Z\"")); 274 + assert!(json.contains("\"$type\": \"com.atproto.repo.strongRef\"")); 275 + // Empty signatures array is skipped in serialization due to skip_serializing_if 276 + assert!(!json.contains("\"signatures\"")); 277 + 278 + Ok(()) 279 + } 280 + 281 + #[test] 282 + fn test_badge_award_with_signatures() -> Result<()> { 283 + let json = r#"{ 284 + "$type": "community.lexicon.badge.award", 285 + "badge": { 286 + "$type": "com.atproto.repo.strongRef", 287 + "cid": "bafyreicid123", 288 + "uri": "at://did:plc:issuer/community.lexicon.badge.definition/badge123" 289 + }, 290 + "did": "did:plc:recipient", 291 + "issued": "2025-06-08T12:00:00.000Z", 292 + "signatures": [ 293 + { 294 + "$type": "community.lexicon.attestation.signature", 295 + "issuer": "did:plc:issuer", 296 + "signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="} 297 + } 298 + ] 299 + }"#; 300 + 301 + let typed_award: TypedAward = serde_json::from_str(json)?; 302 + let award = typed_award.inner; 303 + 304 + assert_eq!(award.did, "did:plc:recipient"); 305 + assert_eq!(award.signatures.len(), 1); 306 + 307 + match award.signatures.first() { 308 + Some(sig_or_ref) => { 309 + // The signature should be inline in this test 310 + match sig_or_ref { 311 + crate::lexicon::community_lexicon_attestation::SignatureOrRef::Inline(sig) => { 312 + assert_eq!(sig.issuer, "did:plc:issuer"); 313 + // The bytes should match the decoded base64 value 314 + // "dGVzdCBzaWduYXR1cmU=" decodes to "test signature" 315 + assert_eq!(sig.signature.bytes, b"test signature".to_vec()); 316 + } 317 + _ => panic!("Expected inline signature"), 318 + } 319 + } 320 + None => assert!(false), 321 + } 322 + 323 + Ok(()) 324 + } 325 + 326 + #[test] 327 + fn test_typed_patterns() -> Result<()> { 328 + // Test that typed patterns automatically handle $type fields 329 + 330 + // StrongRef without explicit $type field 331 + let strong_ref = StrongRef { 332 + uri: "at://example".to_string(), 333 + cid: "bafytest".to_string(), 334 + }; 335 + let typed_ref = TypedLexicon::new(strong_ref); 336 + let json = serde_json::to_value(&typed_ref)?; 337 + assert_eq!(json["$type"], "com.atproto.repo.strongRef"); 338 + 339 + // Definition without explicit $type field 340 + let definition = Definition { 341 + name: "Test".to_string(), 342 + description: "Test desc".to_string(), 343 + image: None, 344 + extra: HashMap::new(), 345 + }; 346 + let typed_def = TypedLexicon::new(definition); 347 + let json = serde_json::to_value(&typed_def)?; 348 + assert_eq!(json["$type"], "community.lexicon.badge.definition"); 349 + 350 + // Award without explicit $type field 351 + let award = Award { 352 + badge: typed_ref, 353 + did: "did:plc:test".to_string(), 354 + issued: Utc::now(), 355 + signatures: vec![], 356 + extra: HashMap::new(), 357 + }; 358 + let typed_award = TypedLexicon::new(award); 359 + let json = serde_json::to_value(&typed_award)?; 360 + assert_eq!(json["$type"], "community.lexicon.badge.award"); 361 + 362 + Ok(()) 363 + } 364 + }
+487
crates/atproto-record/src/lexicon/community_lexicon_calendar_event.rs
···
··· 1 + //! Calendar event types for AT Protocol. 2 + //! 3 + //! This module provides types for representing calendar events with support 4 + //! for various event properties including status, mode (in-person/virtual/hybrid), 5 + //! locations, media, and links. 6 + 7 + use chrono::{DateTime, Utc}; 8 + use serde::{Deserialize, Serialize}; 9 + use std::collections::HashMap; 10 + 11 + use crate::datetime::format as datetime_format; 12 + use crate::datetime::optional_format as optional_datetime_format; 13 + use crate::lexicon::community::lexicon::location::Locations; 14 + use crate::lexicon::{Blob, TypedBlob}; 15 + use crate::typed::{LexiconType, TypedLexicon}; 16 + 17 + /// The namespace identifier for events 18 + pub const NSID: &str = "community.lexicon.calendar.event"; 19 + 20 + /// Event status enumeration. 21 + /// 22 + /// Represents the current status of a calendar event. 23 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default)] 24 + pub enum Status { 25 + /// Event is scheduled and confirmed 26 + #[default] 27 + #[serde(rename = "community.lexicon.calendar.event#scheduled")] 28 + Scheduled, 29 + 30 + /// Event has been rescheduled to a new time 31 + #[serde(rename = "community.lexicon.calendar.event#rescheduled")] 32 + Rescheduled, 33 + 34 + /// Event has been cancelled 35 + #[serde(rename = "community.lexicon.calendar.event#cancelled")] 36 + Cancelled, 37 + 38 + /// Event has been postponed (new date TBD) 39 + #[serde(rename = "community.lexicon.calendar.event#postponed")] 40 + Postponed, 41 + 42 + /// Event is being planned but not yet confirmed 43 + #[serde(rename = "community.lexicon.calendar.event#planned")] 44 + Planned, 45 + } 46 + 47 + /// Event mode enumeration. 48 + /// 49 + /// Represents how attendees can participate in the event. 50 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default)] 51 + pub enum Mode { 52 + /// In-person attendance only 53 + #[default] 54 + #[serde(rename = "community.lexicon.calendar.event#inperson")] 55 + InPerson, 56 + 57 + /// Virtual/online attendance only 58 + #[serde(rename = "community.lexicon.calendar.event#virtual")] 59 + Virtual, 60 + 61 + /// Both in-person and virtual attendance options 62 + #[serde(rename = "community.lexicon.calendar.event#hybrid")] 63 + Hybrid, 64 + } 65 + 66 + /// The namespace identifier for named URIs 67 + pub const NAMED_URI_NSID: &str = "community.lexicon.calendar.event#uri"; 68 + 69 + /// Named URI structure. 70 + /// 71 + /// Represents a URI with an optional human-readable name. 72 + /// Used for linking to external resources related to an event. 73 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 74 + pub struct NamedUri { 75 + /// The URI/URL 76 + pub uri: String, 77 + 78 + /// Optional human-readable name for the link 79 + #[serde(skip_serializing_if = "Option::is_none", default)] 80 + pub name: Option<String>, 81 + } 82 + 83 + impl LexiconType for NamedUri { 84 + fn lexicon_type() -> &'static str { 85 + NAMED_URI_NSID 86 + } 87 + } 88 + 89 + /// Type alias for NamedUri with automatic $type field handling 90 + pub type TypedNamedUri = TypedLexicon<NamedUri>; 91 + 92 + /// The namespace identifier for event links 93 + pub const EVENT_LINK_NSID: &str = "community.lexicon.calendar.event#uri"; 94 + 95 + /// Event link structure. 96 + /// 97 + /// Similar to NamedUri but kept as a separate type for semantic clarity 98 + /// and type safety when dealing with event-specific links. 99 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 100 + pub struct EventLink { 101 + /// The URI/URL for the event link 102 + pub uri: String, 103 + 104 + /// Optional human-readable name for the link 105 + #[serde(skip_serializing_if = "Option::is_none", default)] 106 + pub name: Option<String>, 107 + } 108 + 109 + impl LexiconType for EventLink { 110 + fn lexicon_type() -> &'static str { 111 + EVENT_LINK_NSID 112 + } 113 + } 114 + 115 + /// Type alias for EventLink with automatic $type field handling 116 + pub type TypedEventLink = TypedLexicon<EventLink>; 117 + 118 + /// A vector of typed event links 119 + pub type EventLinks = Vec<TypedEventLink>; 120 + 121 + /// Aspect ratio for media content. 122 + /// 123 + /// Represents the width-to-height ratio of visual media. 124 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 125 + pub struct AspectRatio { 126 + /// Width component of the ratio 127 + pub width: u64, 128 + /// Height component of the ratio 129 + pub height: u64, 130 + } 131 + 132 + /// The namespace identifier for media 133 + pub const MEDIA_NSID: &str = "community.lexicon.calendar.event#media"; 134 + 135 + /// Media structure for event-related visual content. 136 + /// 137 + /// Represents images, videos, or other media associated with an event. 138 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 139 + pub struct Media { 140 + /// The media content as a blob reference 141 + pub content: TypedBlob, 142 + 143 + /// Alternative text description for accessibility 144 + pub alt: String, 145 + 146 + /// The role/purpose of this media (e.g., "banner", "poster", "thumbnail") 147 + pub role: String, 148 + 149 + /// Optional aspect ratio information 150 + #[serde(skip_serializing_if = "Option::is_none", default)] 151 + pub aspect_ratio: Option<AspectRatio>, 152 + } 153 + 154 + impl LexiconType for Media { 155 + fn lexicon_type() -> &'static str { 156 + MEDIA_NSID 157 + } 158 + } 159 + 160 + /// Type alias for Media with automatic $type field handling 161 + pub type TypedMedia = TypedLexicon<Media>; 162 + 163 + /// A vector of typed media items 164 + pub type MediaList = Vec<TypedMedia>; 165 + 166 + /// Calendar event structure. 167 + /// 168 + /// Represents a calendar event with comprehensive metadata including 169 + /// timing, location, media, and status information. 170 + /// 171 + /// # Example 172 + /// 173 + /// ```ignore 174 + /// use atproto_record::lexicon::community::lexicon::calendar::event::{Event, TypedEvent, Status, Mode}; 175 + /// use chrono::Utc; 176 + /// use std::collections::HashMap; 177 + /// 178 + /// let event = Event { 179 + /// name: "Community Meetup".to_string(), 180 + /// description: "Monthly community gathering".to_string(), 181 + /// created_at: Utc::now(), 182 + /// starts_at: Some(Utc::now()), 183 + /// ends_at: None, 184 + /// mode: Some(Mode::Hybrid), 185 + /// status: Some(Status::Scheduled), 186 + /// locations: vec![], 187 + /// uris: vec![], 188 + /// media: vec![], 189 + /// extra: HashMap::new(), 190 + /// }; 191 + /// 192 + /// let typed_event = TypedEvent::new(event); 193 + /// ``` 194 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 195 + pub struct Event { 196 + /// Name/title of the event 197 + pub name: String, 198 + 199 + /// Description of the event 200 + pub description: String, 201 + 202 + /// When the event record was created 203 + #[serde(rename = "createdAt", with = "datetime_format")] 204 + pub created_at: DateTime<Utc>, 205 + 206 + /// When the event starts (optional) 207 + #[serde( 208 + rename = "startsAt", 209 + skip_serializing_if = "Option::is_none", 210 + default, 211 + with = "optional_datetime_format" 212 + )] 213 + pub starts_at: Option<DateTime<Utc>>, 214 + 215 + /// When the event ends (optional) 216 + #[serde( 217 + rename = "endsAt", 218 + skip_serializing_if = "Option::is_none", 219 + default, 220 + with = "optional_datetime_format" 221 + )] 222 + pub ends_at: Option<DateTime<Utc>>, 223 + 224 + /// Event mode (in-person, virtual, hybrid) 225 + #[serde(rename = "mode", skip_serializing_if = "Option::is_none", default)] 226 + pub mode: Option<Mode>, 227 + 228 + /// Event status (scheduled, cancelled, etc.) 229 + #[serde(rename = "status", skip_serializing_if = "Option::is_none", default)] 230 + pub status: Option<Status>, 231 + 232 + /// Event locations (can be inline or referenced) 233 + #[serde(skip_serializing_if = "Vec::is_empty", default)] 234 + pub locations: Locations, 235 + 236 + /// Related URIs/links for the event 237 + #[serde(skip_serializing_if = "Vec::is_empty", default)] 238 + pub uris: EventLinks, 239 + 240 + /// Media associated with the event 241 + #[serde(skip_serializing_if = "Vec::is_empty", default)] 242 + pub media: MediaList, 243 + 244 + /// Extension fields for forward compatibility. 245 + /// This catch-all allows unknown fields to be preserved and indexed 246 + /// for potential future use without requiring re-indexing. 247 + #[serde(flatten)] 248 + pub extra: HashMap<String, serde_json::Value>, 249 + } 250 + 251 + impl LexiconType for Event { 252 + fn lexicon_type() -> &'static str { 253 + NSID 254 + } 255 + } 256 + 257 + /// Type alias for Event with automatic $type field handling. 258 + /// 259 + /// This wrapper ensures proper serialization/deserialization of the 260 + /// `$type` field for event records. 261 + pub type TypedEvent = TypedLexicon<Event>; 262 + 263 + #[cfg(test)] 264 + mod tests { 265 + use super::*; 266 + use anyhow::Result; 267 + 268 + #[test] 269 + fn test_typed_named_uri() -> Result<()> { 270 + let test_json = r#"{"$type":"community.lexicon.calendar.event#uri","uri":"https://smokesignal.events/","name":"Smoke Signal"}"#; 271 + 272 + // Serialize bare NamedUri 273 + let named_uri = NamedUri { 274 + uri: "https://smokesignal.events/".to_string(), 275 + name: Some("Smoke Signal".to_string()), 276 + }; 277 + let typed_uri = TypedLexicon::new(named_uri); 278 + let serialized = serde_json::to_value(&typed_uri)?; 279 + let expected: serde_json::Value = serde_json::from_str(test_json)?; 280 + assert_eq!(serialized, expected); 281 + 282 + // Deserialize bare NamedUri 283 + let deserialized: TypedNamedUri = serde_json::from_str(test_json).unwrap(); 284 + assert_eq!(deserialized.inner.uri, "https://smokesignal.events/"); 285 + assert_eq!(deserialized.inner.name, Some("Smoke Signal".to_string())); 286 + 287 + Ok(()) 288 + } 289 + 290 + #[test] 291 + fn test_typed_event() -> Result<()> { 292 + use chrono::TimeZone; 293 + 294 + // Create an Event without explicit $type field 295 + let event = Event { 296 + name: "Test Event".to_string(), 297 + description: "A test event".to_string(), 298 + created_at: Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap(), 299 + starts_at: Some(Utc.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap()), 300 + ends_at: Some(Utc.with_ymd_and_hms(2025, 1, 15, 16, 0, 0).unwrap()), 301 + mode: Some(Mode::Hybrid), 302 + status: Some(Status::Scheduled), 303 + locations: vec![], 304 + uris: vec![], 305 + media: vec![], 306 + extra: HashMap::new(), 307 + }; 308 + 309 + // Wrap it in TypedEvent 310 + let typed_event = TypedLexicon::new(event.clone()); 311 + 312 + // Serialize and verify $type is added 313 + let json = serde_json::to_value(&typed_event)?; 314 + assert_eq!(json["$type"], "community.lexicon.calendar.event"); 315 + assert_eq!(json["name"], "Test Event"); 316 + assert_eq!(json["description"], "A test event"); 317 + 318 + // Deserialize with $type field 319 + let json_str = r#"{ 320 + "$type": "community.lexicon.calendar.event", 321 + "name": "Deserialized Event", 322 + "description": "Event from JSON", 323 + "createdAt": "2025-01-01T12:00:00Z", 324 + "startsAt": "2025-01-15T14:00:00Z", 325 + "endsAt": "2025-01-15T16:00:00Z", 326 + "mode": "community.lexicon.calendar.event#hybrid", 327 + "status": "community.lexicon.calendar.event#scheduled" 328 + }"#; 329 + 330 + let deserialized: TypedEvent = serde_json::from_str(json_str)?; 331 + assert_eq!(deserialized.inner.name, "Deserialized Event"); 332 + assert_eq!(deserialized.inner.description, "Event from JSON"); 333 + assert!(deserialized.has_type_field()); 334 + 335 + Ok(()) 336 + } 337 + 338 + #[test] 339 + fn test_typed_event_link() -> Result<()> { 340 + // Create an EventLink without explicit $type field 341 + let event_link = EventLink { 342 + uri: "https://example.com/event".to_string(), 343 + name: Some("Example Event".to_string()), 344 + }; 345 + 346 + // Wrap it in TypedEventLink 347 + let typed_link = TypedLexicon::new(event_link); 348 + 349 + // Serialize and verify $type is added 350 + let json = serde_json::to_value(&typed_link)?; 351 + assert_eq!(json["$type"], "community.lexicon.calendar.event#uri"); 352 + assert_eq!(json["uri"], "https://example.com/event"); 353 + assert_eq!(json["name"], "Example Event"); 354 + 355 + // Deserialize with $type field 356 + let json_str = r#"{ 357 + "$type": "community.lexicon.calendar.event#uri", 358 + "uri": "https://test.com", 359 + "name": "Test Link" 360 + }"#; 361 + 362 + let deserialized: TypedEventLink = serde_json::from_str(json_str)?; 363 + assert_eq!(deserialized.inner.uri, "https://test.com"); 364 + assert_eq!(deserialized.inner.name, Some("Test Link".to_string())); 365 + assert!(deserialized.has_type_field()); 366 + 367 + Ok(()) 368 + } 369 + 370 + #[test] 371 + fn test_typed_media() -> Result<()> { 372 + // Create a Media without explicit $type field 373 + let media = Media { 374 + content: TypedLexicon::new(Blob { 375 + ref_: crate::lexicon::Link { 376 + link: "bafkreiblob123".to_string(), 377 + }, 378 + mime_type: "image/jpeg".to_string(), 379 + size: 12345, 380 + }), 381 + alt: "Test image".to_string(), 382 + role: "banner".to_string(), 383 + aspect_ratio: Some(AspectRatio { 384 + width: 1920, 385 + height: 1080, 386 + }), 387 + }; 388 + 389 + // Wrap it in TypedMedia 390 + let typed_media = TypedLexicon::new(media); 391 + 392 + // Serialize and verify $type is added 393 + let json = serde_json::to_value(&typed_media)?; 394 + assert_eq!(json["$type"], "community.lexicon.calendar.event#media"); 395 + assert_eq!(json["alt"], "Test image"); 396 + assert_eq!(json["role"], "banner"); 397 + assert_eq!(json["content"]["$type"], "blob"); 398 + assert_eq!(json["aspect_ratio"]["width"], 1920); 399 + assert_eq!(json["aspect_ratio"]["height"], 1080); 400 + 401 + // Deserialize with $type field 402 + let json_str = r#"{ 403 + "$type": "community.lexicon.calendar.event#media", 404 + "content": { 405 + "$type": "blob", 406 + "ref": { 407 + "$link": "bafkreitest456" 408 + }, 409 + "mimeType": "image/png", 410 + "size": 54321 411 + }, 412 + "alt": "Another test", 413 + "role": "thumbnail" 414 + }"#; 415 + 416 + let deserialized: TypedMedia = serde_json::from_str(json_str)?; 417 + assert_eq!(deserialized.inner.alt, "Another test"); 418 + assert_eq!(deserialized.inner.role, "thumbnail"); 419 + assert_eq!(deserialized.inner.content.inner.mime_type, "image/png"); 420 + assert!(deserialized.inner.aspect_ratio.is_none()); 421 + assert!(deserialized.has_type_field()); 422 + 423 + Ok(()) 424 + } 425 + 426 + #[test] 427 + fn test_event_with_typed_fields() -> Result<()> { 428 + use chrono::TimeZone; 429 + 430 + // Create an Event with typed fields 431 + let event_link = EventLink { 432 + uri: "https://event.com".to_string(), 433 + name: Some("Event Website".to_string()), 434 + }; 435 + 436 + let media = Media { 437 + content: TypedLexicon::new(Blob { 438 + ref_: crate::lexicon::Link { 439 + link: "bafkreimedia".to_string(), 440 + }, 441 + mime_type: "image/jpeg".to_string(), 442 + size: 99999, 443 + }), 444 + alt: "Event poster".to_string(), 445 + role: "poster".to_string(), 446 + aspect_ratio: None, 447 + }; 448 + 449 + let event = Event { 450 + name: "Complex Event".to_string(), 451 + description: "Event with typed fields".to_string(), 452 + created_at: Utc.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap(), 453 + starts_at: None, 454 + ends_at: None, 455 + mode: None, 456 + status: None, 457 + locations: vec![], 458 + uris: vec![TypedLexicon::new(event_link)], 459 + media: vec![TypedLexicon::new(media)], 460 + extra: HashMap::new(), 461 + }; 462 + 463 + // Wrap it in TypedEvent 464 + let typed_event = TypedLexicon::new(event); 465 + 466 + // Serialize and verify nested types have their $type fields 467 + let json = serde_json::to_value(&typed_event)?; 468 + assert_eq!(json["$type"], "community.lexicon.calendar.event"); 469 + assert_eq!(json["name"], "Complex Event"); 470 + 471 + // Check nested EventLink has $type 472 + assert_eq!( 473 + json["uris"][0]["$type"], 474 + "community.lexicon.calendar.event#uri" 475 + ); 476 + assert_eq!(json["uris"][0]["uri"], "https://event.com"); 477 + 478 + // Check nested Media has $type 479 + assert_eq!( 480 + json["media"][0]["$type"], 481 + "community.lexicon.calendar.event#media" 482 + ); 483 + assert_eq!(json["media"][0]["alt"], "Event poster"); 484 + 485 + Ok(()) 486 + } 487 + }
+303
crates/atproto-record/src/lexicon/community_lexicon_calendar_rsvp.rs
···
··· 1 + //! RSVP types for calendar events in AT Protocol. 2 + //! 3 + //! This module provides types for representing RSVPs (responses) to calendar 4 + //! events, including status indicators and signature support for verification. 5 + 6 + use chrono::{DateTime, Utc}; 7 + use serde::{Deserialize, Serialize}; 8 + use std::collections::HashMap; 9 + 10 + use crate::datetime::format as datetime_format; 11 + use crate::lexicon::com::atproto::repo::StrongRef; 12 + use crate::lexicon::community_lexicon_attestation::Signatures; 13 + use crate::typed::{LexiconType, TypedLexicon}; 14 + 15 + /// The namespace identifier for RSVPs 16 + pub const NSID: &str = "community.lexicon.calendar.rsvp"; 17 + 18 + /// RSVP status enumeration. 19 + /// 20 + /// Represents the response status for an event invitation. 21 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Default)] 22 + pub enum RsvpStatus { 23 + /// Attendee is planning to attend 24 + #[default] 25 + #[serde(rename = "community.lexicon.calendar.rsvp#going")] 26 + Going, 27 + 28 + /// Attendee is interested but not confirmed 29 + #[serde(rename = "community.lexicon.calendar.rsvp#interested")] 30 + Interested, 31 + 32 + /// Attendee is not planning to attend 33 + #[serde(rename = "community.lexicon.calendar.rsvp#notgoing")] 34 + NotGoing, 35 + } 36 + 37 + /// RSVP record structure. 38 + /// 39 + /// Represents a user's response to an event invitation, including their 40 + /// attendance status and optional signatures for verification. 41 + /// 42 + /// # Example 43 + /// 44 + /// ```ignore 45 + /// use atproto_record::lexicon::community::lexicon::calendar::rsvp::{Rsvp, TypedRsvp, RsvpStatus}; 46 + /// use atproto_record::lexicon::com::atproto::repo::StrongRef; 47 + /// use chrono::Utc; 48 + /// use std::collections::HashMap; 49 + /// 50 + /// let rsvp = Rsvp { 51 + /// subject: StrongRef { 52 + /// uri: "at://did:plc:example/community.lexicon.calendar.event/abc123".to_string(), 53 + /// cid: "bafyreicid123".to_string(), 54 + /// }, 55 + /// status: RsvpStatus::Going, 56 + /// created_at: Utc::now(), 57 + /// signatures: vec![], 58 + /// extra: HashMap::new(), 59 + /// }; 60 + /// 61 + /// // Use TypedRsvp for automatic $type field handling 62 + /// let typed_rsvp = TypedRsvp::new(rsvp); 63 + /// ``` 64 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 65 + pub struct Rsvp { 66 + /// Reference to the event being responded to 67 + pub subject: StrongRef, 68 + 69 + /// The RSVP status (going, interested, not going) 70 + pub status: RsvpStatus, 71 + 72 + /// When this RSVP was created 73 + #[serde(rename = "createdAt", with = "datetime_format")] 74 + pub created_at: DateTime<Utc>, 75 + 76 + /// Optional signatures for verification 77 + #[serde(skip_serializing_if = "Vec::is_empty", default)] 78 + pub signatures: Signatures, 79 + 80 + /// Extension fields for forward compatibility 81 + #[serde(flatten)] 82 + pub extra: HashMap<String, serde_json::Value>, 83 + } 84 + 85 + impl LexiconType for Rsvp { 86 + fn lexicon_type() -> &'static str { 87 + NSID 88 + } 89 + } 90 + 91 + /// Type alias for Rsvp with automatic $type field handling. 92 + /// 93 + /// This wrapper ensures that the `$type` field is automatically 94 + /// added during serialization and validated during deserialization. 95 + pub type TypedRsvp = TypedLexicon<Rsvp>; 96 + 97 + #[cfg(test)] 98 + mod tests { 99 + use super::*; 100 + use anyhow::Result; 101 + use chrono::TimeZone; 102 + 103 + #[test] 104 + fn test_typed_rsvp_serialization() -> Result<()> { 105 + // Create an RSVP without explicit $type field 106 + let rsvp = Rsvp { 107 + subject: StrongRef { 108 + uri: "at://did:plc:example/community.lexicon.calendar.event/event123".to_string(), 109 + cid: "bafyreievent123".to_string(), 110 + }, 111 + status: RsvpStatus::Going, 112 + created_at: Utc.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap(), 113 + signatures: vec![], 114 + extra: HashMap::new(), 115 + }; 116 + 117 + // Wrap it in TypedRsvp 118 + let typed_rsvp = TypedLexicon::new(rsvp); 119 + 120 + // Serialize and verify $type is added 121 + let json = serde_json::to_value(&typed_rsvp)?; 122 + assert_eq!(json["$type"], "community.lexicon.calendar.rsvp"); 123 + assert_eq!( 124 + json["subject"]["uri"], 125 + "at://did:plc:example/community.lexicon.calendar.event/event123" 126 + ); 127 + assert_eq!(json["subject"]["cid"], "bafyreievent123"); 128 + assert_eq!(json["status"], "community.lexicon.calendar.rsvp#going"); 129 + assert_eq!(json["createdAt"], "2025-01-15T14:00:00.000Z"); 130 + 131 + Ok(()) 132 + } 133 + 134 + #[test] 135 + fn test_typed_rsvp_deserialization() -> Result<()> { 136 + let json_str = r#"{ 137 + "$type": "community.lexicon.calendar.rsvp", 138 + "subject": { 139 + "uri": "at://did:plc:test/community.lexicon.calendar.event/abc", 140 + "cid": "bafyreicid456" 141 + }, 142 + "status": "community.lexicon.calendar.rsvp#interested", 143 + "createdAt": "2025-01-10T10:00:00Z", 144 + "signatures": [] 145 + }"#; 146 + 147 + let typed_rsvp: TypedRsvp = serde_json::from_str(json_str)?; 148 + 149 + assert_eq!( 150 + typed_rsvp.inner.subject.uri, 151 + "at://did:plc:test/community.lexicon.calendar.event/abc" 152 + ); 153 + assert_eq!(typed_rsvp.inner.subject.cid, "bafyreicid456"); 154 + assert_eq!(typed_rsvp.inner.status, RsvpStatus::Interested); 155 + assert!(typed_rsvp.has_type_field()); 156 + assert!(typed_rsvp.validate().is_ok()); 157 + 158 + Ok(()) 159 + } 160 + 161 + #[test] 162 + fn test_typed_rsvp_without_type_field() -> Result<()> { 163 + // Test that we can deserialize an RSVP without $type field 164 + let json_str = r#"{ 165 + "subject": { 166 + "uri": "at://did:plc:test/community.lexicon.calendar.event/xyz", 167 + "cid": "bafyreicid789" 168 + }, 169 + "status": "community.lexicon.calendar.rsvp#notgoing", 170 + "createdAt": "2025-01-05T15:30:00Z", 171 + "signatures": [] 172 + }"#; 173 + 174 + let typed_rsvp: TypedRsvp = serde_json::from_str(json_str)?; 175 + 176 + assert_eq!( 177 + typed_rsvp.inner.subject.uri, 178 + "at://did:plc:test/community.lexicon.calendar.event/xyz" 179 + ); 180 + assert_eq!(typed_rsvp.inner.status, RsvpStatus::NotGoing); 181 + assert!(!typed_rsvp.has_type_field()); 182 + 183 + // Validation should fail because type is required by default 184 + assert!(typed_rsvp.validate().is_err()); 185 + 186 + Ok(()) 187 + } 188 + 189 + #[test] 190 + fn test_rsvp_status_serialization() -> Result<()> { 191 + // Test all RSVP status values 192 + let statuses = vec![ 193 + (RsvpStatus::Going, "community.lexicon.calendar.rsvp#going"), 194 + ( 195 + RsvpStatus::Interested, 196 + "community.lexicon.calendar.rsvp#interested", 197 + ), 198 + ( 199 + RsvpStatus::NotGoing, 200 + "community.lexicon.calendar.rsvp#notgoing", 201 + ), 202 + ]; 203 + 204 + for (status, expected) in statuses { 205 + let json = serde_json::to_value(&status)?; 206 + assert_eq!(json, expected); 207 + 208 + let deserialized: RsvpStatus = serde_json::from_value(json)?; 209 + assert_eq!(deserialized, status); 210 + } 211 + 212 + Ok(()) 213 + } 214 + 215 + #[test] 216 + fn test_typed_rsvp_round_trip() -> Result<()> { 217 + let original = Rsvp { 218 + subject: StrongRef { 219 + uri: "at://did:plc:roundtrip/community.lexicon.calendar.event/test".to_string(), 220 + cid: "bafyreiroundtrip".to_string(), 221 + }, 222 + status: RsvpStatus::Going, 223 + created_at: Utc.with_ymd_and_hms(2025, 2, 1, 12, 0, 0).unwrap(), 224 + signatures: vec![], 225 + extra: HashMap::new(), 226 + }; 227 + 228 + let typed = TypedLexicon::new(original.clone()); 229 + 230 + let json = serde_json::to_string(&typed)?; 231 + let deserialized: TypedRsvp = serde_json::from_str(&json)?; 232 + 233 + assert_eq!(deserialized.inner.subject.uri, original.subject.uri); 234 + assert_eq!(deserialized.inner.subject.cid, original.subject.cid); 235 + assert_eq!(deserialized.inner.status, original.status); 236 + assert_eq!(deserialized.inner.created_at, original.created_at); 237 + assert!(deserialized.has_type_field()); 238 + 239 + Ok(()) 240 + } 241 + 242 + #[test] 243 + fn test_rsvp_with_extra_fields() -> Result<()> { 244 + let json_str = r#"{ 245 + "$type": "community.lexicon.calendar.rsvp", 246 + "subject": { 247 + "uri": "at://did:plc:test/community.lexicon.calendar.event/extra", 248 + "cid": "bafyreiextra" 249 + }, 250 + "status": "community.lexicon.calendar.rsvp#going", 251 + "createdAt": "2025-01-20T09:00:00Z", 252 + "signatures": [], 253 + "customField": "customValue", 254 + "anotherField": 42 255 + }"#; 256 + 257 + let typed_rsvp: TypedRsvp = serde_json::from_str(json_str)?; 258 + 259 + assert_eq!(typed_rsvp.inner.extra.len(), 2); 260 + assert_eq!( 261 + typed_rsvp.inner.extra.get("customField").unwrap(), 262 + "customValue" 263 + ); 264 + assert_eq!(typed_rsvp.inner.extra.get("anotherField").unwrap(), 42); 265 + 266 + Ok(()) 267 + } 268 + 269 + #[test] 270 + fn test_rsvp_with_signatures() -> Result<()> { 271 + use crate::lexicon::community_lexicon_attestation::SignatureOrRef; 272 + 273 + let json_str = r#"{ 274 + "$type": "community.lexicon.calendar.rsvp", 275 + "subject": { 276 + "uri": "at://did:plc:test/community.lexicon.calendar.event/signed", 277 + "cid": "bafyreisigned" 278 + }, 279 + "status": "community.lexicon.calendar.rsvp#going", 280 + "createdAt": "2025-01-25T16:00:00Z", 281 + "signatures": [ 282 + { 283 + "$type": "community.lexicon.attestation.signature", 284 + "issuer": "did:plc:issuer", 285 + "signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="} 286 + } 287 + ] 288 + }"#; 289 + 290 + let typed_rsvp: TypedRsvp = serde_json::from_str(json_str)?; 291 + 292 + assert_eq!(typed_rsvp.inner.signatures.len(), 1); 293 + match &typed_rsvp.inner.signatures[0] { 294 + SignatureOrRef::Inline(sig) => { 295 + assert_eq!(sig.inner.issuer, "did:plc:issuer"); 296 + assert_eq!(sig.inner.signature.bytes, b"test signature"); 297 + } 298 + _ => panic!("Expected inline signature"), 299 + } 300 + 301 + Ok(()) 302 + } 303 + }
+403
crates/atproto-record/src/lexicon/community_lexicon_location.rs
···
··· 1 + //! Location types for AT Protocol. 2 + //! 3 + //! This module provides various location representation types including 4 + //! addresses, geographic coordinates, Foursquare places, and H3 hexagonal 5 + //! hierarchical spatial indices. 6 + 7 + use crate::{ 8 + lexicon::com::atproto::repo::TypedStrongRef, 9 + typed::{LexiconType, TypedLexicon}, 10 + }; 11 + use serde::{Deserialize, Serialize}; 12 + 13 + /// Base namespace identifier for location types 14 + pub const NSID: &str = "community.lexicon.location"; 15 + /// Namespace identifier for address locations 16 + pub const ADDRESS_NSID: &str = "community.lexicon.location.address"; 17 + /// Namespace identifier for geographic coordinate locations 18 + pub const GEO_NSID: &str = "community.lexicon.location.geo"; 19 + /// Namespace identifier for Foursquare locations 20 + pub const FSQ_NSID: &str = "community.lexicon.location.fsq"; 21 + /// Namespace identifier for H3 locations 22 + pub const HTHREE_NSID: &str = "community.lexicon.location.hthree"; 23 + 24 + /// Enum that can hold either a location reference or inline location data. 25 + /// 26 + /// This type allows locations to be either embedded directly in a record 27 + /// or referenced via a strong reference. Supports multiple location types 28 + /// including addresses, coordinates, and third-party location identifiers. 29 + /// 30 + /// # Example 31 + /// 32 + /// ```ignore 33 + /// use atproto_record::lexicon::community::lexicon::location::{LocationOrRef, TypedAddress, Address}; 34 + /// 35 + /// // Inline address 36 + /// let address = Address { 37 + /// country: "USA".to_string(), 38 + /// postal_code: Some("12345".to_string()), 39 + /// region: Some("CA".to_string()), 40 + /// locality: Some("San Francisco".to_string()), 41 + /// street: None, 42 + /// name: None, 43 + /// }; 44 + /// let location = LocationOrRef::InlineAddress(TypedAddress::new(address)); 45 + /// ``` 46 + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] 47 + #[serde(untagged)] 48 + pub enum LocationOrRef { 49 + /// A reference to a location stored elsewhere 50 + Reference(TypedStrongRef), 51 + /// An inline address location 52 + InlineAddress(TypedAddress), 53 + /// An inline geographic coordinate location 54 + InlineGeo(TypedGeo), 55 + /// An inline H3 location 56 + InlineHthree(TypedHthree), 57 + /// An inline Foursquare location 58 + InlineFsq(TypedFsq), 59 + } 60 + 61 + /// A vector of locations that can be either inline or referenced. 62 + /// 63 + /// This type alias is commonly used in records that support multiple 64 + /// locations, such as events that might have both physical and virtual locations. 65 + pub type Locations = Vec<LocationOrRef>; 66 + 67 + /// Address location structure. 68 + /// 69 + /// Represents a physical address with varying levels of detail. 70 + /// Only the country field is required; all other fields are optional. 71 + /// 72 + /// # Example 73 + /// 74 + /// ```ignore 75 + /// use atproto_record::lexicon::community::lexicon::location::Address; 76 + /// 77 + /// let address = Address { 78 + /// country: "United States".to_string(), 79 + /// postal_code: Some("94102".to_string()), 80 + /// region: Some("California".to_string()), 81 + /// locality: Some("San Francisco".to_string()), 82 + /// street: Some("123 Market St".to_string()), 83 + /// name: Some("Tech Hub Building".to_string()), 84 + /// }; 85 + /// ``` 86 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 87 + pub struct Address { 88 + /// Country name (required) 89 + pub country: String, 90 + 91 + /// Postal/ZIP code 92 + #[serde( 93 + rename = "postalCode", 94 + skip_serializing_if = "Option::is_none", 95 + default 96 + )] 97 + pub postal_code: Option<String>, 98 + 99 + /// State, province, or region 100 + #[serde(skip_serializing_if = "Option::is_none", default)] 101 + pub region: Option<String>, 102 + 103 + /// City or locality 104 + #[serde(skip_serializing_if = "Option::is_none", default)] 105 + pub locality: Option<String>, 106 + 107 + /// Street address 108 + #[serde(skip_serializing_if = "Option::is_none", default)] 109 + pub street: Option<String>, 110 + 111 + /// Location name (e.g., building or venue name) 112 + #[serde(skip_serializing_if = "Option::is_none", default)] 113 + pub name: Option<String>, 114 + } 115 + 116 + impl LexiconType for Address { 117 + fn lexicon_type() -> &'static str { 118 + ADDRESS_NSID 119 + } 120 + } 121 + 122 + /// Type alias for Address with automatic $type field handling 123 + pub type TypedAddress = TypedLexicon<Address>; 124 + 125 + /// Geographic coordinates location structure. 126 + /// 127 + /// Represents a location using latitude and longitude coordinates. 128 + /// Coordinates are stored as strings to preserve precision. 129 + /// 130 + /// # Example 131 + /// 132 + /// ```ignore 133 + /// use atproto_record::lexicon::community::lexicon::location::Geo; 134 + /// 135 + /// let location = Geo { 136 + /// latitude: "37.7749".to_string(), 137 + /// longitude: "-122.4194".to_string(), 138 + /// name: Some("San Francisco".to_string()), 139 + /// }; 140 + /// ``` 141 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 142 + pub struct Geo { 143 + /// Latitude coordinate as a string 144 + pub latitude: String, 145 + 146 + /// Longitude coordinate as a string 147 + pub longitude: String, 148 + 149 + /// Optional human-readable name for this location 150 + #[serde(skip_serializing_if = "Option::is_none", default)] 151 + pub name: Option<String>, 152 + } 153 + 154 + impl LexiconType for Geo { 155 + fn lexicon_type() -> &'static str { 156 + GEO_NSID 157 + } 158 + } 159 + 160 + /// Type alias for Geo with automatic $type field handling 161 + pub type TypedGeo = TypedLexicon<Geo>; 162 + 163 + /// Foursquare location structure. 164 + /// 165 + /// Represents a location using Foursquare's place identifier system. 166 + /// This allows integration with Foursquare's venue database. 167 + /// 168 + /// # Example 169 + /// 170 + /// ```ignore 171 + /// use atproto_record::lexicon::community::lexicon::location::Fsq; 172 + /// 173 + /// let location = Fsq { 174 + /// fsq_place_id: "4a27f3d4f964a520a4891fe3".to_string(), 175 + /// name: Some("Empire State Building".to_string()), 176 + /// }; 177 + /// ``` 178 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 179 + pub struct Fsq { 180 + /// Foursquare place identifier 181 + pub fsq_place_id: String, 182 + 183 + /// Optional venue name from Foursquare 184 + #[serde(skip_serializing_if = "Option::is_none", default)] 185 + pub name: Option<String>, 186 + } 187 + 188 + impl LexiconType for Fsq { 189 + fn lexicon_type() -> &'static str { 190 + FSQ_NSID 191 + } 192 + } 193 + 194 + /// Type alias for Fsq with automatic $type field handling 195 + pub type TypedFsq = TypedLexicon<Fsq>; 196 + 197 + /// H3 location structure. 198 + /// 199 + /// Represents a location using Uber's H3 hexagonal hierarchical spatial index. 200 + /// H3 provides a way to represent geographic areas as hexagons at various resolutions. 201 + /// 202 + /// # Example 203 + /// 204 + /// ```ignore 205 + /// use atproto_record::lexicon::community::lexicon::location::Hthree; 206 + /// 207 + /// let location = Hthree { 208 + /// value: "8a2a1072b59ffff".to_string(), 209 + /// name: Some("Downtown Area".to_string()), 210 + /// }; 211 + /// ``` 212 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 213 + pub struct Hthree { 214 + /// H3 hexagon identifier 215 + pub value: String, 216 + 217 + /// Optional human-readable name for this area 218 + #[serde(skip_serializing_if = "Option::is_none", default)] 219 + pub name: Option<String>, 220 + } 221 + 222 + impl LexiconType for Hthree { 223 + fn lexicon_type() -> &'static str { 224 + HTHREE_NSID 225 + } 226 + } 227 + 228 + /// Type alias for Hthree with automatic $type field handling 229 + pub type TypedHthree = TypedLexicon<Hthree>; 230 + 231 + #[cfg(test)] 232 + mod tests { 233 + use super::*; 234 + 235 + #[test] 236 + fn test_typed_address() { 237 + // Create an Address without explicit $type field 238 + let address = Address { 239 + country: "USA".to_string(), 240 + postal_code: Some("12345".to_string()), 241 + region: Some("California".to_string()), 242 + locality: Some("San Francisco".to_string()), 243 + street: Some("123 Main St".to_string()), 244 + name: Some("Office Building".to_string()), 245 + }; 246 + 247 + // Wrap it in TypedAddress 248 + let typed_address = TypedLexicon::new(address.clone()); 249 + 250 + // Serialize and verify $type is added 251 + let json = serde_json::to_value(&typed_address).unwrap(); 252 + assert_eq!(json["$type"], "community.lexicon.location.address"); 253 + assert_eq!(json["country"], "USA"); 254 + assert_eq!(json["postalCode"], "12345"); 255 + assert_eq!(json["region"], "California"); 256 + 257 + // Deserialize with $type field 258 + let json_str = r#"{ 259 + "$type": "community.lexicon.location.address", 260 + "country": "Canada", 261 + "postalCode": "K1A 0B1", 262 + "region": "Ontario", 263 + "locality": "Ottawa" 264 + }"#; 265 + 266 + let deserialized: TypedAddress = serde_json::from_str(json_str).unwrap(); 267 + assert_eq!(deserialized.inner.country, "Canada"); 268 + assert_eq!(deserialized.inner.postal_code, Some("K1A 0B1".to_string())); 269 + assert!(deserialized.has_type_field()); 270 + } 271 + 272 + #[test] 273 + fn test_typed_geo() { 274 + // Create a Geo without explicit $type field 275 + let geo = Geo { 276 + latitude: "37.7749".to_string(), 277 + longitude: "-122.4194".to_string(), 278 + name: Some("San Francisco".to_string()), 279 + }; 280 + 281 + // Wrap it in TypedGeo 282 + let typed_geo = TypedLexicon::new(geo); 283 + 284 + // Serialize and verify $type is added 285 + let json = serde_json::to_value(&typed_geo).unwrap(); 286 + assert_eq!(json["$type"], "community.lexicon.location.geo"); 287 + assert_eq!(json["latitude"], "37.7749"); 288 + assert_eq!(json["longitude"], "-122.4194"); 289 + assert_eq!(json["name"], "San Francisco"); 290 + 291 + // Deserialize with $type field 292 + let json_str = r#"{ 293 + "$type": "community.lexicon.location.geo", 294 + "latitude": "40.7128", 295 + "longitude": "-74.0060", 296 + "name": "New York" 297 + }"#; 298 + 299 + let deserialized: TypedGeo = serde_json::from_str(json_str).unwrap(); 300 + assert_eq!(deserialized.inner.latitude, "40.7128"); 301 + assert_eq!(deserialized.inner.longitude, "-74.0060"); 302 + assert_eq!(deserialized.inner.name, Some("New York".to_string())); 303 + assert!(deserialized.has_type_field()); 304 + } 305 + 306 + #[test] 307 + fn test_typed_fsq() { 308 + // Create an Fsq without explicit $type field 309 + let fsq = Fsq { 310 + fsq_place_id: "4a27f3d4f964a520a4891fe3".to_string(), 311 + name: Some("Empire State Building".to_string()), 312 + }; 313 + 314 + // Wrap it in TypedFsq 315 + let typed_fsq = TypedLexicon::new(fsq); 316 + 317 + // Serialize and verify $type is added 318 + let json = serde_json::to_value(&typed_fsq).unwrap(); 319 + assert_eq!(json["$type"], "community.lexicon.location.fsq"); 320 + assert_eq!(json["fsq_place_id"], "4a27f3d4f964a520a4891fe3"); 321 + assert_eq!(json["name"], "Empire State Building"); 322 + 323 + // Deserialize without name field 324 + let json_str = r#"{ 325 + "$type": "community.lexicon.location.fsq", 326 + "fsq_place_id": "5642aef9498e51025cf4a7a5" 327 + }"#; 328 + 329 + let deserialized: TypedFsq = serde_json::from_str(json_str).unwrap(); 330 + assert_eq!(deserialized.inner.fsq_place_id, "5642aef9498e51025cf4a7a5"); 331 + assert_eq!(deserialized.inner.name, None); 332 + assert!(deserialized.has_type_field()); 333 + } 334 + 335 + #[test] 336 + fn test_typed_hthree() { 337 + // Create an Hthree without explicit $type field 338 + let hthree = Hthree { 339 + value: "8a2a1072b59ffff".to_string(), 340 + name: Some("Downtown Area".to_string()), 341 + }; 342 + 343 + // Wrap it in TypedHthree 344 + let typed_hthree = TypedLexicon::new(hthree); 345 + 346 + // Serialize and verify $type is added 347 + let json = serde_json::to_value(&typed_hthree).unwrap(); 348 + assert_eq!(json["$type"], "community.lexicon.location.hthree"); 349 + assert_eq!(json["value"], "8a2a1072b59ffff"); 350 + assert_eq!(json["name"], "Downtown Area"); 351 + 352 + // Deserialize without name field 353 + let json_str = r#"{ 354 + "$type": "community.lexicon.location.hthree", 355 + "value": "8928308280fffff" 356 + }"#; 357 + 358 + let deserialized: TypedHthree = serde_json::from_str(json_str).unwrap(); 359 + assert_eq!(deserialized.inner.value, "8928308280fffff"); 360 + assert_eq!(deserialized.inner.name, None); 361 + assert!(deserialized.has_type_field()); 362 + } 363 + 364 + #[test] 365 + fn test_optional_fields() { 366 + // Test Address with minimal fields 367 + let address = Address { 368 + country: "USA".to_string(), 369 + postal_code: None, 370 + region: None, 371 + locality: None, 372 + street: None, 373 + name: None, 374 + }; 375 + 376 + let typed_address = TypedLexicon::new(address); 377 + let json = serde_json::to_value(&typed_address).unwrap(); 378 + 379 + // Optional fields should not be present when None 380 + assert_eq!(json["$type"], "community.lexicon.location.address"); 381 + assert_eq!(json["country"], "USA"); 382 + assert!(!json.as_object().unwrap().contains_key("postalCode")); 383 + assert!(!json.as_object().unwrap().contains_key("region")); 384 + assert!(!json.as_object().unwrap().contains_key("locality")); 385 + assert!(!json.as_object().unwrap().contains_key("street")); 386 + assert!(!json.as_object().unwrap().contains_key("name")); 387 + 388 + // Test Geo with minimal fields 389 + let geo = Geo { 390 + latitude: "0.0".to_string(), 391 + longitude: "0.0".to_string(), 392 + name: None, 393 + }; 394 + 395 + let typed_geo = TypedLexicon::new(geo); 396 + let json = serde_json::to_value(&typed_geo).unwrap(); 397 + 398 + assert_eq!(json["$type"], "community.lexicon.location.geo"); 399 + assert_eq!(json["latitude"], "0.0"); 400 + assert_eq!(json["longitude"], "0.0"); 401 + assert!(!json.as_object().unwrap().contains_key("name")); 402 + } 403 + }
+80
crates/atproto-record/src/lexicon/mod.rs
···
··· 1 + //! AT Protocol lexicon type definitions and implementations. 2 + //! 3 + //! This module provides Rust implementations of AT Protocol lexicon types, 4 + //! organized by namespace following the AT Protocol naming conventions. 5 + //! 6 + //! # Structure 7 + //! 8 + //! The module is organized into two main namespaces: 9 + //! 10 + //! - `com.atproto.*` - Core AT Protocol types (e.g., StrongRef) 11 + //! - `community.lexicon.*` - Community-defined types for social features 12 + //! 13 + //! # TypedLexicon Pattern 14 + //! 15 + //! Most types in this module follow the TypedLexicon pattern, which automatically 16 + //! handles the `$type` field required by AT Protocol for type discrimination. 17 + //! This pattern ensures correct serialization and deserialization of lexicon types. 18 + //! 19 + //! # Example 20 + //! 21 + //! ```ignore 22 + //! use atproto_record::lexicon::com::atproto::repo::{StrongRef, TypedStrongRef}; 23 + //! 24 + //! // Create a strong reference 25 + //! let strong_ref = StrongRef { 26 + //! uri: "at://did:plc:example/collection/record".to_string(), 27 + //! cid: "bafyreicid123".to_string(), 28 + //! }; 29 + //! 30 + //! // Wrap it with TypedLexicon for automatic $type handling 31 + //! let typed_ref = TypedStrongRef::new(strong_ref); 32 + //! ``` 33 + 34 + mod com_atproto_repo; 35 + mod community_lexicon_attestation; 36 + mod community_lexicon_badge; 37 + mod community_lexicon_calendar_event; 38 + mod community_lexicon_calendar_rsvp; 39 + mod community_lexicon_location; 40 + mod primatives; 41 + 42 + pub use primatives::*; 43 + 44 + /// AT Protocol core types namespace 45 + pub mod com { 46 + /// AT Protocol namespace 47 + pub mod atproto { 48 + /// Repository-related types 49 + pub mod repo { 50 + pub use crate::lexicon::com_atproto_repo::*; 51 + } 52 + } 53 + } 54 + 55 + /// Community lexicon types namespace 56 + pub mod community { 57 + /// Community lexicon definitions 58 + pub mod lexicon { 59 + /// Calendar-related types for events and RSVPs 60 + pub mod calendar { 61 + /// Event-related types 62 + pub mod event { 63 + pub use crate::lexicon::community_lexicon_calendar_event::*; 64 + } 65 + /// RSVP-related types 66 + pub mod rsvp { 67 + pub use crate::lexicon::community_lexicon_calendar_rsvp::*; 68 + } 69 + } 70 + /// Location-related types 71 + pub mod location { 72 + pub use crate::lexicon::community_lexicon_location::*; 73 + } 74 + 75 + /// Attestation and signature types 76 + pub mod attestation { 77 + pub use crate::lexicon::community_lexicon_attestation::*; 78 + } 79 + } 80 + }
+234
crates/atproto-record/src/lexicon/primatives.rs
···
··· 1 + //! Primitive types used across AT Protocol lexicon definitions. 2 + //! 3 + //! This module contains fundamental data types that are referenced by 4 + //! various AT Protocol lexicon schemas, including blob references, 5 + //! links, and byte arrays. 6 + 7 + use crate::bytes::format as bytes_format; 8 + use crate::typed::{LexiconType, TypedLexicon}; 9 + use serde::{Deserialize, Serialize}; 10 + 11 + /// The namespace identifier for blobs 12 + pub const BLOB_NSID: &str = "blob"; 13 + 14 + /// Blob reference type for AT Protocol. 15 + /// 16 + /// Represents a reference to binary data (images, videos, etc.) stored 17 + /// in the AT Protocol network. The blob itself is not included inline, 18 + /// but referenced via a CID (Content Identifier). 19 + /// 20 + /// # Example 21 + /// 22 + /// ```ignore 23 + /// use atproto_record::lexicon::{Blob, TypedBlob, Link}; 24 + /// 25 + /// let blob = Blob { 26 + /// ref_: Link { 27 + /// link: "bafkreicid123".to_string(), 28 + /// }, 29 + /// mime_type: "image/jpeg".to_string(), 30 + /// size: 123456, 31 + /// }; 32 + /// 33 + /// // Use TypedBlob for automatic $type field handling 34 + /// let typed_blob = TypedBlob::new(blob); 35 + /// ``` 36 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 37 + pub struct Blob { 38 + /// Link to the blob content via CID 39 + #[serde(rename = "ref")] 40 + pub ref_: Link, 41 + /// MIME type of the blob content (e.g., "image/jpeg", "video/mp4") 42 + #[serde(rename = "mimeType")] 43 + pub mime_type: String, 44 + /// Size of the blob in bytes 45 + pub size: u64, 46 + } 47 + 48 + impl LexiconType for Blob { 49 + fn lexicon_type() -> &'static str { 50 + BLOB_NSID 51 + } 52 + } 53 + 54 + /// Type alias for Blob with automatic $type field handling. 55 + /// 56 + /// This wrapper ensures that the `$type` field is automatically 57 + /// added during serialization and validated during deserialization. 58 + pub type TypedBlob = TypedLexicon<Blob>; 59 + 60 + /// Link reference type for AT Protocol. 61 + /// 62 + /// Represents a content-addressed link using a CID (Content Identifier). 63 + /// This is used to reference immutable content in the AT Protocol network. 64 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 65 + pub struct Link { 66 + /// The CID (Content Identifier) as a string 67 + #[serde(rename = "$link")] 68 + pub link: String, 69 + } 70 + 71 + /// Byte array type for AT Protocol. 72 + /// 73 + /// Represents raw byte data that is serialized/deserialized as base64. 74 + /// Used for signatures, hashes, and other binary data that needs to be 75 + /// transmitted in JSON format. 76 + /// 77 + /// # Example 78 + /// 79 + /// ```ignore 80 + /// let signature = Bytes { 81 + /// bytes: b"signature data".to_vec(), 82 + /// }; 83 + /// // Serializes to: {"$bytes": "c2lnbmF0dXJlIGRhdGE="} 84 + /// ``` 85 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 86 + pub struct Bytes { 87 + /// The raw bytes, serialized as base64 in JSON 88 + #[serde(rename = "$bytes", with = "bytes_format")] 89 + pub bytes: Vec<u8>, 90 + } 91 + 92 + #[cfg(test)] 93 + mod tests { 94 + use super::*; 95 + use serde_json::json; 96 + 97 + #[test] 98 + fn test_typed_blob_serialization() { 99 + // Create a Blob without explicit $type field 100 + let blob = Blob { 101 + ref_: Link { 102 + link: "bafkreitest123".to_string(), 103 + }, 104 + mime_type: "image/png".to_string(), 105 + size: 12345, 106 + }; 107 + 108 + // Wrap it in TypedBlob 109 + let typed_blob = TypedLexicon::new(blob); 110 + 111 + // Serialize and verify $type is added 112 + let json = serde_json::to_value(&typed_blob).unwrap(); 113 + assert_eq!(json["$type"], "blob"); 114 + assert_eq!(json["ref"]["$link"], "bafkreitest123"); 115 + assert_eq!(json["mimeType"], "image/png"); 116 + assert_eq!(json["size"], 12345); 117 + } 118 + 119 + #[test] 120 + fn test_typed_blob_deserialization() { 121 + let json = json!({ 122 + "$type": "blob", 123 + "ref": { 124 + "$link": "bafkreideserialized" 125 + }, 126 + "mimeType": "video/mp4", 127 + "size": 54321 128 + }); 129 + 130 + let typed_blob: TypedBlob = serde_json::from_value(json).unwrap(); 131 + 132 + assert_eq!(typed_blob.inner.ref_.link, "bafkreideserialized"); 133 + assert_eq!(typed_blob.inner.mime_type, "video/mp4"); 134 + assert_eq!(typed_blob.inner.size, 54321); 135 + assert!(typed_blob.has_type_field()); 136 + assert!(typed_blob.validate().is_ok()); 137 + } 138 + 139 + #[test] 140 + fn test_typed_blob_without_type_field() { 141 + // Test that we can deserialize a blob without $type field 142 + let json = json!({ 143 + "ref": { 144 + "$link": "bafkreinotype" 145 + }, 146 + "mimeType": "text/plain", 147 + "size": 100 148 + }); 149 + 150 + let typed_blob: TypedBlob = serde_json::from_value(json).unwrap(); 151 + 152 + assert_eq!(typed_blob.inner.ref_.link, "bafkreinotype"); 153 + assert_eq!(typed_blob.inner.mime_type, "text/plain"); 154 + assert_eq!(typed_blob.inner.size, 100); 155 + assert!(!typed_blob.has_type_field()); 156 + 157 + // Validation should fail because type is required by default 158 + assert!(typed_blob.validate().is_err()); 159 + } 160 + 161 + #[test] 162 + fn test_typed_blob_round_trip() { 163 + let original = Blob { 164 + ref_: Link { 165 + link: "bafkreiroundtrip".to_string(), 166 + }, 167 + mime_type: "application/octet-stream".to_string(), 168 + size: 999999, 169 + }; 170 + 171 + let typed = TypedLexicon::new(original.clone()); 172 + 173 + let json = serde_json::to_string(&typed).unwrap(); 174 + let deserialized: TypedBlob = serde_json::from_str(&json).unwrap(); 175 + 176 + assert_eq!(deserialized.inner.ref_.link, original.ref_.link); 177 + assert_eq!(deserialized.inner.mime_type, original.mime_type); 178 + assert_eq!(deserialized.inner.size, original.size); 179 + assert!(deserialized.has_type_field()); 180 + } 181 + 182 + #[test] 183 + fn test_legacy_blob_with_explicit_type() { 184 + // Test backward compatibility: deserializing old format with type_ field 185 + let json_old_format = r#"{ 186 + "$type": "blob", 187 + "ref": { 188 + "$link": "bafkreilegacy" 189 + }, 190 + "mimeType": "image/gif", 191 + "size": 777 192 + }"#; 193 + 194 + let typed_blob: TypedBlob = serde_json::from_str(json_old_format).unwrap(); 195 + 196 + assert_eq!(typed_blob.inner.ref_.link, "bafkreilegacy"); 197 + assert_eq!(typed_blob.inner.mime_type, "image/gif"); 198 + assert_eq!(typed_blob.inner.size, 777); 199 + assert!(typed_blob.has_type_field()); 200 + 201 + // Re-serialize should maintain the $type field 202 + let json = serde_json::to_value(&typed_blob).unwrap(); 203 + assert_eq!(json["$type"], "blob"); 204 + } 205 + 206 + #[test] 207 + fn test_blob_in_context() { 208 + // Test using TypedBlob in a larger structure 209 + #[derive(Debug, Serialize, Deserialize)] 210 + struct TestRecord { 211 + title: String, 212 + thumbnail: TypedBlob, 213 + } 214 + 215 + let record = TestRecord { 216 + title: "Test Image".to_string(), 217 + thumbnail: TypedLexicon::new(Blob { 218 + ref_: Link { 219 + link: "bafkreicontext".to_string(), 220 + }, 221 + mime_type: "image/jpeg".to_string(), 222 + size: 256000, 223 + }), 224 + }; 225 + 226 + let json = serde_json::to_value(&record).unwrap(); 227 + 228 + assert_eq!(json["title"], "Test Image"); 229 + assert_eq!(json["thumbnail"]["$type"], "blob"); 230 + assert_eq!(json["thumbnail"]["ref"]["$link"], "bafkreicontext"); 231 + assert_eq!(json["thumbnail"]["mimeType"], "image/jpeg"); 232 + assert_eq!(json["thumbnail"]["size"], 256000); 233 + } 234 + }
+21 -1
crates/atproto-record/src/lib.rs
··· 25 //! let record = json!({"$type": "app.bsky.feed.post", "text": "Hello!"}); 26 //! let sig_obj = json!({"issuer": "did:plc:..."}); 27 //! 28 - //! let signed = signature::create(&key_data, &record, "did:plc:repo", 29 //! "app.bsky.feed.post", sig_obj).await?; 30 //! 31 //! // Verify a signature ··· 64 /// across AT Protocol records. Includes support for both required and optional 65 /// datetime fields with millisecond precision. 66 pub mod datetime;
··· 25 //! let record = json!({"$type": "app.bsky.feed.post", "text": "Hello!"}); 26 //! let sig_obj = json!({"issuer": "did:plc:..."}); 27 //! 28 + //! let signed = signature::create(&key_data, &record, "did:plc:repo", 29 //! "app.bsky.feed.post", sig_obj).await?; 30 //! 31 //! // Verify a signature ··· 64 /// across AT Protocol records. Includes support for both required and optional 65 /// datetime fields with millisecond precision. 66 pub mod datetime; 67 + 68 + /// Byte array serialization utilities. 69 + /// 70 + /// Provides specialized serialization and deserialization for byte arrays 71 + /// in AT Protocol records, handling base64 encoding with the `$bytes` field format. 72 + pub mod bytes; 73 + 74 + /// AT Protocol lexicon type definitions. 75 + /// 76 + /// Contains structured type definitions for various AT Protocol lexicons including 77 + /// badges, calendar events, RSVPs, locations, and attestations. These types follow 78 + /// the AT Protocol lexicon specifications for data interchange. 79 + pub mod lexicon; 80 + 81 + /// Generic wrapper for handling lexicon types with `$type` fields. 82 + /// 83 + /// Provides a flexible way to handle the `$type` discriminator field that appears 84 + /// in many AT Protocol lexicon structures. The wrapper can automatically add type 85 + /// fields during serialization and validate them during deserialization. 86 + pub mod typed;
+16 -6
crates/atproto-record/src/signature.rs
··· 47 //! ``` 48 49 use atproto_identity::key::{KeyData, sign, validate}; 50 - use serde_json::json; 51 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; 52 53 use crate::errors::VerificationError; 54 ··· 105 if let Some(record_map) = sig.as_object_mut() { 106 record_map.insert("repository".to_string(), json!(repository)); 107 record_map.insert("collection".to_string(), json!(collection)); 108 - record_map.insert("$type".to_string(), json!("community.lexicon.attestation.signature")); 109 } 110 111 // Create a copy of the record with the $sig object for signing. ··· 115 record_map.remove("$sig"); 116 record_map.insert("$sig".to_string(), sig); 117 } 118 - 119 120 // Create a signature. 121 let serialized_signing_record = serde_ipld_dagcbor::to_vec(&signing_record)?; ··· 128 if let Some(record_map) = proof.as_object_mut() { 129 record_map.remove("repository"); 130 record_map.remove("collection"); 131 - record_map.insert("signature".to_string(), json!({"$bytes": json!(encoded_signature)})); 132 - record_map.insert("$type".to_string(), json!("community.lexicon.attestation.signature")); 133 } 134 135 // Add the signature to the original record ··· 233 let serialized_record = serde_ipld_dagcbor::to_vec(&signed_record) 234 .map_err(|error| VerificationError::RecordSerializationFailed { error })?; 235 236 - let signature_bytes = URL_SAFE_NO_PAD.decode(&signature_value).map_err(|error| VerificationError::SignatureDecodingFailed { error })?; 237 238 validate(key_data, &signature_bytes, &serialized_record) 239 .map_err(|error| VerificationError::CryptographicValidationFailed { error })?;
··· 47 //! ``` 48 49 use atproto_identity::key::{KeyData, sign, validate}; 50 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; 51 + use serde_json::json; 52 53 use crate::errors::VerificationError; 54 ··· 105 if let Some(record_map) = sig.as_object_mut() { 106 record_map.insert("repository".to_string(), json!(repository)); 107 record_map.insert("collection".to_string(), json!(collection)); 108 + record_map.insert( 109 + "$type".to_string(), 110 + json!("community.lexicon.attestation.signature"), 111 + ); 112 } 113 114 // Create a copy of the record with the $sig object for signing. ··· 118 record_map.remove("$sig"); 119 record_map.insert("$sig".to_string(), sig); 120 } 121 122 // Create a signature. 123 let serialized_signing_record = serde_ipld_dagcbor::to_vec(&signing_record)?; ··· 130 if let Some(record_map) = proof.as_object_mut() { 131 record_map.remove("repository"); 132 record_map.remove("collection"); 133 + record_map.insert( 134 + "signature".to_string(), 135 + json!({"$bytes": json!(encoded_signature)}), 136 + ); 137 + record_map.insert( 138 + "$type".to_string(), 139 + json!("community.lexicon.attestation.signature"), 140 + ); 141 } 142 143 // Add the signature to the original record ··· 241 let serialized_record = serde_ipld_dagcbor::to_vec(&signed_record) 242 .map_err(|error| VerificationError::RecordSerializationFailed { error })?; 243 244 + let signature_bytes = URL_SAFE_NO_PAD 245 + .decode(&signature_value) 246 + .map_err(|error| VerificationError::SignatureDecodingFailed { error })?; 247 248 validate(key_data, &signature_bytes, &serialized_record) 249 .map_err(|error| VerificationError::CryptographicValidationFailed { error })?;
+444
crates/atproto-record/src/typed.rs
···
··· 1 + //! Generic wrapper type for AT Protocol lexicon structures with optional `$type` field handling. 2 + //! 3 + //! This module provides a flexible way to handle the `$type` discriminator field that 4 + //! appears in many AT Protocol lexicon structures. The wrapper can be used when the 5 + //! type field needs to be validated, automatically added during serialization, or 6 + //! when it may not always be present. 7 + 8 + use serde::de::{DeserializeOwned, MapAccess, Visitor}; 9 + use serde::ser::SerializeMap; 10 + use serde::{Deserialize, Deserializer, Serialize, Serializer}; 11 + use std::fmt; 12 + use std::marker::PhantomData; 13 + 14 + /// A trait for types that have an associated lexicon type identifier. 15 + pub trait LexiconType { 16 + /// Returns the lexicon type identifier (e.g., "community.lexicon.attestation.signature"). 17 + fn lexicon_type() -> &'static str; 18 + 19 + /// Returns whether the type field is required for this lexicon type. 20 + /// Default is true, but types can override this for optional type fields. 21 + fn type_required() -> bool { 22 + true 23 + } 24 + } 25 + 26 + /// A wrapper type that handles the `$type` field for AT Protocol lexicon structures. 27 + /// 28 + /// This wrapper provides flexibility in handling the type field: 29 + /// - Conditionally adds the `$type` field during serialization based on `type_present` 30 + /// - Validates the `$type` during deserialization if present 31 + /// - Preserves the presence/absence of `$type` for round-trip compatibility 32 + /// - Can handle cases where the `$type` field is optional 33 + /// 34 + /// # Serialization Behavior 35 + /// 36 + /// - When created with `TypedLexicon::new()`, the `$type` field will be included in serialization 37 + /// - When created with `TypedLexicon::new_without_type()`, the `$type` field will be omitted 38 + /// - When deserialized, the presence of `$type` is preserved for round-trip compatibility 39 + /// 40 + /// # Example 41 + /// 42 + /// ```ignore 43 + /// use atproto_record::typed::{TypedLexicon, LexiconType}; 44 + /// use serde::{Deserialize, Serialize}; 45 + /// 46 + /// #[derive(Debug, Serialize, Deserialize)] 47 + /// struct MyRecord { 48 + /// name: String, 49 + /// value: i32, 50 + /// } 51 + /// 52 + /// impl LexiconType for MyRecord { 53 + /// fn lexicon_type() -> &'static str { 54 + /// "com.example.myrecord" 55 + /// } 56 + /// } 57 + /// 58 + /// // With type field 59 + /// let record = MyRecord { name: "test".to_string(), value: 42 }; 60 + /// let typed = TypedLexicon::new(record); 61 + /// let json = serde_json::to_string(&typed).unwrap(); 62 + /// assert!(json.contains("\"$type\":\"com.example.myrecord\"")); 63 + /// 64 + /// // Without type field 65 + /// let record2 = MyRecord { name: "test2".to_string(), value: 43 }; 66 + /// let typed2 = TypedLexicon::new_without_type(record2); 67 + /// let json2 = serde_json::to_string(&typed2).unwrap(); 68 + /// assert!(!json2.contains("\"$type\"")); 69 + /// ``` 70 + #[derive(Debug, Clone, PartialEq)] 71 + pub struct TypedLexicon<T: LexiconType + PartialEq> { 72 + /// The inner value being wrapped 73 + pub inner: T, 74 + /// Whether the type field was explicitly present during deserialization 75 + type_present: bool, 76 + } 77 + 78 + impl<T: LexiconType + PartialEq> TypedLexicon<T> { 79 + /// Creates a new TypedLexicon wrapper that will include the `$type` field when serialized. 80 + pub fn new(inner: T) -> Self { 81 + Self { 82 + inner, 83 + type_present: true, 84 + } 85 + } 86 + 87 + /// Creates a new TypedLexicon wrapper that will NOT include the `$type` field when serialized. 88 + pub fn new_without_type(inner: T) -> Self { 89 + Self { 90 + inner, 91 + type_present: false, 92 + } 93 + } 94 + 95 + /// Returns whether the type field was present during deserialization. 96 + pub fn has_type_field(&self) -> bool { 97 + self.type_present 98 + } 99 + 100 + /// Validates that the type field is present if required. 101 + pub fn validate(&self) -> Result<(), String> { 102 + if T::type_required() && !self.type_present { 103 + return Err(format!( 104 + "Missing required $type field for {}", 105 + T::lexicon_type() 106 + )); 107 + } 108 + Ok(()) 109 + } 110 + 111 + /// Consumes the wrapper and returns the inner value. 112 + pub fn into_inner(self) -> T { 113 + self.inner 114 + } 115 + } 116 + 117 + impl<T> Serialize for TypedLexicon<T> 118 + where 119 + T: LexiconType + Serialize + PartialEq, 120 + { 121 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 122 + where 123 + S: Serializer, 124 + { 125 + // Get the serialized form of the inner value 126 + let value = serde_json::to_value(&self.inner).map_err(serde::ser::Error::custom)?; 127 + 128 + if let serde_json::Value::Object(mut map) = value { 129 + // Only add the $type field if type_present is true 130 + if self.type_present { 131 + map.insert( 132 + "$type".to_string(), 133 + serde_json::Value::String(T::lexicon_type().to_string()), 134 + ); 135 + } 136 + 137 + // Serialize the modified map 138 + let mut ser_map = serializer.serialize_map(Some(map.len()))?; 139 + for (k, v) in map { 140 + ser_map.serialize_entry(&k, &v)?; 141 + } 142 + ser_map.end() 143 + } else { 144 + // If it's not an object, just serialize as-is (shouldn't happen with proper structs) 145 + self.inner.serialize(serializer) 146 + } 147 + } 148 + } 149 + 150 + impl<'de, T> Deserialize<'de> for TypedLexicon<T> 151 + where 152 + T: LexiconType + DeserializeOwned + PartialEq, 153 + { 154 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 155 + where 156 + D: Deserializer<'de>, 157 + { 158 + struct TypedLexiconVisitor<T: LexiconType + PartialEq>(PhantomData<T>); 159 + 160 + impl<'de, T> Visitor<'de> for TypedLexiconVisitor<T> 161 + where 162 + T: LexiconType + DeserializeOwned + PartialEq, 163 + { 164 + type Value = TypedLexicon<T>; 165 + 166 + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 167 + formatter.write_str("a lexicon object") 168 + } 169 + 170 + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> 171 + where 172 + A: MapAccess<'de>, 173 + { 174 + let mut obj = serde_json::Map::new(); 175 + let mut type_found = false; 176 + let expected_type = T::lexicon_type(); 177 + 178 + while let Some((key, value)) = map.next_entry::<String, serde_json::Value>()? { 179 + if key == "$type" { 180 + type_found = true; 181 + // Validate the type matches 182 + if let serde_json::Value::String(ref type_str) = value { 183 + if type_str != expected_type { 184 + return Err(serde::de::Error::custom(format!( 185 + "Invalid $type field: expected '{}', found '{}'", 186 + expected_type, type_str 187 + ))); 188 + } 189 + } else { 190 + return Err(serde::de::Error::custom("$type field must be a string")); 191 + } 192 + // Don't include $type in the object we deserialize to T 193 + } else { 194 + obj.insert(key, value); 195 + } 196 + } 197 + 198 + // Deserialize the inner value from the cleaned object 199 + let inner = serde_json::from_value(serde_json::Value::Object(obj)) 200 + .map_err(serde::de::Error::custom)?; 201 + 202 + Ok(TypedLexicon { 203 + inner, 204 + type_present: type_found, 205 + }) 206 + } 207 + } 208 + 209 + deserializer.deserialize_map(TypedLexiconVisitor(PhantomData)) 210 + } 211 + } 212 + 213 + // Allow dereferencing to the inner type 214 + impl<T: LexiconType + PartialEq> std::ops::Deref for TypedLexicon<T> { 215 + type Target = T; 216 + 217 + fn deref(&self) -> &Self::Target { 218 + &self.inner 219 + } 220 + } 221 + 222 + impl<T: LexiconType + PartialEq> std::ops::DerefMut for TypedLexicon<T> { 223 + fn deref_mut(&mut self) -> &mut Self::Target { 224 + &mut self.inner 225 + } 226 + } 227 + 228 + #[cfg(test)] 229 + mod tests { 230 + use super::*; 231 + use serde_json::json; 232 + 233 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 234 + struct TestRecord { 235 + name: String, 236 + value: i32, 237 + } 238 + 239 + impl LexiconType for TestRecord { 240 + fn lexicon_type() -> &'static str { 241 + "test.lexicon.record" 242 + } 243 + } 244 + 245 + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] 246 + struct OptionalTypeRecord { 247 + data: String, 248 + } 249 + 250 + impl LexiconType for OptionalTypeRecord { 251 + fn lexicon_type() -> &'static str { 252 + "test.lexicon.optional" 253 + } 254 + 255 + fn type_required() -> bool { 256 + false 257 + } 258 + } 259 + 260 + #[test] 261 + fn test_serialization_adds_type() { 262 + let record = TestRecord { 263 + name: "test".to_string(), 264 + value: 42, 265 + }; 266 + let typed = TypedLexicon::new(record); 267 + 268 + let json = serde_json::to_value(&typed).unwrap(); 269 + 270 + assert_eq!(json["$type"], "test.lexicon.record"); 271 + assert_eq!(json["name"], "test"); 272 + assert_eq!(json["value"], 42); 273 + } 274 + 275 + #[test] 276 + fn test_deserialization_validates_type() { 277 + let json = json!({ 278 + "$type": "test.lexicon.record", 279 + "name": "test", 280 + "value": 42 281 + }); 282 + 283 + let typed: TypedLexicon<TestRecord> = serde_json::from_value(json).unwrap(); 284 + 285 + assert_eq!(typed.inner.name, "test"); 286 + assert_eq!(typed.inner.value, 42); 287 + assert!(typed.has_type_field()); 288 + } 289 + 290 + #[test] 291 + fn test_deserialization_without_type() { 292 + let json = json!({ 293 + "name": "test", 294 + "value": 42 295 + }); 296 + 297 + let typed: TypedLexicon<TestRecord> = serde_json::from_value(json).unwrap(); 298 + 299 + assert_eq!(typed.inner.name, "test"); 300 + assert_eq!(typed.inner.value, 42); 301 + assert!(!typed.has_type_field()); 302 + } 303 + 304 + #[test] 305 + fn test_deserialization_wrong_type() { 306 + let json = json!({ 307 + "$type": "wrong.type", 308 + "name": "test", 309 + "value": 42 310 + }); 311 + 312 + let result: Result<TypedLexicon<TestRecord>, _> = serde_json::from_value(json); 313 + assert!(result.is_err()); 314 + 315 + let err = result.unwrap_err().to_string(); 316 + assert!(err.contains("expected 'test.lexicon.record'")); 317 + assert!(err.contains("found 'wrong.type'")); 318 + } 319 + 320 + #[test] 321 + fn test_validation_required_type() { 322 + let typed = TypedLexicon::new_without_type(TestRecord { 323 + name: "test".to_string(), 324 + value: 42, 325 + }); 326 + 327 + let result = typed.validate(); 328 + assert!(result.is_err()); 329 + assert!(result.unwrap_err().contains("Missing required $type field")); 330 + } 331 + 332 + #[test] 333 + fn test_validation_optional_type() { 334 + let typed = TypedLexicon::new_without_type(OptionalTypeRecord { 335 + data: "test".to_string(), 336 + }); 337 + 338 + let result = typed.validate(); 339 + assert!(result.is_ok()); 340 + } 341 + 342 + #[test] 343 + fn test_round_trip() { 344 + let original = TestRecord { 345 + name: "round trip".to_string(), 346 + value: 123, 347 + }; 348 + let typed = TypedLexicon::new(original.clone()); 349 + 350 + let json = serde_json::to_string(&typed).unwrap(); 351 + let deserialized: TypedLexicon<TestRecord> = serde_json::from_str(&json).unwrap(); 352 + 353 + assert_eq!(deserialized.inner, original); 354 + assert!(deserialized.has_type_field()); 355 + } 356 + 357 + #[test] 358 + fn test_deref() { 359 + let typed = TypedLexicon::new(TestRecord { 360 + name: "deref test".to_string(), 361 + value: 456, 362 + }); 363 + 364 + // Can access fields through deref 365 + assert_eq!(typed.name, "deref test"); 366 + assert_eq!(typed.value, 456); 367 + } 368 + 369 + #[test] 370 + fn test_new_without_type_omits_type_field() { 371 + let record = TestRecord { 372 + name: "no type".to_string(), 373 + value: 99, 374 + }; 375 + let typed = TypedLexicon::new_without_type(record); 376 + 377 + let json = serde_json::to_value(&typed).unwrap(); 378 + 379 + // Should NOT have $type field 380 + assert!(!json.as_object().unwrap().contains_key("$type")); 381 + assert_eq!(json["name"], "no type"); 382 + assert_eq!(json["value"], 99); 383 + } 384 + 385 + #[test] 386 + fn test_round_trip_preserves_type_absence() { 387 + // Deserialize without $type 388 + let json_without_type = json!({ 389 + "name": "test", 390 + "value": 42 391 + }); 392 + 393 + let typed: TypedLexicon<TestRecord> = serde_json::from_value(json_without_type).unwrap(); 394 + assert!(!typed.has_type_field()); 395 + 396 + // Re-serialize should NOT add $type 397 + let reserialized = serde_json::to_value(&typed).unwrap(); 398 + assert!(!reserialized.as_object().unwrap().contains_key("$type")); 399 + assert_eq!(reserialized["name"], "test"); 400 + assert_eq!(reserialized["value"], 42); 401 + } 402 + 403 + #[test] 404 + fn test_round_trip_preserves_type_presence() { 405 + // Deserialize with $type 406 + let json_with_type = json!({ 407 + "$type": "test.lexicon.record", 408 + "name": "test", 409 + "value": 42 410 + }); 411 + 412 + let typed: TypedLexicon<TestRecord> = serde_json::from_value(json_with_type).unwrap(); 413 + assert!(typed.has_type_field()); 414 + 415 + // Re-serialize should preserve $type 416 + let reserialized = serde_json::to_value(&typed).unwrap(); 417 + assert_eq!(reserialized["$type"], "test.lexicon.record"); 418 + assert_eq!(reserialized["name"], "test"); 419 + assert_eq!(reserialized["value"], 42); 420 + } 421 + 422 + #[test] 423 + fn test_optional_type_record_behavior() { 424 + // Test with type_required() = false 425 + 426 + // new() should still add $type 427 + let with_type = TypedLexicon::new(OptionalTypeRecord { 428 + data: "with".to_string(), 429 + }); 430 + let json = serde_json::to_value(&with_type).unwrap(); 431 + assert_eq!(json["$type"], "test.lexicon.optional"); 432 + 433 + // new_without_type() should omit $type 434 + let without_type = TypedLexicon::new_without_type(OptionalTypeRecord { 435 + data: "without".to_string(), 436 + }); 437 + let json2 = serde_json::to_value(&without_type).unwrap(); 438 + assert!(!json2.as_object().unwrap().contains_key("$type")); 439 + 440 + // Both should validate successfully since type is optional 441 + assert!(with_type.validate().is_ok()); 442 + assert!(without_type.validate().is_ok()); 443 + } 444 + }