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 Schema facade and helper functions

+616 -4
+4 -1
composer.json
··· 22 22 "autoload": { 23 23 "psr-4": { 24 24 "SocialDept\\Schema\\": "src/" 25 - } 25 + }, 26 + "files": [ 27 + "src/helpers.php" 28 + ] 26 29 }, 27 30 "autoload-dev": { 28 31 "psr-4": {
+12 -2
src/Facades/Schema.php
··· 3 3 namespace SocialDept\Schema\Facades; 4 4 5 5 use Illuminate\Support\Facades\Facade; 6 + use SocialDept\Schema\Data\LexiconDocument; 6 7 8 + /** 9 + * @method static array load(string $nsid) 10 + * @method static bool exists(string $nsid) 11 + * @method static LexiconDocument parse(string $nsid) 12 + * @method static bool validate(string $nsid, array $data) 13 + * @method static array validateWithErrors(string $nsid, array $data) 14 + * @method static string generate(string $nsid, array $options = []) 15 + * @method static void clearCache(?string $nsid = null) 16 + * 17 + * @see \SocialDept\Schema\SchemaManager 18 + */ 7 19 class Schema extends Facade 8 20 { 9 21 /** 10 22 * Get the registered name of the component. 11 - * 12 - * @return string 13 23 */ 14 24 protected static function getFacadeAccessor(): string 15 25 {
+141
src/SchemaManager.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema; 4 + 5 + use SocialDept\Schema\Data\LexiconDocument; 6 + use SocialDept\Schema\Generator\DTOGenerator; 7 + use SocialDept\Schema\Parser\SchemaLoader; 8 + use SocialDept\Schema\Validation\LexiconValidator; 9 + 10 + class SchemaManager 11 + { 12 + /** 13 + * Schema loader instance. 14 + */ 15 + protected SchemaLoader $loader; 16 + 17 + /** 18 + * Lexicon validator instance. 19 + */ 20 + protected LexiconValidator $validator; 21 + 22 + /** 23 + * DTO generator instance. 24 + */ 25 + protected ?DTOGenerator $generator = null; 26 + 27 + /** 28 + * Create a new SchemaManager. 29 + */ 30 + public function __construct( 31 + SchemaLoader $loader, 32 + LexiconValidator $validator, 33 + ?DTOGenerator $generator = null 34 + ) { 35 + $this->loader = $loader; 36 + $this->validator = $validator; 37 + $this->generator = $generator; 38 + } 39 + 40 + /** 41 + * Load a schema by NSID. 42 + */ 43 + public function load(string $nsid): array 44 + { 45 + return $this->loader->load($nsid); 46 + } 47 + 48 + /** 49 + * Check if a schema exists. 50 + */ 51 + public function exists(string $nsid): bool 52 + { 53 + return $this->loader->exists($nsid); 54 + } 55 + 56 + /** 57 + * Parse a schema into a LexiconDocument. 58 + */ 59 + public function parse(string $nsid): LexiconDocument 60 + { 61 + $schema = $this->loader->load($nsid); 62 + 63 + return LexiconDocument::fromArray($schema); 64 + } 65 + 66 + /** 67 + * Validate data against a schema. 68 + */ 69 + public function validate(string $nsid, array $data): bool 70 + { 71 + $document = $this->parse($nsid); 72 + 73 + return $this->validator->validate($data, $document); 74 + } 75 + 76 + /** 77 + * Validate data and return errors. 78 + * 79 + * @return array<string, array<string>> 80 + */ 81 + public function validateWithErrors(string $nsid, array $data): array 82 + { 83 + $document = $this->parse($nsid); 84 + 85 + return $this->validator->validateWithErrors($data, $document); 86 + } 87 + 88 + /** 89 + * Generate DTO code from a schema. 90 + */ 91 + public function generate(string $nsid, array $options = []): string 92 + { 93 + if ($this->generator === null) { 94 + throw new \RuntimeException('Generator not available'); 95 + } 96 + 97 + $document = $this->parse($nsid); 98 + 99 + return $this->generator->generate($document); 100 + } 101 + 102 + /** 103 + * Clear schema cache. 104 + */ 105 + public function clearCache(?string $nsid = null): void 106 + { 107 + $this->loader->clearCache($nsid); 108 + } 109 + 110 + /** 111 + * Get the schema loader instance. 112 + */ 113 + public function getLoader(): SchemaLoader 114 + { 115 + return $this->loader; 116 + } 117 + 118 + /** 119 + * Get the validator instance. 120 + */ 121 + public function getValidator(): LexiconValidator 122 + { 123 + return $this->validator; 124 + } 125 + 126 + /** 127 + * Get the generator instance. 128 + */ 129 + public function getGenerator(): ?DTOGenerator 130 + { 131 + return $this->generator; 132 + } 133 + 134 + /** 135 + * Set the generator instance. 136 + */ 137 + public function setGenerator(DTOGenerator $generator): void 138 + { 139 + $this->generator = $generator; 140 + } 141 + }
+34 -1
src/SchemaServiceProvider.php
··· 13 13 { 14 14 $this->mergeConfigFrom(__DIR__.'/../config/schema.php', 'schema'); 15 15 16 - // Singleton bindings will be added in subsequent commits as we create the implementations 16 + // Register SchemaLoader 17 + $this->app->singleton(Parser\SchemaLoader::class, function ($app) { 18 + return new Parser\SchemaLoader( 19 + sources: config('schema.sources', []), 20 + useCache: config('schema.cache.enabled', true), 21 + cacheTtl: config('schema.cache.ttl', 3600), 22 + cachePrefix: config('schema.cache.prefix', 'schema') 23 + ); 24 + }); 25 + 26 + // Register LexiconValidator 27 + $this->app->singleton(Validation\LexiconValidator::class, function ($app) { 28 + return new Validation\LexiconValidator( 29 + schemaLoader: $app->make(Parser\SchemaLoader::class) 30 + ); 31 + }); 32 + 33 + // Register DTOGenerator 34 + $this->app->singleton(Generator\DTOGenerator::class, function ($app) { 35 + return new Generator\DTOGenerator( 36 + schemaLoader: $app->make(Parser\SchemaLoader::class), 37 + baseNamespace: config('schema.generation.base_namespace', 'App\\Lexicon'), 38 + outputDirectory: config('schema.generation.output_directory', 'app/Lexicon') 39 + ); 40 + }); 41 + 42 + // Register SchemaManager 43 + $this->app->singleton('schema', function ($app) { 44 + return new SchemaManager( 45 + loader: $app->make(Parser\SchemaLoader::class), 46 + validator: $app->make(Validation\LexiconValidator::class), 47 + generator: $app->make(Generator\DTOGenerator::class) 48 + ); 49 + }); 17 50 } 18 51 19 52 /**
+51
src/helpers.php
··· 1 + <?php 2 + 3 + use SocialDept\Schema\Data\LexiconDocument; 4 + use SocialDept\Schema\Facades\Schema; 5 + 6 + if (! function_exists('schema')) { 7 + /** 8 + * Get the SchemaManager instance or load a schema. 9 + * 10 + * @param string|null $nsid 11 + * @return \SocialDept\Schema\SchemaManager|array 12 + */ 13 + function schema(?string $nsid = null) 14 + { 15 + if ($nsid === null) { 16 + return app('schema'); 17 + } 18 + 19 + return Schema::load($nsid); 20 + } 21 + } 22 + 23 + if (! function_exists('schema_validate')) { 24 + /** 25 + * Validate data against a schema. 26 + */ 27 + function schema_validate(string $nsid, array $data): bool 28 + { 29 + return Schema::validate($nsid, $data); 30 + } 31 + } 32 + 33 + if (! function_exists('schema_parse')) { 34 + /** 35 + * Parse a schema into a LexiconDocument. 36 + */ 37 + function schema_parse(string $nsid): LexiconDocument 38 + { 39 + return Schema::parse($nsid); 40 + } 41 + } 42 + 43 + if (! function_exists('schema_generate')) { 44 + /** 45 + * Generate DTO code from a schema. 46 + */ 47 + function schema_generate(string $nsid, array $options = []): string 48 + { 49 + return Schema::generate($nsid, $options); 50 + } 51 + }
+107
tests/Unit/Facades/SchemaTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Facades; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\Facades\Schema; 8 + use SocialDept\Schema\SchemaServiceProvider; 9 + 10 + class SchemaTest extends TestCase 11 + { 12 + protected function getPackageProviders($app): array 13 + { 14 + return [SchemaServiceProvider::class]; 15 + } 16 + 17 + protected function defineEnvironment($app): void 18 + { 19 + $app['config']->set('schema.sources', [__DIR__.'/../../fixtures']); 20 + $app['config']->set('schema.cache.enabled', false); 21 + } 22 + 23 + public function test_it_loads_schema(): void 24 + { 25 + $schema = Schema::load('app.bsky.feed.post'); 26 + 27 + $this->assertIsArray($schema); 28 + $this->assertSame('app.bsky.feed.post', $schema['id']); 29 + } 30 + 31 + public function test_it_checks_if_schema_exists(): void 32 + { 33 + $this->assertTrue(Schema::exists('app.bsky.feed.post')); 34 + $this->assertFalse(Schema::exists('nonexistent.schema')); 35 + } 36 + 37 + public function test_it_parses_schema(): void 38 + { 39 + $document = Schema::parse('app.bsky.feed.post'); 40 + 41 + $this->assertInstanceOf(LexiconDocument::class, $document); 42 + $this->assertSame('app.bsky.feed.post', $document->getNsid()); 43 + } 44 + 45 + public function test_it_validates_data(): void 46 + { 47 + $validData = [ 48 + 'text' => 'Hello, world!', 49 + 'createdAt' => '2024-01-01T12:00:00Z', 50 + ]; 51 + 52 + $result = Schema::validate('app.bsky.feed.post', $validData); 53 + 54 + $this->assertTrue($result); 55 + } 56 + 57 + public function test_it_validates_with_errors(): void 58 + { 59 + $invalidData = [ 60 + 'text' => '', // Required field 61 + ]; 62 + 63 + $errors = Schema::validateWithErrors('app.bsky.feed.post', $invalidData); 64 + 65 + $this->assertIsArray($errors); 66 + $this->assertNotEmpty($errors); 67 + } 68 + 69 + public function test_it_generates_code(): void 70 + { 71 + $code = Schema::generate('app.bsky.feed.post'); 72 + 73 + $this->assertIsString($code); 74 + $this->assertStringContainsString('namespace', $code); 75 + $this->assertStringContainsString('class', $code); 76 + } 77 + 78 + public function test_it_clears_cache_for_specific_nsid(): void 79 + { 80 + // Load to cache 81 + Schema::load('app.bsky.feed.post'); 82 + 83 + // Clear cache should not throw 84 + Schema::clearCache('app.bsky.feed.post'); 85 + 86 + // Should still be able to load after clearing 87 + $schema = Schema::load('app.bsky.feed.post'); 88 + $this->assertSame('app.bsky.feed.post', $schema['id']); 89 + } 90 + 91 + public function test_it_clears_all_cache(): void 92 + { 93 + // Load multiple schemas 94 + Schema::load('app.bsky.feed.post'); 95 + Schema::load('com.atproto.repo.getRecord'); 96 + 97 + // Clear all cache should not throw 98 + Schema::clearCache(); 99 + 100 + // Should still be able to load after clearing 101 + $schema1 = Schema::load('app.bsky.feed.post'); 102 + $schema2 = Schema::load('com.atproto.repo.getRecord'); 103 + 104 + $this->assertSame('app.bsky.feed.post', $schema1['id']); 105 + $this->assertSame('com.atproto.repo.getRecord', $schema2['id']); 106 + } 107 + }
+87
tests/Unit/HelpersTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\SchemaManager; 8 + use SocialDept\Schema\SchemaServiceProvider; 9 + 10 + class HelpersTest extends TestCase 11 + { 12 + protected function getPackageProviders($app): array 13 + { 14 + return [SchemaServiceProvider::class]; 15 + } 16 + 17 + protected function defineEnvironment($app): void 18 + { 19 + $app['config']->set('schema.sources', [__DIR__.'/../fixtures']); 20 + $app['config']->set('schema.cache.enabled', false); 21 + } 22 + 23 + public function test_schema_helper_returns_manager(): void 24 + { 25 + $manager = schema(); 26 + 27 + $this->assertInstanceOf(SchemaManager::class, $manager); 28 + } 29 + 30 + public function test_schema_helper_loads_schema(): void 31 + { 32 + $schema = schema('app.bsky.feed.post'); 33 + 34 + $this->assertIsArray($schema); 35 + $this->assertSame('app.bsky.feed.post', $schema['id']); 36 + } 37 + 38 + public function test_schema_validate_helper_validates_data(): void 39 + { 40 + $validData = [ 41 + 'text' => 'Hello, world!', 42 + 'createdAt' => '2024-01-01T12:00:00Z', 43 + ]; 44 + 45 + $result = schema_validate('app.bsky.feed.post', $validData); 46 + 47 + $this->assertTrue($result); 48 + } 49 + 50 + public function test_schema_validate_helper_returns_false_for_invalid_data(): void 51 + { 52 + $invalidData = [ 53 + 'text' => '', // Required field 54 + ]; 55 + 56 + $result = schema_validate('app.bsky.feed.post', $invalidData); 57 + 58 + $this->assertFalse($result); 59 + } 60 + 61 + public function test_schema_parse_helper_parses_schema(): void 62 + { 63 + $document = schema_parse('app.bsky.feed.post'); 64 + 65 + $this->assertInstanceOf(LexiconDocument::class, $document); 66 + $this->assertSame('app.bsky.feed.post', $document->getNsid()); 67 + } 68 + 69 + public function test_schema_generate_helper_generates_code(): void 70 + { 71 + $code = schema_generate('app.bsky.feed.post'); 72 + 73 + $this->assertIsString($code); 74 + $this->assertStringContainsString('namespace', $code); 75 + $this->assertStringContainsString('class', $code); 76 + } 77 + 78 + public function test_schema_generate_helper_accepts_options(): void 79 + { 80 + $code = schema_generate('app.bsky.feed.post', [ 81 + 'namespace' => 'Custom\\Namespace', 82 + ]); 83 + 84 + $this->assertIsString($code); 85 + $this->assertStringContainsString('namespace', $code); 86 + } 87 + }
+180
tests/Unit/SchemaManagerTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\Generator\DTOGenerator; 8 + use SocialDept\Schema\Parser\SchemaLoader; 9 + use SocialDept\Schema\SchemaManager; 10 + use SocialDept\Schema\Validation\LexiconValidator; 11 + 12 + class SchemaManagerTest extends TestCase 13 + { 14 + protected string $fixturesPath; 15 + 16 + protected SchemaLoader $loader; 17 + 18 + protected LexiconValidator $validator; 19 + 20 + protected DTOGenerator $generator; 21 + 22 + protected SchemaManager $manager; 23 + 24 + protected function setUp(): void 25 + { 26 + parent::setUp(); 27 + 28 + $this->fixturesPath = __DIR__.'/../fixtures'; 29 + 30 + // Create mock dependencies 31 + $this->loader = new SchemaLoader([$this->fixturesPath], false); 32 + $this->validator = new LexiconValidator($this->loader); 33 + $this->generator = new DTOGenerator($this->loader, 'App\\Lexicon', 'app/Lexicon'); 34 + 35 + // Create manager 36 + $this->manager = new SchemaManager( 37 + $this->loader, 38 + $this->validator, 39 + $this->generator 40 + ); 41 + } 42 + 43 + public function test_it_loads_schema(): void 44 + { 45 + $schema = $this->manager->load('app.bsky.feed.post'); 46 + 47 + $this->assertIsArray($schema); 48 + $this->assertSame('app.bsky.feed.post', $schema['id']); 49 + } 50 + 51 + public function test_it_checks_if_schema_exists(): void 52 + { 53 + $this->assertTrue($this->manager->exists('app.bsky.feed.post')); 54 + $this->assertFalse($this->manager->exists('nonexistent.schema')); 55 + } 56 + 57 + public function test_it_parses_schema_into_document(): void 58 + { 59 + $document = $this->manager->parse('app.bsky.feed.post'); 60 + 61 + $this->assertInstanceOf(LexiconDocument::class, $document); 62 + $this->assertSame('app.bsky.feed.post', $document->getNsid()); 63 + $this->assertSame(1, $document->lexicon); 64 + } 65 + 66 + public function test_it_validates_data_against_schema(): void 67 + { 68 + $validData = [ 69 + 'text' => 'Hello, world!', 70 + 'createdAt' => '2024-01-01T12:00:00Z', 71 + ]; 72 + 73 + $result = $this->manager->validate('app.bsky.feed.post', $validData); 74 + 75 + $this->assertTrue($result); 76 + } 77 + 78 + public function test_it_validates_data_with_errors(): void 79 + { 80 + $invalidData = [ 81 + 'text' => '', // Required field 82 + ]; 83 + 84 + $errors = $this->manager->validateWithErrors('app.bsky.feed.post', $invalidData); 85 + 86 + $this->assertIsArray($errors); 87 + $this->assertNotEmpty($errors); 88 + } 89 + 90 + public function test_it_generates_dto_code(): void 91 + { 92 + $code = $this->manager->generate('app.bsky.feed.post'); 93 + 94 + $this->assertIsString($code); 95 + $this->assertStringContainsString('namespace', $code); 96 + $this->assertStringContainsString('class', $code); 97 + } 98 + 99 + public function test_it_throws_when_generator_not_available(): void 100 + { 101 + $managerWithoutGenerator = new SchemaManager( 102 + $this->loader, 103 + $this->validator, 104 + null 105 + ); 106 + 107 + $this->expectException(\RuntimeException::class); 108 + $this->expectExceptionMessage('Generator not available'); 109 + 110 + $managerWithoutGenerator->generate('app.bsky.feed.post'); 111 + } 112 + 113 + public function test_it_clears_cache_for_specific_nsid(): void 114 + { 115 + // Load to cache 116 + $this->manager->load('app.bsky.feed.post'); 117 + 118 + $this->assertContains('app.bsky.feed.post', $this->loader->getCachedNsids()); 119 + 120 + // Clear cache 121 + $this->manager->clearCache('app.bsky.feed.post'); 122 + 123 + $this->assertNotContains('app.bsky.feed.post', $this->loader->getCachedNsids()); 124 + } 125 + 126 + public function test_it_clears_all_cache(): void 127 + { 128 + // Load multiple schemas 129 + $this->manager->load('app.bsky.feed.post'); 130 + $this->manager->load('com.atproto.repo.getRecord'); 131 + 132 + $this->assertCount(2, $this->loader->getCachedNsids()); 133 + 134 + // Clear all 135 + $this->manager->clearCache(); 136 + 137 + $this->assertCount(0, $this->loader->getCachedNsids()); 138 + } 139 + 140 + public function test_it_gets_loader(): void 141 + { 142 + $loader = $this->manager->getLoader(); 143 + 144 + $this->assertSame($this->loader, $loader); 145 + } 146 + 147 + public function test_it_gets_validator(): void 148 + { 149 + $validator = $this->manager->getValidator(); 150 + 151 + $this->assertSame($this->validator, $validator); 152 + } 153 + 154 + public function test_it_gets_generator(): void 155 + { 156 + $generator = $this->manager->getGenerator(); 157 + 158 + $this->assertSame($this->generator, $generator); 159 + } 160 + 161 + public function test_it_sets_generator(): void 162 + { 163 + $newGenerator = new DTOGenerator($this->loader, 'Custom\\Namespace', 'custom/path'); 164 + 165 + $this->manager->setGenerator($newGenerator); 166 + 167 + $this->assertSame($newGenerator, $this->manager->getGenerator()); 168 + } 169 + 170 + public function test_it_gets_null_generator_when_not_set(): void 171 + { 172 + $managerWithoutGenerator = new SchemaManager( 173 + $this->loader, 174 + $this->validator, 175 + null 176 + ); 177 + 178 + $this->assertNull($managerWithoutGenerator->getGenerator()); 179 + } 180 + }