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 union type support

+633
+35
src/Contracts/LexiconRegistry.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Contracts; 4 + 5 + use SocialDept\Schema\Data\LexiconDocument; 6 + 7 + interface LexiconRegistry 8 + { 9 + /** 10 + * Register a lexicon document. 11 + */ 12 + public function register(LexiconDocument $document): void; 13 + 14 + /** 15 + * Get a lexicon document by NSID. 16 + */ 17 + public function get(string $nsid): ?LexiconDocument; 18 + 19 + /** 20 + * Check if a lexicon document exists. 21 + */ 22 + public function has(string $nsid): bool; 23 + 24 + /** 25 + * Get all registered lexicon documents. 26 + * 27 + * @return array<string, LexiconDocument> 28 + */ 29 + public function all(): array; 30 + 31 + /** 32 + * Clear all registered lexicon documents. 33 + */ 34 + public function clear(): void; 35 + }
+170
src/Services/UnionResolver.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Services; 4 + 5 + use SocialDept\Schema\Contracts\LexiconRegistry; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\Exceptions\RecordValidationException; 8 + 9 + class UnionResolver 10 + { 11 + /** 12 + * Create a new UnionResolver. 13 + */ 14 + public function __construct( 15 + protected ?LexiconRegistry $registry = null 16 + ) { 17 + } 18 + 19 + /** 20 + * Resolve union type from data. 21 + * 22 + * Returns the NSID of the matched type for discriminated unions, 23 + * or null for open unions. 24 + */ 25 + public function resolve(mixed $data, array $unionDef): ?string 26 + { 27 + // Check if this is a closed/discriminated union 28 + $closed = $unionDef['closed'] ?? false; 29 + 30 + if ($closed) { 31 + return $this->resolveDiscriminated($data, $unionDef); 32 + } 33 + 34 + return null; 35 + } 36 + 37 + /** 38 + * Resolve discriminated union type. 39 + */ 40 + protected function resolveDiscriminated(mixed $data, array $unionDef): string 41 + { 42 + if (! is_array($data)) { 43 + throw RecordValidationException::invalidType('union', 'object', gettype($data)); 44 + } 45 + 46 + if (! isset($data['$type'])) { 47 + throw RecordValidationException::invalidValue('union', 'Missing required $type field'); 48 + } 49 + 50 + $type = $data['$type']; 51 + $refs = $unionDef['refs'] ?? []; 52 + 53 + if (! in_array($type, $refs, true)) { 54 + throw RecordValidationException::invalidValue( 55 + 'union', 56 + "Type '{$type}' not in union. Allowed: ".implode(', ', $refs) 57 + ); 58 + } 59 + 60 + return $type; 61 + } 62 + 63 + /** 64 + * Check if data matches a specific union type. 65 + */ 66 + public function matches(mixed $data, string $expectedType, array $unionDef): bool 67 + { 68 + try { 69 + $resolvedType = $this->resolve($data, $unionDef); 70 + 71 + if ($resolvedType === null) { 72 + // Open union - can't determine type 73 + return false; 74 + } 75 + 76 + return $resolvedType === $expectedType; 77 + } catch (RecordValidationException) { 78 + return false; 79 + } 80 + } 81 + 82 + /** 83 + * Get the definition for the resolved type. 84 + */ 85 + public function getTypeDefinition(mixed $data, array $unionDef): ?LexiconDocument 86 + { 87 + if ($this->registry === null) { 88 + return null; 89 + } 90 + 91 + $type = $this->resolve($data, $unionDef); 92 + 93 + if ($type === null) { 94 + return null; 95 + } 96 + 97 + return $this->registry->get($type); 98 + } 99 + 100 + /** 101 + * Validate that data is a valid discriminated union. 102 + */ 103 + public function validateDiscriminated(mixed $data, array $refs): void 104 + { 105 + if (! is_array($data)) { 106 + throw RecordValidationException::invalidType('union', 'object', gettype($data)); 107 + } 108 + 109 + if (! isset($data['$type'])) { 110 + throw RecordValidationException::invalidValue('union', 'Missing required $type field'); 111 + } 112 + 113 + $type = $data['$type']; 114 + 115 + if (! in_array($type, $refs, true)) { 116 + throw RecordValidationException::invalidValue( 117 + 'union', 118 + "Type '{$type}' not in union. Allowed: ".implode(', ', $refs) 119 + ); 120 + } 121 + } 122 + 123 + /** 124 + * Extract type from discriminated union data. 125 + */ 126 + public function extractType(mixed $data): ?string 127 + { 128 + if (! is_array($data)) { 129 + return null; 130 + } 131 + 132 + return $data['$type'] ?? null; 133 + } 134 + 135 + /** 136 + * Create discriminated union data. 137 + */ 138 + public function createDiscriminated(string $type, array $data): array 139 + { 140 + return [...$data, '$type' => $type]; 141 + } 142 + 143 + /** 144 + * Check if union definition is closed/discriminated. 145 + */ 146 + public function isClosed(array $unionDef): bool 147 + { 148 + return $unionDef['closed'] ?? false; 149 + } 150 + 151 + /** 152 + * Get all possible types from union definition. 153 + * 154 + * @return array<string> 155 + */ 156 + public function getTypes(array $unionDef): array 157 + { 158 + return $unionDef['refs'] ?? []; 159 + } 160 + 161 + /** 162 + * Set the lexicon registry. 163 + */ 164 + public function setRegistry(LexiconRegistry $registry): self 165 + { 166 + $this->registry = $registry; 167 + 168 + return $this; 169 + } 170 + }
+428
tests/Unit/Services/UnionResolverTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Services; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Contracts\LexiconRegistry; 7 + use SocialDept\Schema\Data\LexiconDocument; 8 + use SocialDept\Schema\Exceptions\RecordValidationException; 9 + use SocialDept\Schema\Parser\Nsid; 10 + use SocialDept\Schema\Services\UnionResolver; 11 + 12 + class UnionResolverTest extends TestCase 13 + { 14 + protected UnionResolver $resolver; 15 + 16 + protected function setUp(): void 17 + { 18 + parent::setUp(); 19 + 20 + $this->resolver = new UnionResolver(); 21 + } 22 + 23 + public function test_it_resolves_discriminated_union(): void 24 + { 25 + $data = ['$type' => 'app.bsky.feed.post']; 26 + 27 + $unionDef = [ 28 + 'type' => 'union', 29 + 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'], 30 + 'closed' => true, 31 + ]; 32 + 33 + $type = $this->resolver->resolve($data, $unionDef); 34 + 35 + $this->assertEquals('app.bsky.feed.post', $type); 36 + } 37 + 38 + public function test_it_returns_null_for_open_union(): void 39 + { 40 + $data = ['text' => 'Hello']; 41 + 42 + $unionDef = [ 43 + 'type' => 'union', 44 + 'refs' => ['app.bsky.feed.post'], 45 + 'closed' => false, 46 + ]; 47 + 48 + $type = $this->resolver->resolve($data, $unionDef); 49 + 50 + $this->assertNull($type); 51 + } 52 + 53 + public function test_it_throws_exception_for_discriminated_union_without_type(): void 54 + { 55 + $this->expectException(RecordValidationException::class); 56 + 57 + $data = ['text' => 'Hello']; 58 + 59 + $unionDef = [ 60 + 'type' => 'union', 61 + 'refs' => ['app.bsky.feed.post'], 62 + 'closed' => true, 63 + ]; 64 + 65 + $this->resolver->resolve($data, $unionDef); 66 + } 67 + 68 + public function test_it_throws_exception_for_invalid_type(): void 69 + { 70 + $this->expectException(RecordValidationException::class); 71 + 72 + $data = ['$type' => 'app.bsky.feed.invalid']; 73 + 74 + $unionDef = [ 75 + 'type' => 'union', 76 + 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'], 77 + 'closed' => true, 78 + ]; 79 + 80 + $this->resolver->resolve($data, $unionDef); 81 + } 82 + 83 + public function test_it_throws_exception_for_non_object_discriminated_union(): void 84 + { 85 + $this->expectException(RecordValidationException::class); 86 + 87 + $unionDef = [ 88 + 'type' => 'union', 89 + 'refs' => ['app.bsky.feed.post'], 90 + 'closed' => true, 91 + ]; 92 + 93 + $this->resolver->resolve('not an object', $unionDef); 94 + } 95 + 96 + public function test_it_checks_if_data_matches_type(): void 97 + { 98 + $data = ['$type' => 'app.bsky.feed.post']; 99 + 100 + $unionDef = [ 101 + 'type' => 'union', 102 + 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'], 103 + 'closed' => true, 104 + ]; 105 + 106 + $this->assertTrue($this->resolver->matches($data, 'app.bsky.feed.post', $unionDef)); 107 + $this->assertFalse($this->resolver->matches($data, 'app.bsky.feed.repost', $unionDef)); 108 + } 109 + 110 + public function test_it_returns_false_for_invalid_data_when_checking_match(): void 111 + { 112 + $data = ['text' => 'Hello']; 113 + 114 + $unionDef = [ 115 + 'type' => 'union', 116 + 'refs' => ['app.bsky.feed.post'], 117 + 'closed' => true, 118 + ]; 119 + 120 + $this->assertFalse($this->resolver->matches($data, 'app.bsky.feed.post', $unionDef)); 121 + } 122 + 123 + public function test_it_returns_false_for_open_union_when_checking_match(): void 124 + { 125 + $data = ['$type' => 'app.bsky.feed.post']; 126 + 127 + $unionDef = [ 128 + 'type' => 'union', 129 + 'refs' => ['app.bsky.feed.post'], 130 + 'closed' => false, 131 + ]; 132 + 133 + $this->assertFalse($this->resolver->matches($data, 'app.bsky.feed.post', $unionDef)); 134 + } 135 + 136 + public function test_it_gets_type_definition_with_registry(): void 137 + { 138 + // Create a simple registry implementation 139 + $registry = new class () implements LexiconRegistry { 140 + public function register(LexiconDocument $document): void 141 + { 142 + } 143 + 144 + public function get(string $nsid): ?LexiconDocument 145 + { 146 + return new LexiconDocument( 147 + 1, 148 + Nsid::parse('app.bsky.feed.post'), 149 + ['main' => ['type' => 'record']] 150 + ); 151 + } 152 + 153 + public function has(string $nsid): bool 154 + { 155 + return true; 156 + } 157 + 158 + public function all(): array 159 + { 160 + return []; 161 + } 162 + 163 + public function clear(): void 164 + { 165 + } 166 + }; 167 + 168 + $this->resolver->setRegistry($registry); 169 + 170 + $data = ['$type' => 'app.bsky.feed.post']; 171 + 172 + $unionDef = [ 173 + 'type' => 'union', 174 + 'refs' => ['app.bsky.feed.post'], 175 + 'closed' => true, 176 + ]; 177 + 178 + $result = $this->resolver->getTypeDefinition($data, $unionDef); 179 + 180 + $this->assertInstanceOf(LexiconDocument::class, $result); 181 + $this->assertEquals('app.bsky.feed.post', $result->getNsid()); 182 + } 183 + 184 + public function test_it_returns_null_for_type_definition_without_registry(): void 185 + { 186 + $data = ['$type' => 'app.bsky.feed.post']; 187 + 188 + $unionDef = [ 189 + 'type' => 'union', 190 + 'refs' => ['app.bsky.feed.post'], 191 + 'closed' => true, 192 + ]; 193 + 194 + $result = $this->resolver->getTypeDefinition($data, $unionDef); 195 + 196 + $this->assertNull($result); 197 + } 198 + 199 + public function test_it_returns_null_for_type_definition_with_open_union(): void 200 + { 201 + // Create a simple registry implementation 202 + $registry = new class () implements LexiconRegistry { 203 + public function register(LexiconDocument $document): void 204 + { 205 + } 206 + 207 + public function get(string $nsid): ?LexiconDocument 208 + { 209 + return null; 210 + } 211 + 212 + public function has(string $nsid): bool 213 + { 214 + return false; 215 + } 216 + 217 + public function all(): array 218 + { 219 + return []; 220 + } 221 + 222 + public function clear(): void 223 + { 224 + } 225 + }; 226 + 227 + $this->resolver->setRegistry($registry); 228 + 229 + $data = ['text' => 'Hello']; 230 + 231 + $unionDef = [ 232 + 'type' => 'union', 233 + 'refs' => ['app.bsky.feed.post'], 234 + 'closed' => false, 235 + ]; 236 + 237 + $result = $this->resolver->getTypeDefinition($data, $unionDef); 238 + 239 + $this->assertNull($result); 240 + } 241 + 242 + public function test_it_validates_discriminated_union(): void 243 + { 244 + $data = ['$type' => 'app.bsky.feed.post']; 245 + $refs = ['app.bsky.feed.post', 'app.bsky.feed.repost']; 246 + 247 + $this->resolver->validateDiscriminated($data, $refs); 248 + 249 + $this->assertTrue(true); // No exception thrown 250 + } 251 + 252 + public function test_it_throws_exception_when_validating_non_object(): void 253 + { 254 + $this->expectException(RecordValidationException::class); 255 + 256 + $this->resolver->validateDiscriminated('not an object', ['app.bsky.feed.post']); 257 + } 258 + 259 + public function test_it_throws_exception_when_validating_without_type(): void 260 + { 261 + $this->expectException(RecordValidationException::class); 262 + 263 + $this->resolver->validateDiscriminated(['text' => 'Hello'], ['app.bsky.feed.post']); 264 + } 265 + 266 + public function test_it_throws_exception_when_validating_invalid_type(): void 267 + { 268 + $this->expectException(RecordValidationException::class); 269 + 270 + $data = ['$type' => 'app.bsky.feed.invalid']; 271 + 272 + $this->resolver->validateDiscriminated($data, ['app.bsky.feed.post']); 273 + } 274 + 275 + public function test_it_extracts_type_from_data(): void 276 + { 277 + $data = ['$type' => 'app.bsky.feed.post', 'text' => 'Hello']; 278 + 279 + $type = $this->resolver->extractType($data); 280 + 281 + $this->assertEquals('app.bsky.feed.post', $type); 282 + } 283 + 284 + public function test_it_returns_null_when_extracting_type_from_non_object(): void 285 + { 286 + $type = $this->resolver->extractType('not an object'); 287 + 288 + $this->assertNull($type); 289 + } 290 + 291 + public function test_it_returns_null_when_extracting_type_without_type_field(): void 292 + { 293 + $data = ['text' => 'Hello']; 294 + 295 + $type = $this->resolver->extractType($data); 296 + 297 + $this->assertNull($type); 298 + } 299 + 300 + public function test_it_creates_discriminated_union_data(): void 301 + { 302 + $data = $this->resolver->createDiscriminated('app.bsky.feed.post', [ 303 + 'text' => 'Hello', 304 + 'createdAt' => '2024-01-01T00:00:00Z', 305 + ]); 306 + 307 + $this->assertEquals([ 308 + '$type' => 'app.bsky.feed.post', 309 + 'text' => 'Hello', 310 + 'createdAt' => '2024-01-01T00:00:00Z', 311 + ], $data); 312 + } 313 + 314 + public function test_it_checks_if_union_is_closed(): void 315 + { 316 + $closedUnion = ['closed' => true]; 317 + $openUnion = ['closed' => false]; 318 + $defaultUnion = []; 319 + 320 + $this->assertTrue($this->resolver->isClosed($closedUnion)); 321 + $this->assertFalse($this->resolver->isClosed($openUnion)); 322 + $this->assertFalse($this->resolver->isClosed($defaultUnion)); 323 + } 324 + 325 + public function test_it_gets_union_types(): void 326 + { 327 + $unionDef = [ 328 + 'type' => 'union', 329 + 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'], 330 + ]; 331 + 332 + $types = $this->resolver->getTypes($unionDef); 333 + 334 + $this->assertEquals(['app.bsky.feed.post', 'app.bsky.feed.repost'], $types); 335 + } 336 + 337 + public function test_it_returns_empty_array_for_union_without_refs(): void 338 + { 339 + $unionDef = ['type' => 'union']; 340 + 341 + $types = $this->resolver->getTypes($unionDef); 342 + 343 + $this->assertEquals([], $types); 344 + } 345 + 346 + public function test_it_allows_setting_registry(): void 347 + { 348 + // Create a simple registry implementation 349 + $registry = new class () implements LexiconRegistry { 350 + public function register(LexiconDocument $document): void 351 + { 352 + } 353 + 354 + public function get(string $nsid): ?LexiconDocument 355 + { 356 + return null; 357 + } 358 + 359 + public function has(string $nsid): bool 360 + { 361 + return false; 362 + } 363 + 364 + public function all(): array 365 + { 366 + return []; 367 + } 368 + 369 + public function clear(): void 370 + { 371 + } 372 + }; 373 + 374 + $result = $this->resolver->setRegistry($registry); 375 + 376 + $this->assertSame($this->resolver, $result); 377 + } 378 + 379 + public function test_it_handles_multiple_types_in_discriminated_union(): void 380 + { 381 + $refs = [ 382 + 'app.bsky.feed.post', 383 + 'app.bsky.feed.repost', 384 + 'app.bsky.feed.like', 385 + ]; 386 + 387 + $unionDef = [ 388 + 'type' => 'union', 389 + 'refs' => $refs, 390 + 'closed' => true, 391 + ]; 392 + 393 + foreach ($refs as $ref) { 394 + $data = ['$type' => $ref]; 395 + $type = $this->resolver->resolve($data, $unionDef); 396 + $this->assertEquals($ref, $type); 397 + } 398 + } 399 + 400 + public function test_it_preserves_data_when_creating_discriminated_union(): void 401 + { 402 + $originalData = [ 403 + 'field1' => 'value1', 404 + 'field2' => 123, 405 + 'field3' => ['nested' => 'data'], 406 + ]; 407 + 408 + $data = $this->resolver->createDiscriminated('app.bsky.feed.post', $originalData); 409 + 410 + $this->assertEquals('app.bsky.feed.post', $data['$type']); 411 + $this->assertEquals('value1', $data['field1']); 412 + $this->assertEquals(123, $data['field2']); 413 + $this->assertEquals(['nested' => 'data'], $data['field3']); 414 + } 415 + 416 + public function test_it_overwrites_existing_type_when_creating_discriminated_union(): void 417 + { 418 + $originalData = [ 419 + '$type' => 'old.type', 420 + 'text' => 'Hello', 421 + ]; 422 + 423 + $data = $this->resolver->createDiscriminated('app.bsky.feed.post', $originalData); 424 + 425 + $this->assertEquals('app.bsky.feed.post', $data['$type']); 426 + $this->assertEquals('Hello', $data['text']); 427 + } 428 + }