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.

Add Artisan commands for schema generation validation and cache management

+708
+74
src/Console/ClearCacheCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Console; 4 + 5 + use Illuminate\Console\Command; 6 + use Illuminate\Support\Facades\Cache; 7 + 8 + class ClearCacheCommand extends Command 9 + { 10 + /** 11 + * The name and signature of the console command. 12 + */ 13 + protected $signature = 'schema:clear-cache 14 + {--nsid= : Clear cache for a specific NSID} 15 + {--all : Clear all schema caches}'; 16 + 17 + /** 18 + * The console command description. 19 + */ 20 + protected $description = 'Clear ATProto Lexicon schema caches'; 21 + 22 + /** 23 + * Execute the console command. 24 + */ 25 + public function handle(): int 26 + { 27 + $nsid = $this->option('nsid'); 28 + $all = $this->option('all'); 29 + 30 + if (! $nsid && ! $all) { 31 + $this->error('Either --nsid or --all option must be provided'); 32 + 33 + return self::FAILURE; 34 + } 35 + 36 + try { 37 + $cachePrefix = config('schema.cache.prefix', 'schema'); 38 + 39 + if ($all) { 40 + $this->info('Clearing all schema caches...'); 41 + 42 + // Clear all cache keys with the schema prefix 43 + if (Cache::getStore() instanceof \Illuminate\Cache\TaggableStore) { 44 + Cache::tags([$cachePrefix])->flush(); 45 + } else { 46 + // For non-taggable stores, we need to clear by pattern 47 + $this->warn('Cache store does not support tags. Using Cache::flush() to clear all caches.'); 48 + Cache::flush(); 49 + } 50 + 51 + $this->info('✓ All schema caches cleared'); 52 + 53 + return self::SUCCESS; 54 + } 55 + 56 + $this->info("Clearing cache for schema: {$nsid}"); 57 + 58 + $cacheKey = "{$cachePrefix}:parsed:{$nsid}"; 59 + Cache::forget($cacheKey); 60 + 61 + $this->info('✓ Cache cleared'); 62 + 63 + return self::SUCCESS; 64 + } catch (\Exception $e) { 65 + $this->error('Failed to clear cache: '.$e->getMessage()); 66 + 67 + if ($this->output->isVerbose()) { 68 + $this->error($e->getTraceAsString()); 69 + } 70 + 71 + return self::FAILURE; 72 + } 73 + } 74 + }
+89
src/Console/GenerateCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Console; 4 + 5 + use Illuminate\Console\Command; 6 + use SocialDept\Schema\Generator\DTOGenerator; 7 + use SocialDept\Schema\Parser\SchemaLoader; 8 + 9 + class 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 + 21 + /** 22 + * The console command description. 23 + */ 24 + protected $description = 'Generate PHP DTO classes from ATProto Lexicon schemas'; 25 + 26 + /** 27 + * Execute the console command. 28 + */ 29 + public function handle(): int 30 + { 31 + $nsid = $this->argument('nsid'); 32 + $output = $this->option('output') ?? config('schema.generation.output_directory'); 33 + $namespace = $this->option('namespace') ?? config('schema.generation.base_namespace'); 34 + $force = $this->option('force'); 35 + $dryRun = $this->option('dry-run'); 36 + 37 + $this->info("Generating DTO classes for schema: {$nsid}"); 38 + 39 + try { 40 + $sources = config('schema.sources', []); 41 + $loader = new SchemaLoader($sources); 42 + 43 + $generator = new DTOGenerator( 44 + schemaLoader: $loader, 45 + baseNamespace: $namespace, 46 + outputDirectory: $output 47 + ); 48 + 49 + if ($dryRun) { 50 + $this->info('Dry run mode - no files will be written'); 51 + $schema = $loader->load($nsid); 52 + $document = \SocialDept\Schema\Data\LexiconDocument::fromArray($schema); 53 + $code = $generator->preview($document); 54 + 55 + $this->line(''); 56 + $this->line('Generated code:'); 57 + $this->line('─────────────────────────────────────────────────'); 58 + $this->line($code); 59 + $this->line('─────────────────────────────────────────────────'); 60 + 61 + return self::SUCCESS; 62 + } 63 + 64 + $files = $generator->generateByNsid($nsid, [ 65 + 'dryRun' => false, 66 + 'overwrite' => $force, 67 + ]); 68 + 69 + $this->info('Generated '.count($files).' file(s):'); 70 + 71 + foreach ($files as $file) { 72 + $this->line(" - {$file}"); 73 + } 74 + 75 + $this->newLine(); 76 + $this->info('✓ Generation completed successfully'); 77 + 78 + return self::SUCCESS; 79 + } catch (\Exception $e) { 80 + $this->error('Generation failed: '.$e->getMessage()); 81 + 82 + if ($this->output->isVerbose()) { 83 + $this->error($e->getTraceAsString()); 84 + } 85 + 86 + return self::FAILURE; 87 + } 88 + } 89 + }
+191
src/Console/ListCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Console; 4 + 5 + use Illuminate\Console\Command; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\Parser\SchemaLoader; 8 + 9 + class ListCommand extends Command 10 + { 11 + /** 12 + * The name and signature of the console command. 13 + */ 14 + protected $signature = 'schema:list 15 + {--filter= : Filter schemas by pattern (supports wildcards)} 16 + {--type= : Filter by schema type (record, query, procedure, subscription)}'; 17 + 18 + /** 19 + * The console command description. 20 + */ 21 + protected $description = 'List all available ATProto Lexicon schemas'; 22 + 23 + /** 24 + * Execute the console command. 25 + */ 26 + public function handle(): int 27 + { 28 + $filter = $this->option('filter'); 29 + $type = $this->option('type'); 30 + 31 + try { 32 + $sources = config('schema.sources', []); 33 + $loader = new SchemaLoader($sources); 34 + 35 + $schemas = $this->discoverSchemas($sources); 36 + 37 + if ($filter) { 38 + $schemas = $this->filterSchemas($schemas, $filter); 39 + } 40 + 41 + if ($type) { 42 + $schemas = $this->filterByType($schemas, $type, $loader); 43 + } 44 + 45 + if (empty($schemas)) { 46 + $this->info('No schemas found'); 47 + 48 + return self::SUCCESS; 49 + } 50 + 51 + $this->info('Found '.count($schemas).' schema(s):'); 52 + $this->newLine(); 53 + 54 + $tableData = []; 55 + 56 + foreach ($schemas as $nsid) { 57 + try { 58 + $schema = $loader->load($nsid); 59 + $document = LexiconDocument::fromArray($schema); 60 + 61 + $schemaType = 'unknown'; 62 + if ($document->isRecord()) { 63 + $schemaType = 'record'; 64 + } elseif ($document->isQuery()) { 65 + $schemaType = 'query'; 66 + } elseif ($document->isProcedure()) { 67 + $schemaType = 'procedure'; 68 + } elseif ($document->isSubscription()) { 69 + $schemaType = 'subscription'; 70 + } 71 + 72 + $tableData[] = [ 73 + $nsid, 74 + $schemaType, 75 + $document->description ?? '-', 76 + ]; 77 + } catch (\Exception $e) { 78 + $tableData[] = [ 79 + $nsid, 80 + 'error', 81 + $e->getMessage(), 82 + ]; 83 + } 84 + } 85 + 86 + $this->table(['NSID', 'Type', 'Description'], $tableData); 87 + 88 + return self::SUCCESS; 89 + } catch (\Exception $e) { 90 + $this->error('Failed to list schemas: '.$e->getMessage()); 91 + 92 + if ($this->output->isVerbose()) { 93 + $this->error($e->getTraceAsString()); 94 + } 95 + 96 + return self::FAILURE; 97 + } 98 + } 99 + 100 + /** 101 + * Discover all schema files in sources. 102 + * 103 + * @param array<string> $sources 104 + * @return array<string> 105 + */ 106 + protected function discoverSchemas(array $sources): array 107 + { 108 + $schemas = []; 109 + 110 + foreach ($sources as $source) { 111 + if (! is_dir($source)) { 112 + continue; 113 + } 114 + 115 + $schemas = array_merge($schemas, $this->scanDirectory($source)); 116 + } 117 + 118 + return array_unique($schemas); 119 + } 120 + 121 + /** 122 + * Scan directory for schema files. 123 + * 124 + * @return array<string> 125 + */ 126 + protected function scanDirectory(string $directory, string $prefix = ''): array 127 + { 128 + $schemas = []; 129 + $items = scandir($directory); 130 + 131 + foreach ($items as $item) { 132 + if ($item === '.' || $item === '..') { 133 + continue; 134 + } 135 + 136 + $path = $directory.'/'.$item; 137 + 138 + if (is_dir($path)) { 139 + $newPrefix = $prefix ? $prefix.'.'.$item : $item; 140 + $schemas = array_merge($schemas, $this->scanDirectory($path, $newPrefix)); 141 + } elseif (pathinfo($item, PATHINFO_EXTENSION) === 'json' || pathinfo($item, PATHINFO_EXTENSION) === 'php') { 142 + $name = pathinfo($item, PATHINFO_FILENAME); 143 + $nsid = $prefix ? $prefix.'.'.$name : $name; 144 + $schemas[] = $nsid; 145 + } 146 + } 147 + 148 + return $schemas; 149 + } 150 + 151 + /** 152 + * Filter schemas by pattern. 153 + * 154 + * @param array<string> $schemas 155 + * @return array<string> 156 + */ 157 + protected function filterSchemas(array $schemas, string $pattern): array 158 + { 159 + $pattern = str_replace('*', '.*', preg_quote($pattern, '/')); 160 + 161 + return array_filter($schemas, function ($nsid) use ($pattern) { 162 + return preg_match("/^{$pattern}$/", $nsid); 163 + }); 164 + } 165 + 166 + /** 167 + * Filter schemas by type. 168 + * 169 + * @param array<string> $schemas 170 + * @return array<string> 171 + */ 172 + protected function filterByType(array $schemas, string $type, SchemaLoader $loader): array 173 + { 174 + return array_filter($schemas, function ($nsid) use ($type, $loader) { 175 + try { 176 + $schema = $loader->load($nsid); 177 + $document = LexiconDocument::fromArray($schema); 178 + 179 + return match ($type) { 180 + 'record' => $document->isRecord(), 181 + 'query' => $document->isQuery(), 182 + 'procedure' => $document->isProcedure(), 183 + 'subscription' => $document->isSubscription(), 184 + default => false, 185 + }; 186 + } catch (\Exception) { 187 + return false; 188 + } 189 + }); 190 + } 191 + }
+99
src/Console/ValidateCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Console; 4 + 5 + use Illuminate\Console\Command; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\Parser\SchemaLoader; 8 + use SocialDept\Schema\Validation\LexiconValidator; 9 + 10 + class ValidateCommand extends Command 11 + { 12 + /** 13 + * The name and signature of the console command. 14 + */ 15 + protected $signature = 'schema:validate 16 + {nsid : The NSID of the schema to validate} 17 + {--data= : JSON data to validate against the schema} 18 + {--file= : Path to file containing JSON data to validate}'; 19 + 20 + /** 21 + * The console command description. 22 + */ 23 + protected $description = 'Validate data against ATProto Lexicon schemas'; 24 + 25 + /** 26 + * Execute the console command. 27 + */ 28 + public function handle(): int 29 + { 30 + $nsid = $this->argument('nsid'); 31 + $dataJson = $this->option('data'); 32 + $dataFile = $this->option('file'); 33 + 34 + if (! $dataJson && ! $dataFile) { 35 + $this->error('Either --data or --file option must be provided'); 36 + 37 + return self::FAILURE; 38 + } 39 + 40 + try { 41 + // Load data 42 + if ($dataFile) { 43 + if (! file_exists($dataFile)) { 44 + $this->error("File not found: {$dataFile}"); 45 + 46 + return self::FAILURE; 47 + } 48 + 49 + $dataJson = file_get_contents($dataFile); 50 + } 51 + 52 + $data = json_decode($dataJson, true); 53 + 54 + if (json_last_error() !== JSON_ERROR_NONE) { 55 + $this->error('Invalid JSON: '.json_last_error_msg()); 56 + 57 + return self::FAILURE; 58 + } 59 + 60 + // Load schema and validate 61 + $sources = config('schema.sources', []); 62 + $loader = new SchemaLoader($sources); 63 + $validator = new LexiconValidator($loader); 64 + 65 + $this->info("Validating data against schema: {$nsid}"); 66 + 67 + $schema = $loader->load($nsid); 68 + $document = LexiconDocument::fromArray($schema); 69 + 70 + $errors = $validator->validateWithErrors($data, $document); 71 + 72 + if (empty($errors)) { 73 + $this->info('✓ Validation passed'); 74 + 75 + return self::SUCCESS; 76 + } 77 + 78 + $this->error('✗ Validation failed:'); 79 + $this->newLine(); 80 + 81 + foreach ($errors as $field => $fieldErrors) { 82 + $this->line(" {$field}:"); 83 + foreach ($fieldErrors as $error) { 84 + $this->line(" - {$error}"); 85 + } 86 + } 87 + 88 + return self::FAILURE; 89 + } catch (\Exception $e) { 90 + $this->error('Validation error: '.$e->getMessage()); 91 + 92 + if ($this->output->isVerbose()) { 93 + $this->error($e->getTraceAsString()); 94 + } 95 + 96 + return self::FAILURE; 97 + } 98 + } 99 + }
+8
src/SchemaServiceProvider.php
··· 50 50 $this->publishes([ 51 51 __DIR__.'/../stubs' => base_path('stubs/schema'), 52 52 ], 'schema-stubs'); 53 + 54 + // Register commands 55 + $this->commands([ 56 + Console\GenerateCommand::class, 57 + Console\ValidateCommand::class, 58 + Console\ListCommand::class, 59 + Console\ClearCacheCommand::class, 60 + ]); 53 61 } 54 62 }
+62
tests/Unit/Console/ClearCacheCommandTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Console; 4 + 5 + use Illuminate\Support\Facades\Cache; 6 + use Orchestra\Testbench\TestCase; 7 + use SocialDept\Schema\Console\ClearCacheCommand; 8 + use SocialDept\Schema\SchemaServiceProvider; 9 + 10 + class ClearCacheCommandTest extends TestCase 11 + { 12 + protected function getPackageProviders($app): array 13 + { 14 + return [SchemaServiceProvider::class]; 15 + } 16 + 17 + protected function setUp(): void 18 + { 19 + parent::setUp(); 20 + 21 + Cache::flush(); 22 + 23 + config([ 24 + 'schema.cache.prefix' => 'schema', 25 + ]); 26 + } 27 + 28 + public function test_it_clears_specific_schema_cache(): void 29 + { 30 + // Set a cache value 31 + Cache::put('schema:parsed:test.nsid', ['data'], 3600); 32 + 33 + $this->artisan(ClearCacheCommand::class, [ 34 + '--nsid' => 'test.nsid', 35 + ]) 36 + ->expectsOutput('Clearing cache for schema: test.nsid') 37 + ->expectsOutput('✓ Cache cleared') 38 + ->assertSuccessful(); 39 + 40 + $this->assertFalse(Cache::has('schema:parsed:test.nsid')); 41 + } 42 + 43 + public function test_it_clears_all_caches(): void 44 + { 45 + // Set some cache values 46 + Cache::put('schema:parsed:test1', ['data'], 3600); 47 + Cache::put('schema:parsed:test2', ['data'], 3600); 48 + 49 + $this->artisan(ClearCacheCommand::class, [ 50 + '--all' => true, 51 + ]) 52 + ->expectsOutput('Clearing all schema caches...') 53 + ->assertSuccessful(); 54 + } 55 + 56 + public function test_it_requires_nsid_or_all_option(): void 57 + { 58 + $this->artisan(ClearCacheCommand::class) 59 + ->expectsOutput('Either --nsid or --all option must be provided') 60 + ->assertFailed(); 61 + } 62 + }
+66
tests/Unit/Console/GenerateCommandTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Console; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Console\GenerateCommand; 7 + use SocialDept\Schema\SchemaServiceProvider; 8 + 9 + class GenerateCommandTest extends TestCase 10 + { 11 + protected function getPackageProviders($app): array 12 + { 13 + return [SchemaServiceProvider::class]; 14 + } 15 + 16 + protected function setUp(): void 17 + { 18 + parent::setUp(); 19 + 20 + config([ 21 + 'schema.sources' => [__DIR__.'/../../fixtures'], 22 + 'schema.generation.output_directory' => sys_get_temp_dir().'/schema-test', 23 + 'schema.generation.base_namespace' => 'Test\\Generated', 24 + ]); 25 + } 26 + 27 + public function test_it_generates_code_in_dry_run_mode(): void 28 + { 29 + $this->artisan(GenerateCommand::class, [ 30 + 'nsid' => 'app.bsky.feed.post', 31 + '--dry-run' => true, 32 + ]) 33 + ->expectsOutput('Generating DTO classes for schema: app.bsky.feed.post') 34 + ->expectsOutput('Dry run mode - no files will be written') 35 + ->assertSuccessful(); 36 + } 37 + 38 + public function test_it_handles_invalid_nsid(): void 39 + { 40 + $this->artisan(GenerateCommand::class, [ 41 + 'nsid' => 'nonexistent.schema', 42 + ]) 43 + ->expectsOutput('Generating DTO classes for schema: nonexistent.schema') 44 + ->assertFailed(); 45 + } 46 + 47 + public function test_it_accepts_custom_output_directory(): void 48 + { 49 + $this->artisan(GenerateCommand::class, [ 50 + 'nsid' => 'app.bsky.feed.post', 51 + '--output' => '/custom/path', 52 + '--dry-run' => true, 53 + ]) 54 + ->assertSuccessful(); 55 + } 56 + 57 + public function test_it_accepts_custom_namespace(): void 58 + { 59 + $this->artisan(GenerateCommand::class, [ 60 + 'nsid' => 'app.bsky.feed.post', 61 + '--namespace' => 'Custom\\Namespace', 62 + '--dry-run' => true, 63 + ]) 64 + ->assertSuccessful(); 65 + } 66 + }
+46
tests/Unit/Console/ListCommandTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Console; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Console\ListCommand; 7 + use SocialDept\Schema\SchemaServiceProvider; 8 + 9 + class ListCommandTest extends TestCase 10 + { 11 + protected function getPackageProviders($app): array 12 + { 13 + return [SchemaServiceProvider::class]; 14 + } 15 + 16 + protected function setUp(): void 17 + { 18 + parent::setUp(); 19 + 20 + config([ 21 + 'schema.sources' => [__DIR__.'/../../fixtures'], 22 + ]); 23 + } 24 + 25 + public function test_it_lists_schemas(): void 26 + { 27 + $this->artisan(ListCommand::class) 28 + ->assertSuccessful(); 29 + } 30 + 31 + public function test_it_filters_schemas_by_pattern(): void 32 + { 33 + $this->artisan(ListCommand::class, [ 34 + '--filter' => 'app.bsky.*', 35 + ]) 36 + ->assertSuccessful(); 37 + } 38 + 39 + public function test_it_filters_schemas_by_type(): void 40 + { 41 + $this->artisan(ListCommand::class, [ 42 + '--type' => 'record', 43 + ]) 44 + ->assertSuccessful(); 45 + } 46 + }
+73
tests/Unit/Console/ValidateCommandTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Console; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Console\ValidateCommand; 7 + use SocialDept\Schema\SchemaServiceProvider; 8 + 9 + class ValidateCommandTest extends TestCase 10 + { 11 + protected function getPackageProviders($app): array 12 + { 13 + return [SchemaServiceProvider::class]; 14 + } 15 + 16 + protected function setUp(): void 17 + { 18 + parent::setUp(); 19 + 20 + config([ 21 + 'schema.sources' => [__DIR__.'/../../fixtures'], 22 + ]); 23 + } 24 + 25 + public function test_it_validates_valid_data(): void 26 + { 27 + $data = json_encode([ 28 + 'text' => 'Hello, World!', 29 + 'createdAt' => '2024-01-01T00:00:00Z', 30 + ]); 31 + 32 + $this->artisan(ValidateCommand::class, [ 33 + 'nsid' => 'app.bsky.feed.post', 34 + '--data' => $data, 35 + ]) 36 + ->expectsOutput('Validating data against schema: app.bsky.feed.post') 37 + ->expectsOutput('✓ Validation passed') 38 + ->assertSuccessful(); 39 + } 40 + 41 + public function test_it_fails_on_invalid_data(): void 42 + { 43 + $data = json_encode([ 44 + 'text' => 'Hello, World!', 45 + // Missing required createdAt 46 + ]); 47 + 48 + $this->artisan(ValidateCommand::class, [ 49 + 'nsid' => 'app.bsky.feed.post', 50 + '--data' => $data, 51 + ]) 52 + ->expectsOutput('✗ Validation failed:') 53 + ->assertFailed(); 54 + } 55 + 56 + public function test_it_requires_data_or_file_option(): void 57 + { 58 + $this->artisan(ValidateCommand::class, [ 59 + 'nsid' => 'app.bsky.feed.post', 60 + ]) 61 + ->expectsOutput('Either --data or --file option must be provided') 62 + ->assertFailed(); 63 + } 64 + 65 + public function test_it_handles_invalid_json(): void 66 + { 67 + $this->artisan(ValidateCommand::class, [ 68 + 'nsid' => 'app.bsky.feed.post', 69 + '--data' => '{invalid json}', 70 + ]) 71 + ->assertFailed(); 72 + } 73 + }