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