Parse and validate AT Protocol Lexicons with DTO generation for Laravel
at dev 6.1 kB view raw
1<?php 2 3namespace SocialDept\AtpSchema\Parser; 4 5use Illuminate\Support\Facades\Http; 6use SocialDept\AtpSchema\Contracts\LexiconParser; 7use SocialDept\AtpSchema\Contracts\LexiconResolver; 8use SocialDept\AtpSchema\Data\LexiconDocument; 9use SocialDept\AtpSchema\Exceptions\SchemaNotFoundException; 10 11class DnsLexiconResolver implements LexiconResolver 12{ 13 /** 14 * Whether DNS resolution is enabled. 15 */ 16 protected bool $enabled; 17 18 /** 19 * HTTP timeout in seconds. 20 */ 21 protected int $httpTimeout; 22 23 /** 24 * Lexicon parser instance. 25 */ 26 protected LexiconParser $parser; 27 28 /** 29 * Whether the atp-resolver package is available. 30 */ 31 protected bool $hasResolver; 32 33 /** 34 * Whether we've shown the resolver warning. 35 */ 36 protected static bool $resolverWarningShown = false; 37 38 /** 39 * Create a new DnsLexiconResolver. 40 */ 41 public function __construct( 42 bool $enabled = true, 43 int $httpTimeout = 10, 44 ?LexiconParser $parser = null 45 ) { 46 $this->enabled = $enabled; 47 $this->httpTimeout = $httpTimeout; 48 $this->parser = $parser ?? new DefaultLexiconParser(); 49 $this->hasResolver = class_exists('SocialDept\\AtpResolver\\Resolver'); 50 } 51 52 /** 53 * Resolve NSID to Lexicon schema via DNS and XRPC. 54 */ 55 public function resolve(string $nsid): LexiconDocument 56 { 57 if (! $this->enabled) { 58 throw SchemaNotFoundException::forNsid($nsid); 59 } 60 61 if (! $this->hasResolver) { 62 $this->showResolverWarning(); 63 64 throw SchemaNotFoundException::forNsid($nsid); 65 } 66 67 try { 68 $nsidParsed = Nsid::parse($nsid); 69 70 // Step 1: Query DNS TXT record for DID 71 $did = $this->lookupDns($nsidParsed->getAuthority()); 72 if ($did === null) { 73 throw SchemaNotFoundException::forNsid($nsid); 74 } 75 76 // Step 2: Resolve DID to PDS endpoint 77 $pdsUrl = $this->resolvePdsEndpoint($did); 78 if ($pdsUrl === null) { 79 throw SchemaNotFoundException::forNsid($nsid); 80 } 81 82 // Step 3: Fetch lexicon schema from repository 83 $schema = $this->retrieveSchema($pdsUrl, $did, $nsid); 84 85 return $this->parser->parseArray($schema); 86 } catch (SchemaNotFoundException $e) { 87 throw $e; 88 } catch (\Exception $e) { 89 throw SchemaNotFoundException::forNsid($nsid); 90 } 91 } 92 93 /** 94 * Perform DNS TXT lookup for _lexicon.{authority}. 95 */ 96 public function lookupDns(string $authority): ?string 97 { 98 // Convert authority to domain (e.g., pub.leaflet -> leaflet.pub) 99 $parts = explode('.', $authority); 100 $domain = implode('.', array_reverse($parts)); 101 102 // Query DNS TXT record at _lexicon.<domain> 103 $hostname = "_lexicon.{$domain}"; 104 105 try { 106 $records = dns_get_record($hostname, DNS_TXT); 107 108 if ($records === false || empty($records)) { 109 return null; 110 } 111 112 // Look for TXT record with did= prefix 113 foreach ($records as $record) { 114 if (isset($record['txt']) && str_starts_with($record['txt'], 'did=')) { 115 return substr($record['txt'], 4); // Remove 'did=' prefix 116 } 117 } 118 } catch (\Exception $e) { 119 // DNS query failed 120 return null; 121 } 122 123 return null; 124 } 125 126 /** 127 * Retrieve schema via XRPC from PDS. 128 */ 129 public function retrieveSchema(string $pdsEndpoint, string $did, string $nsid): array 130 { 131 try { 132 // Construct XRPC call to com.atproto.repo.getRecord 133 $response = Http::timeout($this->httpTimeout) 134 ->get("{$pdsEndpoint}/xrpc/com.atproto.repo.getRecord", [ 135 'repo' => $did, 136 'collection' => 'com.atproto.lexicon.schema', 137 'rkey' => $nsid, 138 ]); 139 140 if ($response->successful()) { 141 $data = $response->json(); 142 143 // Extract the lexicon schema from the record value 144 if (isset($data['value']) && is_array($data['value']) && isset($data['value']['lexicon'])) { 145 return $data['value']; 146 } 147 } 148 } catch (\Exception $e) { 149 throw SchemaNotFoundException::forNsid($nsid); 150 } 151 152 throw SchemaNotFoundException::forNsid($nsid); 153 } 154 155 /** 156 * Check if DNS resolution is enabled. 157 */ 158 public function isEnabled(): bool 159 { 160 return $this->enabled; 161 } 162 163 /** 164 * Resolve DID to PDS endpoint using atp-resolver. 165 */ 166 protected function resolvePdsEndpoint(string $did): ?string 167 { 168 if (! $this->hasResolver) { 169 return null; 170 } 171 172 try { 173 // Get resolver from Laravel container if available 174 if (function_exists('app') && app()->has(\SocialDept\AtpResolver\Resolver::class)) { 175 $resolver = app(\SocialDept\AtpResolver\Resolver::class); 176 } else { 177 // Can't instantiate without dependencies 178 return null; 179 } 180 181 // Use the resolvePds method which handles DID resolution and PDS extraction 182 return $resolver->resolvePds($did); 183 } catch (\Exception $e) { 184 return null; 185 } 186 } 187 188 /** 189 * Show warning about missing atp-resolver package. 190 */ 191 protected function showResolverWarning(): void 192 { 193 if (self::$resolverWarningShown) { 194 return; 195 } 196 197 if (function_exists('logger')) { 198 logger()->warning( 199 'DNS-based lexicon resolution requires the socialdept/atp-resolver package. '. 200 'Install it with: composer require socialdept/atp-resolver '. 201 'Falling back to local lexicon sources only.' 202 ); 203 } 204 205 self::$resolverWarningShown = true; 206 } 207}