Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement DNS lexicon lookup as fallback

+154 -44
+13 -18
config/schema.php
··· 149 149 | DNS-Based Lexicon Resolution 150 150 |-------------------------------------------------------------------------- 151 151 | 152 - | Configure DNS-based lexicon resolution for third-party schemas. 153 - | Requires socialdept/atp-resolver package for DID resolution. 152 + | Configure DNS-based lexicon resolution following AT Protocol specification. 153 + | 154 + | When enabled, the schema loader will attempt to discover custom lexicons via: 155 + | 1. Querying DNS TXT record at _lexicon.<authority-domain> for DID 156 + | 2. Resolving DID to PDS endpoint (requires socialdept/atp-resolver) 157 + | 3. Fetching lexicon from repository via com.atproto.repo.getRecord 158 + | 159 + | IMPORTANT: DNS resolution requires the optional socialdept/atp-resolver package. 160 + | Install with: composer require socialdept/atp-resolver 161 + | 162 + | If atp-resolver is not installed, DNS resolution will be skipped and a 163 + | warning will be logged. The schema loader will fall back to local sources. 154 164 | 155 165 */ 156 166 157 167 'dns_resolution' => [ 158 - // Enable DNS-based lexicon resolution 168 + // Enable DNS-based lexicon resolution (requires socialdept/atp-resolver) 159 169 'enabled' => env('SCHEMA_DNS_RESOLUTION_ENABLED', true), 160 - 161 - // Use Resolver for DID resolution (requires socialdept/atp-resolver) 162 - 'use_resolver' => env('SCHEMA_USE_RESOLVER', true), 163 - 164 - // Fallback behavior when schema not found: fail, warn, allow 165 - 'fallback' => env('SCHEMA_DNS_FALLBACK', 'warn'), 166 - 167 - // DNS nameservers (null = system default) 168 - 'nameservers' => env('SCHEMA_DNS_NAMESERVERS', null), 169 - 170 - // DNS query timeout (seconds) 171 - 'timeout' => env('SCHEMA_DNS_TIMEOUT', 5), 172 - 173 - // Auto-generate Data classes for resolved schemas 174 - 'auto_generate' => env('SCHEMA_DNS_AUTO_GENERATE', false), 175 170 ], 176 171 177 172 /*
+141 -26
src/Parser/SchemaLoader.php
··· 49 49 protected int $httpTimeout; 50 50 51 51 /** 52 + * Whether the atp-resolver package is available. 53 + */ 54 + protected bool $hasResolver = false; 55 + 56 + /** 57 + * Whether we've shown the resolver warning. 58 + */ 59 + protected static bool $resolverWarningShown = false; 60 + 61 + /** 52 62 * Create a new SchemaLoader instance. 53 63 * 54 64 * @param array<string> $sources ··· 67 77 $this->cachePrefix = $cachePrefix; 68 78 $this->dnsResolutionEnabled = $dnsResolutionEnabled; 69 79 $this->httpTimeout = $httpTimeout; 80 + $this->hasResolver = class_exists('SocialDept\\Resolver\\Resolver'); 70 81 } 71 82 72 83 /** ··· 268 279 } 269 280 270 281 /** 271 - * Load schema via DNS resolution from official sources. 282 + * Load schema via DNS resolution following AT Protocol spec. 283 + * 284 + * AT Protocol DNS-based lexicon discovery: 285 + * 1. Query DNS TXT record at _lexicon.<authority-domain> 286 + * 2. Extract DID from TXT record (format: did=<DID>) 287 + * 3. Resolve DID to PDS endpoint (requires atp-resolver package) 288 + * 4. Fetch lexicon from repository via com.atproto.repo.getRecord 272 289 */ 273 290 protected function loadViaDns(string $nsid): ?array 274 291 { 275 - // Try to fetch from official AT Protocol GitHub repository 276 - $urls = $this->getOfficialSchemaUrls($nsid); 292 + // Check if atp-resolver is available 293 + if (! $this->hasResolver) { 294 + $this->showResolverWarning(); 277 295 278 - foreach ($urls as $url) { 279 - try { 280 - $response = Http::timeout($this->httpTimeout) 281 - ->get($url); 296 + return null; 297 + } 298 + 299 + try { 300 + $nsidParsed = Nsid::parse($nsid); 301 + 302 + // Step 1: Query DNS TXT record for DID 303 + $did = $this->queryLexiconDid($nsidParsed); 304 + if ($did === null) { 305 + return null; 306 + } 282 307 283 - if ($response->successful()) { 284 - $data = $response->json(); 308 + // Step 2: Resolve DID to PDS endpoint 309 + $pdsUrl = $this->resolvePdsEndpoint($did); 310 + if ($pdsUrl === null) { 311 + return null; 312 + } 285 313 286 - if (is_array($data) && isset($data['lexicon'])) { 287 - return $data; 288 - } 314 + // Step 3: Fetch lexicon schema from repository 315 + return $this->fetchLexiconFromRepository($pdsUrl, $did, $nsid); 316 + } catch (\Exception $e) { 317 + // Silently fail and return null - will try other sources or fail with main error 318 + return null; 319 + } 320 + } 321 + 322 + /** 323 + * Query DNS TXT record for lexicon DID. 324 + * 325 + * Queries _lexicon.<authority-domain> for TXT record containing did=<DID> 326 + */ 327 + protected function queryLexiconDid(Nsid $nsid): ?string 328 + { 329 + // Convert authority to domain (e.g., pub.leaflet -> leaflet.pub) 330 + $authority = $nsid->getAuthority(); 331 + $parts = explode('.', $authority); 332 + $domain = implode('.', array_reverse($parts)); 333 + 334 + // Query DNS TXT record at _lexicon.<domain> 335 + $hostname = "_lexicon.{$domain}"; 336 + 337 + try { 338 + $records = dns_get_record($hostname, DNS_TXT); 339 + 340 + if ($records === false || empty($records)) { 341 + return null; 342 + } 343 + 344 + // Look for TXT record with did= prefix 345 + foreach ($records as $record) { 346 + if (isset($record['txt']) && str_starts_with($record['txt'], 'did=')) { 347 + return substr($record['txt'], 4); // Remove 'did=' prefix 289 348 } 290 - } catch (\Exception $e) { 291 - // Continue to next URL 292 - continue; 293 349 } 350 + } catch (\Exception $e) { 351 + // DNS query failed 352 + return null; 294 353 } 295 354 296 355 return null; 297 356 } 298 357 299 358 /** 300 - * Get official schema URLs for an NSID. 301 - * 302 - * @return array<string> 359 + * Resolve DID to PDS endpoint using atp-resolver. 303 360 */ 304 - protected function getOfficialSchemaUrls(string $nsid): array 361 + protected function resolvePdsEndpoint(string $did): ?string 305 362 { 306 - $path = str_replace('.', '/', $nsid); 363 + if (! $this->hasResolver) { 364 + return null; 365 + } 307 366 308 - return [ 309 - // Official AT Protocol lexicons repository (main branch) 310 - "https://raw.githubusercontent.com/bluesky-social/atproto/main/lexicons/{$path}.json", 367 + try { 368 + // Get resolver from Laravel container if available 369 + if (function_exists('app') && app()->has(\SocialDept\Resolver\Resolver::class)) { 370 + $resolver = app(\SocialDept\Resolver\Resolver::class); 371 + } else { 372 + // Can't instantiate without dependencies 373 + return null; 374 + } 375 + 376 + // Use the resolvePds method which handles DID resolution and PDS extraction 377 + return $resolver->resolvePds($did); 378 + } catch (\Exception $e) { 379 + return null; 380 + } 381 + } 311 382 312 - // Fallback to legacy location 313 - "https://raw.githubusercontent.com/bluesky-social/atproto/main/lexicons/{$nsid}.json", 314 - ]; 383 + /** 384 + * Fetch lexicon schema from AT Protocol repository. 385 + */ 386 + protected function fetchLexiconFromRepository(string $pdsUrl, string $did, string $nsid): ?array 387 + { 388 + try { 389 + // Construct XRPC call to com.atproto.repo.getRecord 390 + $response = Http::timeout($this->httpTimeout) 391 + ->get("{$pdsUrl}/xrpc/com.atproto.repo.getRecord", [ 392 + 'repo' => $did, 393 + 'collection' => 'com.atproto.lexicon.schema', 394 + 'rkey' => $nsid, 395 + ]); 396 + 397 + if ($response->successful()) { 398 + $data = $response->json(); 399 + 400 + // Extract the lexicon schema from the record value 401 + if (isset($data['value']) && is_array($data['value']) && isset($data['value']['lexicon'])) { 402 + return $data['value']; 403 + } 404 + } 405 + } catch (\Exception $e) { 406 + return null; 407 + } 408 + 409 + return null; 410 + } 411 + 412 + /** 413 + * Show warning about missing atp-resolver package. 414 + */ 415 + protected function showResolverWarning(): void 416 + { 417 + if (self::$resolverWarningShown) { 418 + return; 419 + } 420 + 421 + if (function_exists('logger')) { 422 + logger()->warning( 423 + 'DNS-based lexicon resolution requires the socialdept/atp-resolver package. '. 424 + 'Install it with: composer require socialdept/atp-resolver '. 425 + 'Falling back to local lexicon sources only.' 426 + ); 427 + } 428 + 429 + self::$resolverWarningShown = true; 315 430 } 316 431 }