+1
Cargo.lock
+1
Cargo.lock
+1
-1
crates/atproto-oauth/src/jwk.rs
+1
-1
crates/atproto-oauth/src/jwk.rs
+1
-1
crates/atproto-record/Cargo.toml
+1
-1
crates/atproto-record/Cargo.toml
+2
-2
crates/atproto-record/src/aturi.rs
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}