Parse and validate AT Protocol Lexicons with DTO generation for Laravel

Add schema loader with multi-source support

Changed files
+500
src
tests
+245
src/Parser/SchemaLoader.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Parser; 4 + 5 + use Illuminate\Support\Facades\Cache; 6 + use SocialDept\Schema\Exceptions\SchemaNotFoundException; 7 + use SocialDept\Schema\Exceptions\SchemaParseException; 8 + 9 + class SchemaLoader 10 + { 11 + /** 12 + * In-memory cache of loaded schemas for current request. 13 + * 14 + * @var array<string, array> 15 + */ 16 + protected array $memoryCache = []; 17 + 18 + /** 19 + * Schema source directories. 20 + * 21 + * @var array<string> 22 + */ 23 + protected array $sources; 24 + 25 + /** 26 + * Whether to use Laravel cache. 27 + */ 28 + protected bool $useCache; 29 + 30 + /** 31 + * Cache TTL in seconds. 32 + */ 33 + protected int $cacheTtl; 34 + 35 + /** 36 + * Cache key prefix. 37 + */ 38 + protected string $cachePrefix; 39 + 40 + /** 41 + * Create a new SchemaLoader instance. 42 + * 43 + * @param array<string> $sources 44 + */ 45 + public function __construct( 46 + array $sources, 47 + bool $useCache = true, 48 + int $cacheTtl = 3600, 49 + string $cachePrefix = 'schema' 50 + ) { 51 + $this->sources = $sources; 52 + $this->useCache = $useCache; 53 + $this->cacheTtl = $cacheTtl; 54 + $this->cachePrefix = $cachePrefix; 55 + } 56 + 57 + /** 58 + * Load schema by NSID. 59 + */ 60 + public function load(string $nsid): array 61 + { 62 + // Check memory cache first 63 + if (isset($this->memoryCache[$nsid])) { 64 + return $this->memoryCache[$nsid]; 65 + } 66 + 67 + // Check Laravel cache 68 + if ($this->useCache) { 69 + $cacheKey = $this->getCacheKey($nsid); 70 + $cached = Cache::get($cacheKey); 71 + 72 + if ($cached !== null) { 73 + $this->memoryCache[$nsid] = $cached; 74 + 75 + return $cached; 76 + } 77 + } 78 + 79 + // Load from sources 80 + $schema = $this->loadFromSources($nsid); 81 + 82 + // Cache the result 83 + $this->memoryCache[$nsid] = $schema; 84 + 85 + if ($this->useCache) { 86 + Cache::put($this->getCacheKey($nsid), $schema, $this->cacheTtl); 87 + } 88 + 89 + return $schema; 90 + } 91 + 92 + /** 93 + * Check if schema exists. 94 + */ 95 + public function exists(string $nsid): bool 96 + { 97 + try { 98 + $this->load($nsid); 99 + 100 + return true; 101 + } catch (SchemaNotFoundException) { 102 + return false; 103 + } 104 + } 105 + 106 + /** 107 + * Load schema from configured sources. 108 + */ 109 + protected function loadFromSources(string $nsid): array 110 + { 111 + foreach ($this->sources as $source) { 112 + // Try to load from this source 113 + $schema = $this->loadFromSource($nsid, $source); 114 + 115 + if ($schema !== null) { 116 + return $schema; 117 + } 118 + } 119 + 120 + throw SchemaNotFoundException::forNsid($nsid); 121 + } 122 + 123 + /** 124 + * Load schema from a specific source directory. 125 + */ 126 + protected function loadFromSource(string $nsid, string $source): ?array 127 + { 128 + // Try NSID-based path (app.bsky.feed.post -> app/bsky/feed/post.json) 129 + $nsidPath = $this->nsidToPath($nsid); 130 + $jsonPath = $source . '/' . $nsidPath . '.json'; 131 + 132 + if (file_exists($jsonPath)) { 133 + return $this->loadJsonFile($jsonPath, $nsid); 134 + } 135 + 136 + // Try PHP file 137 + $phpPath = $source . '/' . $nsidPath . '.php'; 138 + 139 + if (file_exists($phpPath)) { 140 + return $this->loadPhpFile($phpPath, $nsid); 141 + } 142 + 143 + // Try flat structure (app.bsky.feed.post.json) 144 + $flatJsonPath = $source . '/' . $nsid . '.json'; 145 + 146 + if (file_exists($flatJsonPath)) { 147 + return $this->loadJsonFile($flatJsonPath, $nsid); 148 + } 149 + 150 + $flatPhpPath = $source . '/' . $nsid . '.php'; 151 + 152 + if (file_exists($flatPhpPath)) { 153 + return $this->loadPhpFile($flatPhpPath, $nsid); 154 + } 155 + 156 + return null; 157 + } 158 + 159 + /** 160 + * Convert NSID to file path (app.bsky.feed.post -> app/bsky/feed/post). 161 + */ 162 + protected function nsidToPath(string $nsid): string 163 + { 164 + return str_replace('.', '/', $nsid); 165 + } 166 + 167 + /** 168 + * Load and parse JSON file. 169 + */ 170 + protected function loadJsonFile(string $path, string $nsid): array 171 + { 172 + $contents = file_get_contents($path); 173 + 174 + if ($contents === false) { 175 + throw SchemaNotFoundException::forFile($path); 176 + } 177 + 178 + $data = json_decode($contents, true); 179 + 180 + if (json_last_error() !== JSON_ERROR_NONE) { 181 + throw SchemaParseException::invalidJson($nsid, json_last_error_msg()); 182 + } 183 + 184 + if (! is_array($data)) { 185 + throw SchemaParseException::malformed($nsid, 'Schema must be a JSON object'); 186 + } 187 + 188 + return $data; 189 + } 190 + 191 + /** 192 + * Load PHP file returning array. 193 + */ 194 + protected function loadPhpFile(string $path, string $nsid): array 195 + { 196 + $data = include $path; 197 + 198 + if (! is_array($data)) { 199 + throw SchemaParseException::malformed($nsid, 'PHP file must return an array'); 200 + } 201 + 202 + return $data; 203 + } 204 + 205 + /** 206 + * Get cache key for NSID. 207 + */ 208 + protected function getCacheKey(string $nsid): string 209 + { 210 + return "{$this->cachePrefix}:parsed:{$nsid}"; 211 + } 212 + 213 + /** 214 + * Clear cached schema. 215 + */ 216 + public function clearCache(?string $nsid = null): void 217 + { 218 + if ($nsid === null) { 219 + // Clear all memory cache 220 + $this->memoryCache = []; 221 + 222 + // Note: Can't easily clear all Laravel cache entries with prefix 223 + // Users should call Cache::flush() or use cache tags if needed 224 + return; 225 + } 226 + 227 + // Clear specific NSID from memory cache 228 + unset($this->memoryCache[$nsid]); 229 + 230 + // Clear from Laravel cache 231 + if ($this->useCache) { 232 + Cache::forget($this->getCacheKey($nsid)); 233 + } 234 + } 235 + 236 + /** 237 + * Get all cached NSIDs from memory. 238 + * 239 + * @return array<string> 240 + */ 241 + public function getCachedNsids(): array 242 + { 243 + return array_keys($this->memoryCache); 244 + } 245 + }
+202
tests/Unit/Parser/SchemaLoaderTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Parser; 4 + 5 + use Illuminate\Support\Facades\Cache; 6 + use Orchestra\Testbench\TestCase; 7 + use SocialDept\Schema\Exceptions\SchemaNotFoundException; 8 + use SocialDept\Schema\Exceptions\SchemaParseException; 9 + use SocialDept\Schema\Parser\SchemaLoader; 10 + 11 + class SchemaLoaderTest extends TestCase 12 + { 13 + protected string $fixturesPath; 14 + 15 + protected function setUp(): void 16 + { 17 + parent::setUp(); 18 + 19 + $this->fixturesPath = __DIR__ . '/../../fixtures'; 20 + 21 + // Clear cache before each test 22 + Cache::flush(); 23 + } 24 + 25 + public function test_it_loads_schema_from_hierarchical_json(): void 26 + { 27 + $loader = new SchemaLoader([$this->fixturesPath], false); 28 + 29 + $schema = $loader->load('app.bsky.feed.post'); 30 + 31 + $this->assertIsArray($schema); 32 + $this->assertSame(1, $schema['lexicon']); 33 + $this->assertSame('app.bsky.feed.post', $schema['id']); 34 + } 35 + 36 + public function test_it_loads_schema_from_flat_php(): void 37 + { 38 + $loader = new SchemaLoader([$this->fixturesPath], false); 39 + 40 + $schema = $loader->load('com.atproto.repo.getRecord'); 41 + 42 + $this->assertIsArray($schema); 43 + $this->assertSame(1, $schema['lexicon']); 44 + $this->assertSame('com.atproto.repo.getRecord', $schema['id']); 45 + } 46 + 47 + public function test_it_checks_if_schema_exists(): void 48 + { 49 + $loader = new SchemaLoader([$this->fixturesPath], false); 50 + 51 + $this->assertTrue($loader->exists('app.bsky.feed.post')); 52 + $this->assertTrue($loader->exists('com.atproto.repo.getRecord')); 53 + $this->assertFalse($loader->exists('nonexistent.schema')); 54 + } 55 + 56 + public function test_it_throws_when_schema_not_found(): void 57 + { 58 + $loader = new SchemaLoader([$this->fixturesPath], false); 59 + 60 + $this->expectException(SchemaNotFoundException::class); 61 + $this->expectExceptionMessage('Schema not found for NSID: nonexistent.schema'); 62 + 63 + $loader->load('nonexistent.schema'); 64 + } 65 + 66 + public function test_it_caches_schemas_in_memory(): void 67 + { 68 + $loader = new SchemaLoader([$this->fixturesPath], false); 69 + 70 + // First load 71 + $schema1 = $loader->load('app.bsky.feed.post'); 72 + 73 + // Second load should come from memory 74 + $schema2 = $loader->load('app.bsky.feed.post'); 75 + 76 + $this->assertSame($schema1, $schema2); 77 + $this->assertContains('app.bsky.feed.post', $loader->getCachedNsids()); 78 + } 79 + 80 + public function test_it_caches_schemas_in_laravel_cache(): void 81 + { 82 + $loader = new SchemaLoader([$this->fixturesPath], true, 3600, 'schema'); 83 + 84 + // First load - should store in cache 85 + $schema = $loader->load('app.bsky.feed.post'); 86 + 87 + // Clear memory cache to force Laravel cache lookup 88 + $loader->clearCache('app.bsky.feed.post'); 89 + 90 + // Manually put it back in Laravel cache 91 + Cache::put('schema:parsed:app.bsky.feed.post', $schema, 3600); 92 + 93 + // This should retrieve from Laravel cache 94 + $cached = $loader->load('app.bsky.feed.post'); 95 + 96 + $this->assertSame($schema, $cached); 97 + } 98 + 99 + public function test_it_retrieves_from_laravel_cache(): void 100 + { 101 + $loader = new SchemaLoader([$this->fixturesPath], true); 102 + 103 + // First load to cache it 104 + $originalSchema = $loader->load('app.bsky.feed.post'); 105 + 106 + // Clear memory cache 107 + $loader->clearCache('app.bsky.feed.post'); 108 + 109 + // Second load should come from Laravel cache 110 + $cachedSchema = $loader->load('app.bsky.feed.post'); 111 + 112 + $this->assertSame($originalSchema, $cachedSchema); 113 + $this->assertSame('app.bsky.feed.post', $cachedSchema['id']); 114 + } 115 + 116 + public function test_it_clears_specific_schema_cache(): void 117 + { 118 + $loader = new SchemaLoader([$this->fixturesPath], true); 119 + 120 + // Load to populate caches 121 + $loader->load('app.bsky.feed.post'); 122 + 123 + $this->assertContains('app.bsky.feed.post', $loader->getCachedNsids()); 124 + 125 + // Clear cache 126 + $loader->clearCache('app.bsky.feed.post'); 127 + 128 + // Memory cache should be cleared 129 + $this->assertNotContains('app.bsky.feed.post', $loader->getCachedNsids()); 130 + 131 + // Laravel cache should also be cleared (verify by loading again and checking it comes from file) 132 + $this->assertFalse(Cache::has('schema:parsed:app.bsky.feed.post')); 133 + } 134 + 135 + public function test_it_clears_all_memory_cache(): void 136 + { 137 + $loader = new SchemaLoader([$this->fixturesPath], false); 138 + 139 + // Load multiple schemas 140 + $loader->load('app.bsky.feed.post'); 141 + $loader->load('com.atproto.repo.getRecord'); 142 + 143 + $this->assertCount(2, $loader->getCachedNsids()); 144 + 145 + // Clear all 146 + $loader->clearCache(); 147 + 148 + $this->assertCount(0, $loader->getCachedNsids()); 149 + } 150 + 151 + public function test_it_searches_multiple_sources_in_order(): void 152 + { 153 + $source1 = $this->fixturesPath . '/source1'; 154 + $source2 = $this->fixturesPath; 155 + 156 + // Schema only exists in source2 157 + $loader = new SchemaLoader([$source1, $source2], false); 158 + 159 + $schema = $loader->load('app.bsky.feed.post'); 160 + 161 + $this->assertSame('app.bsky.feed.post', $schema['id']); 162 + } 163 + 164 + public function test_it_throws_on_invalid_json(): void 165 + { 166 + $invalidPath = $this->fixturesPath . '/invalid'; 167 + @mkdir($invalidPath, 0755, true); 168 + file_put_contents($invalidPath . '/invalid.json', '{invalid json}'); 169 + 170 + $loader = new SchemaLoader([$invalidPath], false); 171 + 172 + $this->expectException(SchemaParseException::class); 173 + $this->expectExceptionMessage('Failed to parse schema JSON'); 174 + 175 + try { 176 + $loader->load('invalid'); 177 + } finally { 178 + @unlink($invalidPath . '/invalid.json'); 179 + @rmdir($invalidPath); 180 + } 181 + } 182 + 183 + public function test_it_throws_on_php_file_not_returning_array(): void 184 + { 185 + $invalidPath = $this->fixturesPath . '/invalid'; 186 + @mkdir($invalidPath, 0755, true); 187 + file_put_contents($invalidPath . '/invalid.php', '<?php return "not an array";'); 188 + 189 + $loader = new SchemaLoader([$invalidPath], false); 190 + 191 + $this->expectException(SchemaParseException::class); 192 + $this->expectExceptionMessage('PHP file must return an array'); 193 + 194 + try { 195 + $loader->load('invalid'); 196 + } finally { 197 + @unlink($invalidPath . '/invalid.php'); 198 + @rmdir($invalidPath); 199 + } 200 + } 201 + 202 + }
+26
tests/fixtures/app/bsky/feed/post.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.feed.post", 4 + "description": "A post record", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["text", "createdAt"], 12 + "properties": { 13 + "text": { 14 + "type": "string", 15 + "maxLength": 300, 16 + "maxGraphemes": 300 17 + }, 18 + "createdAt": { 19 + "type": "string", 20 + "format": "datetime" 21 + } 22 + } 23 + } 24 + } 25 + } 26 + }
+27
tests/fixtures/com.atproto.repo.getRecord.php
··· 1 + <?php 2 + 3 + return [ 4 + 'lexicon' => 1, 5 + 'id' => 'com.atproto.repo.getRecord', 6 + 'description' => 'Get a record', 7 + 'defs' => [ 8 + 'main' => [ 9 + 'type' => 'query', 10 + 'parameters' => [ 11 + 'type' => 'params', 12 + 'required' => ['repo', 'collection', 'rkey'], 13 + 'properties' => [ 14 + 'repo' => [ 15 + 'type' => 'string', 16 + ], 17 + 'collection' => [ 18 + 'type' => 'string', 19 + ], 20 + 'rkey' => [ 21 + 'type' => 'string', 22 + ], 23 + ], 24 + ], 25 + ], 26 + ], 27 + ];