+245
src/Parser/SchemaLoader.php
+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
+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
+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
+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
+
];