Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Console;
4
5use Illuminate\Console\Command;
6use SocialDept\AtpSchema\Generator\DTOGenerator;
7use SocialDept\AtpSchema\Parser\SchemaLoader;
8
9class GenerateCommand extends Command
10{
11 /**
12 * The name and signature of the console command.
13 */
14 protected $signature = 'schema:generate
15 {nsid : The NSID of the schema to generate}
16 {--output= : Output directory for generated files}
17 {--namespace= : Base namespace for generated classes}
18 {--force : Overwrite existing files}
19 {--dry-run : Preview generated code without writing files}
20 {--with-dependencies : Also generate all referenced lexicons recursively}
21 {--r|recursive : Alias for --with-dependencies}';
22
23 /**
24 * The console command description.
25 */
26 protected $description = 'Generate PHP DTO classes from ATProto Lexicon schemas';
27
28 /**
29 * Track generated NSIDs to avoid duplicates.
30 */
31 protected array $generated = [];
32
33 /**
34 * Execute the console command.
35 */
36 public function handle(): int
37 {
38 $nsid = $this->argument('nsid');
39 $output = $this->option('output') ?? config('schema.lexicons.output_path');
40 $namespace = $this->option('namespace') ?? config('schema.lexicons.base_namespace');
41 $force = $this->option('force');
42 $dryRun = $this->option('dry-run');
43 $withDependencies = $this->option('with-dependencies') || $this->option('recursive');
44
45 $this->info("Generating DTO classes for schema: {$nsid}");
46
47 try {
48 $sources = config('schema.sources', []);
49 $loader = new SchemaLoader(
50 sources: $sources,
51 useCache: config('schema.cache.enabled', true),
52 cacheTtl: config('schema.cache.schema_ttl', 3600),
53 cachePrefix: config('schema.cache.prefix', 'schema'),
54 dnsResolutionEnabled: config('schema.dns_resolution.enabled', true),
55 httpTimeout: config('schema.http.timeout', 10)
56 );
57
58 $generator = new DTOGenerator(
59 schemaLoader: $loader,
60 baseNamespace: $namespace,
61 outputDirectory: $output
62 );
63
64 if ($dryRun) {
65 $this->info('Dry run mode - no files will be written');
66 $document = $loader->load($nsid);
67 $code = $generator->preview($document);
68
69 $this->line('');
70 $this->line('Generated code:');
71 $this->line('─────────────────────────────────────────────────');
72 $this->line($code);
73 $this->line('─────────────────────────────────────────────────');
74
75 return self::SUCCESS;
76 }
77
78 $allFiles = [];
79
80 if ($withDependencies) {
81 $this->info('Generating with dependencies...');
82 $allFiles = $this->generateWithDependencies($nsid, $loader, $generator, $force);
83 } else {
84 $allFiles = $generator->generateByNsid($nsid, [
85 'dryRun' => false,
86 'overwrite' => $force,
87 ]);
88 }
89
90 $this->newLine();
91 $this->info('Generated '.count($allFiles).' file(s):');
92
93 foreach ($allFiles as $file) {
94 $this->line(" - {$file}");
95 }
96
97 $this->newLine();
98 $this->info('✓ Generation completed successfully');
99
100 return self::SUCCESS;
101 } catch (\Exception $e) {
102 $this->error('Generation failed: '.$e->getMessage());
103
104 if ($this->output->isVerbose()) {
105 $this->error($e->getTraceAsString());
106 }
107
108 return self::FAILURE;
109 }
110 }
111
112 /**
113 * Generate schema with all its dependencies recursively.
114 */
115 protected function generateWithDependencies(
116 string $nsid,
117 SchemaLoader $loader,
118 DTOGenerator $generator,
119 bool $force
120 ): array {
121 // Skip if already generated
122 if (in_array($nsid, $this->generated)) {
123 return [];
124 }
125
126 $this->line(" → Loading schema: {$nsid}");
127
128 try {
129 $schema = $loader->load($nsid);
130 } catch (\Exception $e) {
131 $this->warn(" ⚠ Could not load {$nsid}: ".$e->getMessage());
132
133 return [];
134 }
135
136 // Extract all referenced NSIDs from this schema
137 $dependencies = $this->extractDependencies($schema);
138
139 $allFiles = [];
140
141 // Generate dependencies first
142 foreach ($dependencies as $depNsid) {
143 $depFiles = $this->generateWithDependencies($depNsid, $loader, $generator, $force);
144 $allFiles = array_merge($allFiles, $depFiles);
145 }
146
147 // Mark as generated before generating to prevent circular references
148 $this->generated[] = $nsid;
149
150 // Generate current schema
151 try {
152 $files = $generator->generateByNsid($nsid, [
153 'dryRun' => false,
154 'overwrite' => $force,
155 ]);
156 $allFiles = array_merge($allFiles, $files);
157 } catch (\Exception $e) {
158 $this->warn(" ⚠ Could not generate {$nsid}: ".$e->getMessage());
159 }
160
161 return $allFiles;
162 }
163
164 /**
165 * Extract all NSID dependencies from a schema.
166 */
167 protected function extractDependencies(\SocialDept\AtpSchema\Data\LexiconDocument $schema): array
168 {
169 $dependencies = [];
170 $currentNsid = $schema->getNsid();
171
172 // Walk through all definitions
173 foreach ($schema->defs as $def) {
174 $dependencies = array_merge($dependencies, $this->extractRefsFromDefinition($def));
175 }
176
177 // Filter out refs that are definitions within the same schema
178 // (refs that start with the current NSID followed by a dot)
179 $dependencies = array_filter($dependencies, function ($ref) use ($currentNsid) {
180 return ! str_starts_with($ref, $currentNsid.'.');
181 });
182
183 return array_unique($dependencies);
184 }
185
186 /**
187 * Recursively extract refs from a definition.
188 */
189 protected function extractRefsFromDefinition(array $definition): array
190 {
191 $refs = [];
192
193 // Handle direct ref
194 if (isset($definition['ref'])) {
195 $ref = $definition['ref'];
196 // Skip local references (starting with #)
197 if (! str_starts_with($ref, '#')) {
198 // Extract NSID part (before fragment)
199 if (str_contains($ref, '#')) {
200 $ref = explode('#', $ref)[0];
201 }
202 $refs[] = $ref;
203 }
204 }
205
206 // Handle union refs
207 if (isset($definition['refs']) && is_array($definition['refs'])) {
208 foreach ($definition['refs'] as $ref) {
209 // Skip local references
210 if (! str_starts_with($ref, '#')) {
211 // Extract NSID part
212 if (str_contains($ref, '#')) {
213 $ref = explode('#', $ref)[0];
214 }
215 $refs[] = $ref;
216 }
217 }
218 }
219
220 // Recursively check properties
221 if (isset($definition['properties']) && is_array($definition['properties'])) {
222 foreach ($definition['properties'] as $propDef) {
223 $refs = array_merge($refs, $this->extractRefsFromDefinition($propDef));
224 }
225 }
226
227 // Recursively check record
228 if (isset($definition['record']) && is_array($definition['record'])) {
229 $refs = array_merge($refs, $this->extractRefsFromDefinition($definition['record']));
230 }
231
232 // Recursively check array items
233 if (isset($definition['items']) && is_array($definition['items'])) {
234 $refs = array_merge($refs, $this->extractRefsFromDefinition($definition['items']));
235 }
236
237 return $refs;
238 }
239}