+205
crates/atproto-record/src/lexicon/app_bsky_richtext_facet.rs
+205
crates/atproto-record/src/lexicon/app_bsky_richtext_facet.rs
···
1
+
//! AT Protocol rich text facet types.
2
+
//!
3
+
//! This module provides types for annotating rich text content with semantic
4
+
//! meaning, based on the `app.bsky.richtext.facet` lexicon. Facets enable
5
+
//! mentions, links, hashtags, and other structured metadata to be attached
6
+
//! to specific byte ranges within text content.
7
+
//!
8
+
//! # Overview
9
+
//!
10
+
//! Facets consist of:
11
+
//! - A byte range (start/end indices in UTF-8 encoded text)
12
+
//! - One or more features (mention, link, tag) that apply to that range
13
+
//!
14
+
//! # Example
15
+
//!
16
+
//! ```ignore
17
+
//! use atproto_record::lexicon::app::bsky::richtext::facet::{Facet, ByteSlice, FacetFeature, Mention};
18
+
//!
19
+
//! // Create a mention facet for "@alice.bsky.social"
20
+
//! let facet = Facet {
21
+
//! index: ByteSlice { byte_start: 0, byte_end: 19 },
22
+
//! features: vec![
23
+
//! FacetFeature::Mention(Mention {
24
+
//! did: "did:plc:alice123".to_string(),
25
+
//! })
26
+
//! ],
27
+
//! };
28
+
//! ```
29
+
30
+
use serde::{Deserialize, Serialize};
31
+
32
+
/// Byte range specification for facet features.
33
+
///
34
+
/// Specifies the sub-string range a facet feature applies to using
35
+
/// zero-indexed byte offsets in UTF-8 encoded text. Start index is
36
+
/// inclusive, end index is exclusive.
37
+
///
38
+
/// # Example
39
+
///
40
+
/// ```ignore
41
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::ByteSlice;
42
+
///
43
+
/// // Represents bytes 0-5 of the text
44
+
/// let slice = ByteSlice {
45
+
/// byte_start: 0,
46
+
/// byte_end: 5,
47
+
/// };
48
+
/// ```
49
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
50
+
#[serde(rename_all = "camelCase")]
51
+
pub struct ByteSlice {
52
+
/// Starting byte index (inclusive)
53
+
pub byte_start: usize,
54
+
55
+
/// Ending byte index (exclusive)
56
+
pub byte_end: usize,
57
+
}
58
+
59
+
/// Mention facet feature for referencing another account.
60
+
///
61
+
/// The text content typically displays a handle with '@' prefix (e.g., "@alice.bsky.social"),
62
+
/// but the facet reference must use the account's DID for stable identification.
63
+
///
64
+
/// # Example
65
+
///
66
+
/// ```ignore
67
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::Mention;
68
+
///
69
+
/// let mention = Mention {
70
+
/// did: "did:plc:alice123".to_string(),
71
+
/// };
72
+
/// ```
73
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
74
+
pub struct Mention {
75
+
/// DID of the mentioned account
76
+
pub did: String,
77
+
}
78
+
79
+
/// Link facet feature for URL references.
80
+
///
81
+
/// The text content may be simplified or truncated for display purposes,
82
+
/// but the facet reference should contain the complete, valid URL.
83
+
///
84
+
/// # Example
85
+
///
86
+
/// ```ignore
87
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::Link;
88
+
///
89
+
/// let link = Link {
90
+
/// uri: "https://example.com/full/path".to_string(),
91
+
/// };
92
+
/// ```
93
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
94
+
pub struct Link {
95
+
/// Complete URI/URL for the link
96
+
pub uri: String,
97
+
}
98
+
99
+
/// Tag facet feature for hashtags.
100
+
///
101
+
/// The text content typically includes a '#' prefix for display,
102
+
/// but the facet reference should contain only the tag text without the prefix.
103
+
///
104
+
/// # Example
105
+
///
106
+
/// ```ignore
107
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::Tag;
108
+
///
109
+
/// // For text "#atproto", store just "atproto"
110
+
/// let tag = Tag {
111
+
/// tag: "atproto".to_string(),
112
+
/// };
113
+
/// ```
114
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
115
+
pub struct Tag {
116
+
/// Tag text without '#' prefix
117
+
pub tag: String,
118
+
}
119
+
120
+
/// Discriminated union of facet feature types.
121
+
///
122
+
/// Represents the different types of semantic annotations that can be
123
+
/// applied to text ranges. Each variant corresponds to a specific lexicon
124
+
/// type in the `app.bsky.richtext.facet` namespace.
125
+
///
126
+
/// # Example
127
+
///
128
+
/// ```ignore
129
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::{FacetFeature, Mention, Link, Tag};
130
+
///
131
+
/// // Create different feature types
132
+
/// let mention = FacetFeature::Mention(Mention {
133
+
/// did: "did:plc:alice123".to_string(),
134
+
/// });
135
+
///
136
+
/// let link = FacetFeature::Link(Link {
137
+
/// uri: "https://example.com".to_string(),
138
+
/// });
139
+
///
140
+
/// let tag = FacetFeature::Tag(Tag {
141
+
/// tag: "rust".to_string(),
142
+
/// });
143
+
/// ```
144
+
#[derive(Serialize, Deserialize, Clone, PartialEq)]
145
+
#[cfg_attr(debug_assertions, derive(Debug))]
146
+
#[serde(tag = "$type")]
147
+
pub enum FacetFeature {
148
+
/// Account mention feature
149
+
#[serde(rename = "app.bsky.richtext.facet#mention")]
150
+
Mention(Mention),
151
+
152
+
/// URL link feature
153
+
#[serde(rename = "app.bsky.richtext.facet#link")]
154
+
Link(Link),
155
+
156
+
/// Hashtag feature
157
+
#[serde(rename = "app.bsky.richtext.facet#tag")]
158
+
Tag(Tag),
159
+
}
160
+
161
+
/// Rich text facet annotation.
162
+
///
163
+
/// Associates one or more semantic features with a specific byte range
164
+
/// within text content. Multiple features can apply to the same range
165
+
/// (e.g., a URL that is also a hashtag).
166
+
///
167
+
/// # Example
168
+
///
169
+
/// ```ignore
170
+
/// use atproto_record::lexicon::app::bsky::richtext::facet::{
171
+
/// Facet, ByteSlice, FacetFeature, Mention, Link
172
+
/// };
173
+
///
174
+
/// // Annotate "@alice.bsky.social" at bytes 0-19
175
+
/// let facet = Facet {
176
+
/// index: ByteSlice { byte_start: 0, byte_end: 19 },
177
+
/// features: vec![
178
+
/// FacetFeature::Mention(Mention {
179
+
/// did: "did:plc:alice123".to_string(),
180
+
/// }),
181
+
/// ],
182
+
/// };
183
+
///
184
+
/// // Multiple features for the same range
185
+
/// let multi_facet = Facet {
186
+
/// index: ByteSlice { byte_start: 20, byte_end: 35 },
187
+
/// features: vec![
188
+
/// FacetFeature::Link(Link {
189
+
/// uri: "https://example.com".to_string(),
190
+
/// }),
191
+
/// FacetFeature::Tag(Tag {
192
+
/// tag: "example".to_string(),
193
+
/// }),
194
+
/// ],
195
+
/// };
196
+
/// ```
197
+
#[derive(Serialize, Deserialize, Clone, PartialEq)]
198
+
#[cfg_attr(debug_assertions, derive(Debug))]
199
+
pub struct Facet {
200
+
/// Byte range this facet applies to
201
+
pub index: ByteSlice,
202
+
203
+
/// Semantic features applied to this range
204
+
pub features: Vec<FacetFeature>,
205
+
}
+19
-68
crates/atproto-record/src/lexicon/community_lexicon_attestation.rs
+19
-68
crates/atproto-record/src/lexicon/community_lexicon_attestation.rs
···
30
30
///
31
31
/// // Inline signature
32
32
/// let inline = SignatureOrRef::Inline(create_typed_signature(
33
-
/// "did:plc:issuer".to_string(),
34
33
/// Bytes { bytes: b"signature".to_vec() },
35
34
/// ));
36
35
///
···
55
54
56
55
/// Cryptographic signature structure.
57
56
///
58
-
/// Represents a signature created by an issuer (identified by DID) over
59
-
/// some data. The signature can be used to verify authenticity, authorization,
60
-
/// or other properties of the signed content.
57
+
/// Represents a cryptographic signature over some data. The signature can be
58
+
/// used to verify authenticity, authorization, or other properties of the
59
+
/// signed content.
61
60
///
62
61
/// # Fields
63
62
///
64
-
/// - `issuer`: DID of the entity that created the signature
65
63
/// - `signature`: The actual signature bytes
66
64
/// - `extra`: Additional fields that may be present in the signature
67
65
///
···
73
71
/// use std::collections::HashMap;
74
72
///
75
73
/// let sig = Signature {
76
-
/// issuer: "did:plc:example".to_string(),
77
74
/// signature: Bytes { bytes: b"signature_bytes".to_vec() },
78
75
/// extra: HashMap::new(),
79
76
/// };
···
81
78
#[derive(Deserialize, Serialize, Clone, PartialEq)]
82
79
#[cfg_attr(debug_assertions, derive(Debug))]
83
80
pub struct Signature {
84
-
/// DID of the entity that created this signature
85
-
pub issuer: String,
86
-
87
81
/// The cryptographic signature bytes
88
82
pub signature: Bytes,
89
83
···
116
110
///
117
111
/// # Arguments
118
112
///
119
-
/// * `issuer` - DID of the signature issuer
120
113
/// * `signature` - The signature bytes
121
114
///
122
115
/// # Example
···
126
119
/// use atproto_record::lexicon::Bytes;
127
120
///
128
121
/// let sig = create_typed_signature(
129
-
/// "did:plc:issuer".to_string(),
130
122
/// Bytes { bytes: b"sig_data".to_vec() },
131
123
/// );
132
124
/// ```
133
-
pub fn create_typed_signature(issuer: String, signature: Bytes) -> TypedSignature {
125
+
pub fn create_typed_signature(signature: Bytes) -> TypedSignature {
134
126
TypedLexicon::new(Signature {
135
-
issuer,
136
127
signature,
137
128
extra: HashMap::new(),
138
129
})
···
150
141
let json_str = r#"{
151
142
"$type": "community.lexicon.attestation.signature",
152
143
"issuedAt": "2025-08-19T20:17:17.133Z",
153
-
"issuer": "did:web:acudo-dev.smokesignal.tools",
154
144
"signature": {
155
145
"$bytes": "mr9c0MCu3g6SXNQ25JFhzfX1ecYgK9k1Kf6OZI2p2AlQRoQu09dOE7J5uaeilIx/UFCjJErO89C/uBBb9ANmUA"
156
146
}
···
160
150
let typed_sig_result: Result<TypedSignature, _> = serde_json::from_str(json_str);
161
151
match &typed_sig_result {
162
152
Ok(sig) => {
163
-
println!("TypedSignature OK: issuer={}", sig.inner.issuer);
164
-
assert_eq!(sig.inner.issuer, "did:web:acudo-dev.smokesignal.tools");
153
+
println!("TypedSignature OK: signature bytes len={}", sig.inner.signature.bytes.len());
154
+
assert_eq!(sig.inner.signature.bytes.len(), 64);
165
155
}
166
156
Err(e) => {
167
157
eprintln!("TypedSignature deserialization error: {}", e);
···
172
162
let sig_or_ref_result: Result<SignatureOrRef, _> = serde_json::from_str(json_str);
173
163
match &sig_or_ref_result {
174
164
Ok(SignatureOrRef::Inline(sig)) => {
175
-
println!("SignatureOrRef OK (Inline): issuer={}", sig.inner.issuer);
176
-
assert_eq!(sig.inner.issuer, "did:web:acudo-dev.smokesignal.tools");
165
+
println!("SignatureOrRef OK (Inline): signature bytes len={}", sig.inner.signature.bytes.len());
166
+
assert_eq!(sig.inner.signature.bytes.len(), 64);
177
167
}
178
168
Ok(SignatureOrRef::Reference(_)) => {
179
169
panic!("Expected Inline signature, got Reference");
···
186
176
// Try without $type field
187
177
let json_no_type = r#"{
188
178
"issuedAt": "2025-08-19T20:17:17.133Z",
189
-
"issuer": "did:web:acudo-dev.smokesignal.tools",
190
179
"signature": {
191
180
"$bytes": "mr9c0MCu3g6SXNQ25JFhzfX1ecYgK9k1Kf6OZI2p2AlQRoQu09dOE7J5uaeilIx/UFCjJErO89C/uBBb9ANmUA"
192
181
}
···
195
184
let no_type_result: Result<Signature, _> = serde_json::from_str(json_no_type);
196
185
match &no_type_result {
197
186
Ok(sig) => {
198
-
println!("Signature (no type) OK: issuer={}", sig.issuer);
199
-
assert_eq!(sig.issuer, "did:web:acudo-dev.smokesignal.tools");
187
+
println!("Signature (no type) OK: signature bytes len={}", sig.signature.bytes.len());
200
188
assert_eq!(sig.signature.bytes.len(), 64);
201
189
202
190
// Now wrap it in TypedLexicon and try as SignatureOrRef
···
220
208
fn test_signature_deserialization() {
221
209
let json_str = r#"{
222
210
"$type": "community.lexicon.attestation.signature",
223
-
"issuer": "did:plc:test123",
224
211
"signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="}
225
212
}"#;
226
213
227
214
let signature: Signature = serde_json::from_str(json_str).unwrap();
228
215
229
-
assert_eq!(signature.issuer, "did:plc:test123");
230
216
assert_eq!(signature.signature.bytes, b"test signature");
231
217
// The $type field will be captured in extra due to #[serde(flatten)]
232
218
assert_eq!(signature.extra.len(), 1);
···
237
223
fn test_signature_deserialization_with_extra_fields() {
238
224
let json_str = r#"{
239
225
"$type": "community.lexicon.attestation.signature",
240
-
"issuer": "did:plc:test123",
241
226
"signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="},
242
227
"issuedAt": "2024-01-01T00:00:00.000Z",
243
228
"purpose": "verification"
···
245
230
246
231
let signature: Signature = serde_json::from_str(json_str).unwrap();
247
232
248
-
assert_eq!(signature.issuer, "did:plc:test123");
249
233
assert_eq!(signature.signature.bytes, b"test signature");
250
234
// 3 extra fields: $type, issuedAt, purpose
251
235
assert_eq!(signature.extra.len(), 3);
···
263
247
extra.insert("custom_field".to_string(), json!("custom_value"));
264
248
265
249
let signature = Signature {
266
-
issuer: "did:plc:serializer".to_string(),
267
250
signature: Bytes {
268
251
bytes: b"hello world".to_vec(),
269
252
},
···
274
257
275
258
// Without custom Serialize impl, $type is not automatically added
276
259
assert!(!json.as_object().unwrap().contains_key("$type"));
277
-
assert_eq!(json["issuer"], "did:plc:serializer");
278
260
// "hello world" base64 encoded is "aGVsbG8gd29ybGQ="
279
261
assert_eq!(json["signature"]["$bytes"], "aGVsbG8gd29ybGQ=");
280
262
assert_eq!(json["custom_field"], "custom_value");
···
283
265
#[test]
284
266
fn test_signature_round_trip() {
285
267
let original = Signature {
286
-
issuer: "did:plc:roundtrip".to_string(),
287
268
signature: Bytes {
288
269
bytes: b"round trip test".to_vec(),
289
270
},
···
296
277
// Deserialize back
297
278
let deserialized: Signature = serde_json::from_str(&json).unwrap();
298
279
299
-
assert_eq!(original.issuer, deserialized.issuer);
300
280
assert_eq!(original.signature.bytes, deserialized.signature.bytes);
301
281
// Without the custom Serialize impl, no $type is added
302
282
// so the round-trip preserves the empty extra map
···
317
297
extra.insert("tags".to_string(), json!(["tag1", "tag2", "tag3"]));
318
298
319
299
let signature = Signature {
320
-
issuer: "did:plc:complex".to_string(),
321
300
signature: Bytes {
322
301
bytes: vec![0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA],
323
302
},
···
328
307
329
308
// Without custom Serialize impl, $type is not automatically added
330
309
assert!(!json.as_object().unwrap().contains_key("$type"));
331
-
assert_eq!(json["issuer"], "did:plc:complex");
332
310
assert_eq!(json["timestamp"], 1234567890);
333
311
assert_eq!(json["metadata"]["version"], "1.0");
334
312
assert_eq!(json["metadata"]["algorithm"], "ES256");
···
338
316
#[test]
339
317
fn test_empty_signature() {
340
318
let signature = Signature {
341
-
issuer: String::new(),
342
319
signature: Bytes { bytes: Vec::new() },
343
320
extra: HashMap::new(),
344
321
};
···
347
324
348
325
// Without custom Serialize impl, $type is not automatically added
349
326
assert!(!json.as_object().unwrap().contains_key("$type"));
350
-
assert_eq!(json["issuer"], "");
351
327
assert_eq!(json["signature"]["$bytes"], ""); // Empty bytes encode to empty string
352
328
}
353
329
···
356
332
// Test with plain Vec<Signature> for basic signature serialization
357
333
let signatures: Vec<Signature> = vec![
358
334
Signature {
359
-
issuer: "did:plc:first".to_string(),
360
335
signature: Bytes {
361
336
bytes: b"first".to_vec(),
362
337
},
363
338
extra: HashMap::new(),
364
339
},
365
340
Signature {
366
-
issuer: "did:plc:second".to_string(),
367
341
signature: Bytes {
368
342
bytes: b"second".to_vec(),
369
343
},
···
375
349
376
350
assert!(json.is_array());
377
351
assert_eq!(json.as_array().unwrap().len(), 2);
378
-
assert_eq!(json[0]["issuer"], "did:plc:first");
379
-
assert_eq!(json[1]["issuer"], "did:plc:second");
352
+
assert_eq!(json[0]["signature"]["$bytes"], "Zmlyc3Q="); // "first" in base64
353
+
assert_eq!(json[1]["signature"]["$bytes"], "c2Vjb25k"); // "second" in base64
380
354
}
381
355
382
356
#[test]
···
384
358
// Test the new Signatures type with inline signatures
385
359
let signatures: Signatures = vec![
386
360
SignatureOrRef::Inline(create_typed_signature(
387
-
"did:plc:first".to_string(),
388
361
Bytes {
389
362
bytes: b"first".to_vec(),
390
363
},
391
364
)),
392
365
SignatureOrRef::Inline(create_typed_signature(
393
-
"did:plc:second".to_string(),
394
366
Bytes {
395
367
bytes: b"second".to_vec(),
396
368
},
···
402
374
assert!(json.is_array());
403
375
assert_eq!(json.as_array().unwrap().len(), 2);
404
376
assert_eq!(json[0]["$type"], "community.lexicon.attestation.signature");
405
-
assert_eq!(json[0]["issuer"], "did:plc:first");
377
+
assert_eq!(json[0]["signature"]["$bytes"], "Zmlyc3Q="); // "first" in base64
406
378
assert_eq!(json[1]["$type"], "community.lexicon.attestation.signature");
407
-
assert_eq!(json[1]["issuer"], "did:plc:second");
379
+
assert_eq!(json[1]["signature"]["$bytes"], "c2Vjb25k"); // "second" in base64
408
380
}
409
381
410
382
#[test]
411
383
fn test_typed_signature_serialization() {
412
384
let typed_sig = create_typed_signature(
413
-
"did:plc:typed".to_string(),
414
385
Bytes {
415
386
bytes: b"typed signature".to_vec(),
416
387
},
···
419
390
let json = serde_json::to_value(&typed_sig).unwrap();
420
391
421
392
assert_eq!(json["$type"], "community.lexicon.attestation.signature");
422
-
assert_eq!(json["issuer"], "did:plc:typed");
423
393
// "typed signature" base64 encoded
424
394
assert_eq!(json["signature"]["$bytes"], "dHlwZWQgc2lnbmF0dXJl");
425
395
}
···
428
398
fn test_typed_signature_deserialization() {
429
399
let json = json!({
430
400
"$type": "community.lexicon.attestation.signature",
431
-
"issuer": "did:plc:typed",
432
401
"signature": {"$bytes": "dHlwZWQgc2lnbmF0dXJl"}
433
402
});
434
403
435
404
let typed_sig: TypedSignature = serde_json::from_value(json).unwrap();
436
405
437
-
assert_eq!(typed_sig.inner.issuer, "did:plc:typed");
438
406
assert_eq!(typed_sig.inner.signature.bytes, b"typed signature");
439
407
assert!(typed_sig.has_type_field());
440
408
assert!(typed_sig.validate().is_ok());
···
443
411
#[test]
444
412
fn test_typed_signature_without_type_field() {
445
413
let json = json!({
446
-
"issuer": "did:plc:notype",
447
414
"signature": {"$bytes": "bm8gdHlwZQ=="} // "no type" in base64
448
415
});
449
416
450
417
let typed_sig: TypedSignature = serde_json::from_value(json).unwrap();
451
418
452
-
assert_eq!(typed_sig.inner.issuer, "did:plc:notype");
453
419
assert_eq!(typed_sig.inner.signature.bytes, b"no type");
454
420
assert!(!typed_sig.has_type_field());
455
421
// Validation should still pass because type_required() returns false for Signature
···
459
425
#[test]
460
426
fn test_typed_signature_with_extra_fields() {
461
427
let mut sig = Signature {
462
-
issuer: "did:plc:extra".to_string(),
463
428
signature: Bytes {
464
429
bytes: b"extra test".to_vec(),
465
430
},
···
474
439
let json = serde_json::to_value(&typed_sig).unwrap();
475
440
476
441
assert_eq!(json["$type"], "community.lexicon.attestation.signature");
477
-
assert_eq!(json["issuer"], "did:plc:extra");
478
442
assert_eq!(json["customField"], "customValue");
479
443
assert_eq!(json["timestamp"], 1234567890);
480
444
}
···
482
446
#[test]
483
447
fn test_typed_signature_round_trip() {
484
448
let original = Signature {
485
-
issuer: "did:plc:roundtrip2".to_string(),
486
449
signature: Bytes {
487
450
bytes: b"round trip typed".to_vec(),
488
451
},
···
494
457
let json = serde_json::to_string(&typed).unwrap();
495
458
let deserialized: TypedSignature = serde_json::from_str(&json).unwrap();
496
459
497
-
assert_eq!(deserialized.inner.issuer, original.issuer);
498
460
assert_eq!(deserialized.inner.signature.bytes, original.signature.bytes);
499
461
assert!(deserialized.has_type_field());
500
462
}
···
503
465
fn test_typed_signatures_vec() {
504
466
let typed_sigs: Vec<TypedSignature> = vec![
505
467
create_typed_signature(
506
-
"did:plc:first".to_string(),
507
468
Bytes {
508
469
bytes: b"first".to_vec(),
509
470
},
510
471
),
511
472
create_typed_signature(
512
-
"did:plc:second".to_string(),
513
473
Bytes {
514
474
bytes: b"second".to_vec(),
515
475
},
···
520
480
521
481
assert!(json.is_array());
522
482
assert_eq!(json[0]["$type"], "community.lexicon.attestation.signature");
523
-
assert_eq!(json[0]["issuer"], "did:plc:first");
483
+
assert_eq!(json[0]["signature"]["$bytes"], "Zmlyc3Q="); // "first" in base64
524
484
assert_eq!(json[1]["$type"], "community.lexicon.attestation.signature");
525
-
assert_eq!(json[1]["issuer"], "did:plc:second");
485
+
assert_eq!(json[1]["signature"]["$bytes"], "c2Vjb25k"); // "second" in base64
526
486
}
527
487
528
488
#[test]
529
489
fn test_plain_vs_typed_signature() {
530
490
// Plain Signature doesn't include $type field
531
491
let plain_sig = Signature {
532
-
issuer: "did:plc:plain".to_string(),
533
492
signature: Bytes {
534
493
bytes: b"plain sig".to_vec(),
535
494
},
···
548
507
);
549
508
550
509
// Both have the same core data
551
-
assert_eq!(plain_json["issuer"], typed_json["issuer"]);
552
510
assert_eq!(plain_json["signature"], typed_json["signature"]);
553
511
}
554
512
···
556
514
fn test_signature_or_ref_inline() {
557
515
// Test inline signature
558
516
let inline_sig = create_typed_signature(
559
-
"did:plc:inline".to_string(),
560
517
Bytes {
561
518
bytes: b"inline signature".to_vec(),
562
519
},
···
567
524
// Serialize
568
525
let json = serde_json::to_value(&sig_or_ref).unwrap();
569
526
assert_eq!(json["$type"], "community.lexicon.attestation.signature");
570
-
assert_eq!(json["issuer"], "did:plc:inline");
571
527
assert_eq!(json["signature"]["$bytes"], "aW5saW5lIHNpZ25hdHVyZQ=="); // "inline signature" in base64
572
528
573
529
// Deserialize
574
530
let deserialized: SignatureOrRef = serde_json::from_value(json.clone()).unwrap();
575
531
match deserialized {
576
532
SignatureOrRef::Inline(sig) => {
577
-
assert_eq!(sig.inner.issuer, "did:plc:inline");
578
533
assert_eq!(sig.inner.signature.bytes, b"inline signature");
579
534
}
580
535
_ => panic!("Expected inline signature"),
···
621
576
let signatures: Signatures = vec![
622
577
// Inline signature
623
578
SignatureOrRef::Inline(create_typed_signature(
624
-
"did:plc:signer1".to_string(),
625
579
Bytes {
626
580
bytes: b"sig1".to_vec(),
627
581
},
···
633
587
})),
634
588
// Another inline signature
635
589
SignatureOrRef::Inline(create_typed_signature(
636
-
"did:plc:signer3".to_string(),
637
590
Bytes {
638
591
bytes: b"sig3".to_vec(),
639
592
},
···
648
601
649
602
// First element should be inline signature
650
603
assert_eq!(array[0]["$type"], "community.lexicon.attestation.signature");
651
-
assert_eq!(array[0]["issuer"], "did:plc:signer1");
604
+
assert_eq!(array[0]["signature"]["$bytes"], "c2lnMQ=="); // "sig1" in base64
652
605
653
606
// Second element should be reference
654
607
assert_eq!(array[1]["$type"], "com.atproto.repo.strongRef");
···
659
612
660
613
// Third element should be inline signature
661
614
assert_eq!(array[2]["$type"], "community.lexicon.attestation.signature");
662
-
assert_eq!(array[2]["issuer"], "did:plc:signer3");
615
+
assert_eq!(array[2]["signature"]["$bytes"], "c2lnMw=="); // "sig3" in base64
663
616
664
617
// Deserialize back
665
618
let deserialized: Signatures = serde_json::from_value(json).unwrap();
···
667
620
668
621
// Verify each element
669
622
match &deserialized[0] {
670
-
SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.issuer, "did:plc:signer1"),
623
+
SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.signature.bytes, b"sig1"),
671
624
_ => panic!("Expected inline signature at index 0"),
672
625
}
673
626
···
682
635
}
683
636
684
637
match &deserialized[2] {
685
-
SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.issuer, "did:plc:signer3"),
638
+
SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.signature.bytes, b"sig3"),
686
639
_ => panic!("Expected inline signature at index 2"),
687
640
}
688
641
}
···
694
647
// Inline signature JSON
695
648
let inline_json = r#"{
696
649
"$type": "community.lexicon.attestation.signature",
697
-
"issuer": "did:plc:testinline",
698
650
"signature": {"$bytes": "aGVsbG8="}
699
651
}"#;
700
652
701
653
let inline_deser: SignatureOrRef = serde_json::from_str(inline_json).unwrap();
702
654
match inline_deser {
703
655
SignatureOrRef::Inline(sig) => {
704
-
assert_eq!(sig.inner.issuer, "did:plc:testinline");
705
656
assert_eq!(sig.inner.signature.bytes, b"hello");
706
657
}
707
658
_ => panic!("Expected inline signature"),
+1
-2
crates/atproto-record/src/lexicon/community_lexicon_badge.rs
+1
-2
crates/atproto-record/src/lexicon/community_lexicon_badge.rs
···
311
311
// The signature should be inline in this test
312
312
match sig_or_ref {
313
313
crate::lexicon::community_lexicon_attestation::SignatureOrRef::Inline(sig) => {
314
-
assert_eq!(sig.issuer, "did:plc:issuer");
315
314
// The bytes should match the decoded base64 value
316
315
// "dGVzdCBzaWduYXR1cmU=" decodes to "test signature"
317
-
assert_eq!(sig.signature.bytes, b"test signature".to_vec());
316
+
assert_eq!(sig.inner.signature.bytes, b"test signature".to_vec());
318
317
}
319
318
_ => panic!("Expected inline signature"),
320
319
}
+43
-9
crates/atproto-record/src/lexicon/community_lexicon_calendar_event.rs
+43
-9
crates/atproto-record/src/lexicon/community_lexicon_calendar_event.rs
···
10
10
11
11
use crate::datetime::format as datetime_format;
12
12
use crate::datetime::optional_format as optional_datetime_format;
13
+
use crate::lexicon::app::bsky::richtext::facet::Facet;
13
14
use crate::lexicon::TypedBlob;
14
15
use crate::lexicon::community::lexicon::location::Locations;
15
16
use crate::typed::{LexiconType, TypedLexicon};
16
17
17
-
/// The namespace identifier for events
18
+
/// Lexicon namespace identifier for calendar events.
19
+
///
20
+
/// Used as the `$type` field value for event records in the AT Protocol.
18
21
pub const NSID: &str = "community.lexicon.calendar.event";
19
22
20
23
/// Event status enumeration.
···
65
68
Hybrid,
66
69
}
67
70
68
-
/// The namespace identifier for named URIs
71
+
/// Lexicon namespace identifier for named URIs in calendar events.
72
+
///
73
+
/// Used as the `$type` field value for URI references associated with events.
69
74
pub const NAMED_URI_NSID: &str = "community.lexicon.calendar.event#uri";
70
75
71
76
/// Named URI structure.
···
89
94
}
90
95
}
91
96
92
-
/// Type alias for NamedUri with automatic $type field handling
97
+
/// Type alias for NamedUri with automatic $type field handling.
98
+
///
99
+
/// Wraps `NamedUri` in `TypedLexicon` to ensure proper serialization
100
+
/// and deserialization of the `$type` field.
93
101
pub type TypedNamedUri = TypedLexicon<NamedUri>;
94
102
95
-
/// The namespace identifier for event links
103
+
/// Lexicon namespace identifier for event links.
104
+
///
105
+
/// Used as the `$type` field value for event link references.
106
+
/// Note: This shares the same NSID as `NAMED_URI_NSID` for compatibility.
96
107
pub const EVENT_LINK_NSID: &str = "community.lexicon.calendar.event#uri";
97
108
98
109
/// Event link structure.
···
116
127
}
117
128
}
118
129
119
-
/// Type alias for EventLink with automatic $type field handling
130
+
/// Type alias for EventLink with automatic $type field handling.
131
+
///
132
+
/// Wraps `EventLink` in `TypedLexicon` to ensure proper serialization
133
+
/// and deserialization of the `$type` field.
120
134
pub type TypedEventLink = TypedLexicon<EventLink>;
121
135
122
-
/// A vector of typed event links
136
+
/// Collection of typed event links.
137
+
///
138
+
/// Represents multiple URI references associated with an event,
139
+
/// such as registration pages, live streams, or related content.
123
140
pub type EventLinks = Vec<TypedEventLink>;
124
141
125
142
/// Aspect ratio for media content.
···
134
151
pub height: u64,
135
152
}
136
153
137
-
/// The namespace identifier for media
154
+
/// Lexicon namespace identifier for event media.
155
+
///
156
+
/// Used as the `$type` field value for media attachments associated with events.
138
157
pub const MEDIA_NSID: &str = "community.lexicon.calendar.event#media";
139
158
140
159
/// Media structure for event-related visual content.
···
163
182
}
164
183
}
165
184
166
-
/// Type alias for Media with automatic $type field handling
185
+
/// Type alias for Media with automatic $type field handling.
186
+
///
187
+
/// Wraps `Media` in `TypedLexicon` to ensure proper serialization
188
+
/// and deserialization of the `$type` field.
167
189
pub type TypedMedia = TypedLexicon<Media>;
168
190
169
-
/// A vector of typed media items
191
+
/// Collection of typed media items.
192
+
///
193
+
/// Represents multiple media attachments for an event, such as banners,
194
+
/// posters, thumbnails, or promotional images.
170
195
pub type MediaList = Vec<TypedMedia>;
171
196
172
197
/// Calendar event structure.
···
248
273
#[serde(skip_serializing_if = "Vec::is_empty", default)]
249
274
pub media: MediaList,
250
275
276
+
/// Rich text facets for semantic annotations in description field.
277
+
///
278
+
/// Enables mentions, links, and hashtags to be embedded in the event
279
+
/// description text with proper semantic metadata.
280
+
#[serde(skip_serializing_if = "Option::is_none")]
281
+
pub facets: Option<Vec<Facet>>,
282
+
251
283
/// Extension fields for forward compatibility.
252
284
/// This catch-all allows unknown fields to be preserved and indexed
253
285
/// for potential future use without requiring re-indexing.
···
312
344
locations: vec![],
313
345
uris: vec![],
314
346
media: vec![],
347
+
facets: None,
315
348
extra: HashMap::new(),
316
349
};
317
350
···
466
499
locations: vec![],
467
500
uris: vec![TypedLexicon::new(event_link)],
468
501
media: vec![TypedLexicon::new(media)],
502
+
facets: None,
469
503
extra: HashMap::new(),
470
504
};
471
505
-3
crates/atproto-record/src/lexicon/community_lexicon_calendar_rsvp.rs
-3
crates/atproto-record/src/lexicon/community_lexicon_calendar_rsvp.rs
···
294
294
assert_eq!(typed_rsvp.inner.signatures.len(), 1);
295
295
match &typed_rsvp.inner.signatures[0] {
296
296
SignatureOrRef::Inline(sig) => {
297
-
assert_eq!(sig.inner.issuer, "did:plc:issuer");
298
297
assert_eq!(sig.inner.signature.bytes, b"test signature");
299
298
}
300
299
_ => panic!("Expected inline signature"),
···
364
363
assert_eq!(typed_rsvp.inner.signatures.len(), 1);
365
364
match &typed_rsvp.inner.signatures[0] {
366
365
SignatureOrRef::Inline(sig) => {
367
-
assert_eq!(sig.inner.issuer, "did:web:acudo-dev.smokesignal.tools");
368
-
369
366
// Verify the issuedAt field if present
370
367
if let Some(issued_at_value) = sig.inner.extra.get("issuedAt") {
371
368
assert_eq!(issued_at_value, "2025-08-19T20:17:17.133Z");
+22
crates/atproto-record/src/lexicon/mod.rs
+22
crates/atproto-record/src/lexicon/mod.rs
···
37
37
mod community_lexicon_calendar_event;
38
38
mod community_lexicon_calendar_rsvp;
39
39
mod community_lexicon_location;
40
+
mod app_bsky_richtext_facet;
40
41
mod primatives;
41
42
43
+
// Re-export primitive types for convenience
42
44
pub use primatives::*;
45
+
46
+
/// Bluesky application namespace.
47
+
///
48
+
/// Contains lexicon types specific to the Bluesky application,
49
+
/// including rich text formatting and social features.
50
+
pub mod app {
51
+
/// Bluesky namespace.
52
+
pub mod bsky {
53
+
/// Rich text formatting types.
54
+
pub mod richtext {
55
+
/// Facet types for semantic text annotations.
56
+
///
57
+
/// Provides types for mentions, links, hashtags, and other
58
+
/// structured metadata that can be attached to text content.
59
+
pub mod facet {
60
+
pub use crate::lexicon::app_bsky_richtext_facet::*;
61
+
}
62
+
}
63
+
}
64
+
}
43
65
44
66
/// AT Protocol core types namespace
45
67
pub mod com {