Parse and validate AT Protocol Lexicons with DTO generation for Laravel
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}