···11+# mlf-lexicon-fetcher
22+33+ATProto Lexicon fetcher with DNS resolution and HTTP client.
44+55+Resolves lexicon NSIDs to DIDs via DNS TXT records and fetches lexicon JSON from ATProto repositories.
66+77+## Features
88+99+- **DNS Resolution**: Resolves NSIDs to DIDs using DNS TXT records (RFC spec)
1010+- **HTTP Fetching**: Fetches lexicons from ATProto repositories via XRPC
1111+- **Pattern Matching**: Supports wildcard patterns (`.*` and `._`)
1212+- **Network Optimization**: Groups similar NSIDs to reduce HTTP requests
1313+- **Lockfile Support**: Fetches from known DIDs, bypassing DNS resolution
1414+- **Testable**: Mock DNS and HTTP clients for testing
1515+1616+## Usage
1717+1818+### Basic Fetching
1919+2020+```rust
2121+use mlf_lexicon_fetcher::ProductionLexiconFetcher;
2222+2323+#[tokio::main]
2424+async fn main() -> Result<(), Box<dyn std::error::Error>> {
2525+ // Create a production fetcher (real DNS + HTTP)
2626+ let fetcher = ProductionLexiconFetcher::production().await?;
2727+2828+ // Fetch a single lexicon with metadata
2929+ let result = fetcher.fetch_with_metadata("app.bsky.feed.post").await?;
3030+3131+ for lexicon in result.lexicons {
3232+ println!("NSID: {}", lexicon.nsid);
3333+ println!("DID: {}", lexicon.did);
3434+ println!("Lexicon: {}", serde_json::to_string_pretty(&lexicon.lexicon)?);
3535+ }
3636+3737+ Ok(())
3838+}
3939+```
4040+4141+### Pattern Matching
4242+4343+```rust
4444+// Fetch all lexicons under a namespace
4545+let result = fetcher.fetch_with_metadata("app.bsky.feed.*").await?;
4646+println!("Fetched {} lexicons", result.lexicons.len());
4747+4848+// Fetch direct children only
4949+let result = fetcher.fetch_with_metadata("app.bsky._").await?;
5050+```
5151+5252+### Optimized Batch Fetching
5353+5454+```rust
5555+use mlf_lexicon_fetcher::ProductionLexiconFetcher;
5656+5757+// Fetch many NSIDs with automatic optimization
5858+let nsids = vec![
5959+ "app.bsky.actor.profile".to_string(),
6060+ "app.bsky.actor.defs".to_string(),
6161+ "app.bsky.feed.post".to_string(),
6262+ "app.bsky.feed.like".to_string(),
6363+];
6464+6565+// Automatically groups into patterns like "app.bsky.actor.*" and "app.bsky.feed.*"
6666+let results = fetcher.fetch_many_optimized(&nsids).await?;
6767+```
6868+6969+### Lockfile Mode (Skip DNS)
7070+7171+```rust
7272+// When you already have the DID (e.g., from a lockfile)
7373+let result = fetcher.fetch_from_did_with_metadata(
7474+ "did:plc:abc123",
7575+ "app.bsky.feed.post"
7676+).await?;
7777+```
7878+7979+### Advanced: Pattern Optimization
8080+8181+```rust
8282+use std::collections::HashSet;
8383+use mlf_lexicon_fetcher::optimize_fetch_patterns;
8484+8585+let nsids: HashSet<String> = vec![
8686+ "app.bsky.actor.foo".to_string(),
8787+ "app.bsky.actor.bar".to_string(),
8888+ "app.bsky.feed.post".to_string(),
8989+].into_iter().collect();
9090+9191+// Returns: ["app.bsky.actor.*", "app.bsky.feed.post"]
9292+let optimized = optimize_fetch_patterns(&nsids);
9393+```
9494+9595+## API
9696+9797+### Main Types
9898+9999+- `ProductionLexiconFetcher` - Ready-to-use fetcher with real DNS and HTTP
100100+- `LexiconFetcher<D, H>` - Generic fetcher (for custom DNS/HTTP implementations)
101101+- `FetchResult` - Contains fetched lexicons with metadata (NSID, DID, JSON)
102102+103103+### Key Methods
104104+105105+- `fetch_with_metadata(nsid)` - Fetch single/pattern, returns metadata
106106+- `fetch_many(nsids)` - Fetch multiple NSIDs sequentially
107107+- `fetch_many_optimized(nsids)` - Fetch multiple NSIDs with pattern optimization
108108+- `fetch_from_did_with_metadata(did, nsid)` - Skip DNS, fetch from known DID
109109+110110+### Utilities
111111+112112+- `optimize_fetch_patterns(nsids)` - Group NSIDs into minimal patterns
113113+- `parse_nsid(nsid)` - Parse NSID into authority and name segments
114114+- `construct_dns_name(authority, name)` - Build DNS TXT record name
115115+116116+## Testing
117117+118118+Use mock implementations for testing:
119119+120120+```rust
121121+use mlf_lexicon_fetcher::{LexiconFetcher, MockDnsResolver, MockHttpClient};
122122+123123+let mut dns = MockDnsResolver::new();
124124+dns.add_record("app.bsky", "feed", "did:plc:test".to_string());
125125+126126+let mut http = MockHttpClient::new();
127127+http.add_lexicon(
128128+ "app.bsky.feed.post".to_string(),
129129+ serde_json::json!({"lexicon": 1, "id": "app.bsky.feed.post"})
130130+);
131131+132132+let fetcher = LexiconFetcher::new(dns, http);
133133+```
134134+135135+## License
136136+137137+MIT
+75-13
mlf-lexicon-fetcher/examples/usage.rs
···5566#[tokio::main]
77async fn main() -> Result<(), Box<dyn std::error::Error>> {
88- // Example 1: Fetch a single lexicon
99- println!("=== Example 1: Fetch Single Lexicon ===");
88+ // Example 1: Fetch with metadata (NEW!)
99+ println!("=== Example 1: Fetch with Metadata ===");
10101111 let mut dns_resolver = MockDnsResolver::new();
1212 dns_resolver.add_record("place.stream", "chat.profile", "did:plc:test123".to_string());
···28282929 let fetcher = LexiconFetcher::new(dns_resolver, http_client);
30303131- match fetcher.fetch("place.stream.chat.profile").await {
3232- Ok(lexicon) => {
3333- println!("Successfully fetched lexicon:");
3434- println!("{}", serde_json::to_string_pretty(&lexicon)?);
3131+ // New API returns metadata (DID, NSID, lexicon)
3232+ match fetcher.fetch_with_metadata("place.stream.chat.profile").await {
3333+ Ok(result) => {
3434+ println!("Successfully fetched {} lexicon(s):", result.lexicons.len());
3535+ for fetched in result.lexicons {
3636+ println!(" NSID: {}", fetched.nsid);
3737+ println!(" DID: {}", fetched.did);
3838+ println!(" Lexicon: {}", serde_json::to_string_pretty(&fetched.lexicon)?);
3939+ }
3540 }
3641 Err(e) => eprintln!("Error: {}", e),
3742 }
38433939- // Example 2: Fetch multiple lexicons with a pattern
4040- println!("\n=== Example 2: Fetch Multiple Lexicons with Pattern ===");
4444+ // Example 2: Fetch multiple with pattern
4545+ println!("\n=== Example 2: Fetch Multiple with Pattern ===");
41464247 let mut dns_resolver2 = MockDnsResolver::new();
4348 dns_resolver2.add_record("app.bsky", "feed", "did:plc:bsky123".to_string());
···58635964 let fetcher2 = LexiconFetcher::new(dns_resolver2, http_client2);
60656161- match fetcher2.fetch_pattern("app.bsky.feed.*").await {
6262- Ok(lexicons) => {
6363- println!("Successfully fetched {} lexicons:", lexicons.len());
6464- for (nsid, _lexicon) in lexicons {
6565- println!(" - {}", nsid);
6666+ match fetcher2.fetch_with_metadata("app.bsky.feed.*").await {
6767+ Ok(result) => {
6868+ println!("Successfully fetched {} lexicons:", result.lexicons.len());
6969+ for fetched in result.lexicons {
7070+ println!(" - {} (from {})", fetched.nsid, fetched.did);
7171+ }
7272+ }
7373+ Err(e) => eprintln!("Error: {}", e),
7474+ }
7575+7676+ // Example 3: Optimized batch fetching (NEW!)
7777+ println!("\n=== Example 3: Optimized Batch Fetching ===");
7878+7979+ let mut dns_resolver3 = MockDnsResolver::new();
8080+ dns_resolver3.add_record("app.bsky", "actor", "did:plc:bsky123".to_string());
8181+8282+ let mut http_client3 = MockHttpClient::new();
8383+ http_client3.add_lexicon(
8484+ "app.bsky.actor.profile".to_string(),
8585+ json!({"lexicon": 1, "id": "app.bsky.actor.profile"}),
8686+ );
8787+ http_client3.add_lexicon(
8888+ "app.bsky.actor.defs".to_string(),
8989+ json!({"lexicon": 1, "id": "app.bsky.actor.defs"}),
9090+ );
9191+9292+ let fetcher3 = LexiconFetcher::new(dns_resolver3, http_client3);
9393+9494+ let nsids = vec![
9595+ "app.bsky.actor.profile".to_string(),
9696+ "app.bsky.actor.defs".to_string(),
9797+ ];
9898+9999+ // Automatically optimizes to "app.bsky.actor.*" pattern
100100+ match fetcher3.fetch_many_optimized(&nsids).await {
101101+ Ok(results) => {
102102+ println!("Optimized fetch completed:");
103103+ for result in results {
104104+ println!(" Batch of {} lexicons", result.lexicons.len());
105105+ }
106106+ }
107107+ Err(e) => eprintln!("Error: {}", e),
108108+ }
109109+110110+ // Example 4: Fetch from known DID (NEW!)
111111+ println!("\n=== Example 4: Fetch from Known DID (Lockfile Mode) ===");
112112+113113+ let dns_resolver4 = MockDnsResolver::new();
114114+ let mut http_client4 = MockHttpClient::new();
115115+ http_client4.add_lexicon(
116116+ "app.bsky.feed.post".to_string(),
117117+ json!({"lexicon": 1, "id": "app.bsky.feed.post"}),
118118+ );
119119+120120+ let fetcher4 = LexiconFetcher::new(dns_resolver4, http_client4);
121121+122122+ // Skip DNS resolution when DID is already known (e.g., from lockfile)
123123+ match fetcher4.fetch_from_did_with_metadata("did:plc:bsky123", "app.bsky.feed.post").await {
124124+ Ok(result) => {
125125+ println!("Fetched from known DID:");
126126+ for fetched in result.lexicons {
127127+ println!(" - {} (bypassed DNS)", fetched.nsid);
66128 }
67129 }
68130 Err(e) => eprintln!("Error: {}", e),
+22-3
website/content/docs/cli/07-fetch.md
···3131**Arguments:**
3232- `[NSID]` - Optional NSID or pattern to fetch:
3333 - Specific lexicon: `com.example.forum.post`
3434- - Wildcard pattern: `com.example.forum.*`
3434+ - All descendants: `com.example.forum.*` (matches `post`, `comment`, `comment.reply`, etc.)
3535+ - Direct children only: `com.example.forum._` (matches `post`, `comment`, but NOT `comment.reply`)
3536 - Real-world example: `app.bsky.feed.*`
36373738**Options:**
···183184184185This downloads only the `com.example.forum.post` lexicon.
185186186186-### Fetch with Wildcard
187187+### Fetch with Wildcards
187188189189+**All descendants (`.*`):**
188190```bash
189191mlf fetch com.example.forum.*
190192```
193193+Downloads all lexicons under the `com.example.forum` namespace (recursive).
191194192192-This downloads all lexicons under the `com.example.forum` namespace.
195195+**Example:** If the namespace contains:
196196+- `com.example.forum.post`
197197+- `com.example.forum.comment`
198198+- `com.example.forum.comment.reply`
199199+200200+The `.*` pattern fetches **all three**.
201201+202202+**Direct children only (`._`):**
203203+```bash
204204+mlf fetch com.example.forum._
205205+```
206206+Downloads only direct children of `com.example.forum` (non-recursive).
207207+208208+Using the same example namespace, the `._` pattern fetches:
209209+- `com.example.forum.post` ✓
210210+- `com.example.forum.comment` ✓
211211+- `com.example.forum.comment.reply` ✗ (skipped, not a direct child)
193212194213### Fetch and Save
195214