Parse and validate AT Protocol Lexicons with DTO generation for Laravel

Implement model mapping

Changed files
+557
src
Contracts
Services
tests
Unit
+21
src/Contracts/Transformer.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Contracts; 4 + 5 + interface Transformer 6 + { 7 + /** 8 + * Transform raw data to model. 9 + */ 10 + public function fromArray(array $data): mixed; 11 + 12 + /** 13 + * Transform model to raw data. 14 + */ 15 + public function toArray(mixed $model): array; 16 + 17 + /** 18 + * Check if this transformer supports the given type. 19 + */ 20 + public function supports(string $type): bool; 21 + }
+192
src/Services/ModelMapper.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Services; 4 + 5 + use SocialDept\Schema\Contracts\Transformer; 6 + use SocialDept\Schema\Exceptions\SchemaException; 7 + 8 + class ModelMapper 9 + { 10 + /** 11 + * Registered transformers. 12 + * 13 + * @var array<string, Transformer> 14 + */ 15 + protected array $transformers = []; 16 + 17 + /** 18 + * Register a transformer for a specific type. 19 + */ 20 + public function register(string $type, Transformer $transformer): self 21 + { 22 + $this->transformers[$type] = $transformer; 23 + 24 + return $this; 25 + } 26 + 27 + /** 28 + * Register multiple transformers at once. 29 + * 30 + * @param array<string, Transformer> $transformers 31 + */ 32 + public function registerMany(array $transformers): self 33 + { 34 + foreach ($transformers as $type => $transformer) { 35 + $this->register($type, $transformer); 36 + } 37 + 38 + return $this; 39 + } 40 + 41 + /** 42 + * Transform raw data to model. 43 + */ 44 + public function fromArray(string $type, array $data): mixed 45 + { 46 + $transformer = $this->getTransformer($type); 47 + 48 + if ($transformer === null) { 49 + throw SchemaException::withContext( 50 + "No transformer registered for type '{$type}'", 51 + ['type' => $type] 52 + ); 53 + } 54 + 55 + return $transformer->fromArray($data); 56 + } 57 + 58 + /** 59 + * Transform model to raw data. 60 + */ 61 + public function toArray(string $type, mixed $model): array 62 + { 63 + $transformer = $this->getTransformer($type); 64 + 65 + if ($transformer === null) { 66 + throw SchemaException::withContext( 67 + "No transformer registered for type '{$type}'", 68 + ['type' => $type] 69 + ); 70 + } 71 + 72 + return $transformer->toArray($model); 73 + } 74 + 75 + /** 76 + * Transform multiple items from arrays. 77 + * 78 + * @param array<array> $items 79 + * @return array<mixed> 80 + */ 81 + public function fromArrayMany(string $type, array $items): array 82 + { 83 + return array_map( 84 + fn (array $item) => $this->fromArray($type, $item), 85 + $items 86 + ); 87 + } 88 + 89 + /** 90 + * Transform multiple items to arrays. 91 + * 92 + * @param array<mixed> $items 93 + * @return array<array> 94 + */ 95 + public function toArrayMany(string $type, array $items): array 96 + { 97 + return array_map( 98 + fn (mixed $item) => $this->toArray($type, $item), 99 + $items 100 + ); 101 + } 102 + 103 + /** 104 + * Get transformer for a specific type. 105 + */ 106 + public function getTransformer(string $type): ?Transformer 107 + { 108 + // Check exact match first 109 + if (isset($this->transformers[$type])) { 110 + return $this->transformers[$type]; 111 + } 112 + 113 + // Check if any transformer supports this type 114 + foreach ($this->transformers as $transformer) { 115 + if ($transformer->supports($type)) { 116 + return $transformer; 117 + } 118 + } 119 + 120 + return null; 121 + } 122 + 123 + /** 124 + * Check if a transformer is registered for a type. 125 + */ 126 + public function has(string $type): bool 127 + { 128 + return $this->getTransformer($type) !== null; 129 + } 130 + 131 + /** 132 + * Unregister a transformer. 133 + */ 134 + public function unregister(string $type): self 135 + { 136 + unset($this->transformers[$type]); 137 + 138 + return $this; 139 + } 140 + 141 + /** 142 + * Get all registered transformers. 143 + * 144 + * @return array<string, Transformer> 145 + */ 146 + public function all(): array 147 + { 148 + return $this->transformers; 149 + } 150 + 151 + /** 152 + * Clear all registered transformers. 153 + */ 154 + public function clear(): self 155 + { 156 + $this->transformers = []; 157 + 158 + return $this; 159 + } 160 + 161 + /** 162 + * Try to transform from array, return null if transformer not found. 163 + */ 164 + public function tryFromArray(string $type, array $data): mixed 165 + { 166 + if (! $this->has($type)) { 167 + return null; 168 + } 169 + 170 + return $this->fromArray($type, $data); 171 + } 172 + 173 + /** 174 + * Try to transform to array, return null if transformer not found. 175 + */ 176 + public function tryToArray(string $type, mixed $model): ?array 177 + { 178 + if (! $this->has($type)) { 179 + return null; 180 + } 181 + 182 + return $this->toArray($type, $model); 183 + } 184 + 185 + /** 186 + * Get count of registered transformers. 187 + */ 188 + public function count(): int 189 + { 190 + return count($this->transformers); 191 + } 192 + }
+344
tests/Unit/Services/ModelMapperTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Services; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Contracts\Transformer; 7 + use SocialDept\Schema\Exceptions\SchemaException; 8 + use SocialDept\Schema\Services\ModelMapper; 9 + 10 + class ModelMapperTest extends TestCase 11 + { 12 + protected ModelMapper $mapper; 13 + 14 + protected function setUp(): void 15 + { 16 + parent::setUp(); 17 + 18 + $this->mapper = new ModelMapper(); 19 + } 20 + 21 + public function test_it_registers_transformer(): void 22 + { 23 + $transformer = $this->createTestTransformer('app.bsky.feed.post'); 24 + 25 + $this->mapper->register('app.bsky.feed.post', $transformer); 26 + 27 + $this->assertTrue($this->mapper->has('app.bsky.feed.post')); 28 + } 29 + 30 + public function test_it_registers_multiple_transformers(): void 31 + { 32 + $transformer1 = $this->createTestTransformer('app.bsky.feed.post'); 33 + $transformer2 = $this->createTestTransformer('app.bsky.feed.repost'); 34 + 35 + $this->mapper->registerMany([ 36 + 'app.bsky.feed.post' => $transformer1, 37 + 'app.bsky.feed.repost' => $transformer2, 38 + ]); 39 + 40 + $this->assertTrue($this->mapper->has('app.bsky.feed.post')); 41 + $this->assertTrue($this->mapper->has('app.bsky.feed.repost')); 42 + } 43 + 44 + public function test_it_transforms_from_array(): void 45 + { 46 + $transformer = $this->createTestTransformer('app.bsky.feed.post'); 47 + $this->mapper->register('app.bsky.feed.post', $transformer); 48 + 49 + $data = ['text' => 'Hello, world!']; 50 + $model = $this->mapper->fromArray('app.bsky.feed.post', $data); 51 + 52 + $this->assertInstanceOf(\stdClass::class, $model); 53 + $this->assertEquals('Hello, world!', $model->text); 54 + } 55 + 56 + public function test_it_transforms_to_array(): void 57 + { 58 + $transformer = $this->createTestTransformer('app.bsky.feed.post'); 59 + $this->mapper->register('app.bsky.feed.post', $transformer); 60 + 61 + $model = (object) ['text' => 'Hello, world!']; 62 + $data = $this->mapper->toArray('app.bsky.feed.post', $model); 63 + 64 + $this->assertEquals(['text' => 'Hello, world!'], $data); 65 + } 66 + 67 + public function test_it_throws_exception_when_transformer_not_found_for_from_array(): void 68 + { 69 + $this->expectException(SchemaException::class); 70 + 71 + $this->mapper->fromArray('unknown.type', []); 72 + } 73 + 74 + public function test_it_throws_exception_when_transformer_not_found_for_to_array(): void 75 + { 76 + $this->expectException(SchemaException::class); 77 + 78 + $this->mapper->toArray('unknown.type', new \stdClass()); 79 + } 80 + 81 + public function test_it_transforms_multiple_items_from_arrays(): void 82 + { 83 + $transformer = $this->createTestTransformer('app.bsky.feed.post'); 84 + $this->mapper->register('app.bsky.feed.post', $transformer); 85 + 86 + $items = [ 87 + ['text' => 'First post'], 88 + ['text' => 'Second post'], 89 + ]; 90 + 91 + $models = $this->mapper->fromArrayMany('app.bsky.feed.post', $items); 92 + 93 + $this->assertCount(2, $models); 94 + $this->assertEquals('First post', $models[0]->text); 95 + $this->assertEquals('Second post', $models[1]->text); 96 + } 97 + 98 + public function test_it_transforms_multiple_items_to_arrays(): void 99 + { 100 + $transformer = $this->createTestTransformer('app.bsky.feed.post'); 101 + $this->mapper->register('app.bsky.feed.post', $transformer); 102 + 103 + $models = [ 104 + (object) ['text' => 'First post'], 105 + (object) ['text' => 'Second post'], 106 + ]; 107 + 108 + $arrays = $this->mapper->toArrayMany('app.bsky.feed.post', $models); 109 + 110 + $this->assertCount(2, $arrays); 111 + $this->assertEquals(['text' => 'First post'], $arrays[0]); 112 + $this->assertEquals(['text' => 'Second post'], $arrays[1]); 113 + } 114 + 115 + public function test_it_gets_transformer(): void 116 + { 117 + $transformer = $this->createTestTransformer('app.bsky.feed.post'); 118 + $this->mapper->register('app.bsky.feed.post', $transformer); 119 + 120 + $retrieved = $this->mapper->getTransformer('app.bsky.feed.post'); 121 + 122 + $this->assertSame($transformer, $retrieved); 123 + } 124 + 125 + public function test_it_returns_null_for_missing_transformer(): void 126 + { 127 + $transformer = $this->mapper->getTransformer('unknown.type'); 128 + 129 + $this->assertNull($transformer); 130 + } 131 + 132 + public function test_it_checks_if_has_transformer(): void 133 + { 134 + $transformer = $this->createTestTransformer('app.bsky.feed.post'); 135 + $this->mapper->register('app.bsky.feed.post', $transformer); 136 + 137 + $this->assertTrue($this->mapper->has('app.bsky.feed.post')); 138 + $this->assertFalse($this->mapper->has('unknown.type')); 139 + } 140 + 141 + public function test_it_unregisters_transformer(): void 142 + { 143 + $transformer = $this->createTestTransformer('app.bsky.feed.post'); 144 + $this->mapper->register('app.bsky.feed.post', $transformer); 145 + 146 + $this->assertTrue($this->mapper->has('app.bsky.feed.post')); 147 + 148 + $this->mapper->unregister('app.bsky.feed.post'); 149 + 150 + $this->assertFalse($this->mapper->has('app.bsky.feed.post')); 151 + } 152 + 153 + public function test_it_gets_all_transformers(): void 154 + { 155 + $transformer1 = $this->createTestTransformer('app.bsky.feed.post'); 156 + $transformer2 = $this->createTestTransformer('app.bsky.feed.repost'); 157 + 158 + $this->mapper->registerMany([ 159 + 'app.bsky.feed.post' => $transformer1, 160 + 'app.bsky.feed.repost' => $transformer2, 161 + ]); 162 + 163 + $all = $this->mapper->all(); 164 + 165 + $this->assertCount(2, $all); 166 + $this->assertArrayHasKey('app.bsky.feed.post', $all); 167 + $this->assertArrayHasKey('app.bsky.feed.repost', $all); 168 + } 169 + 170 + public function test_it_clears_all_transformers(): void 171 + { 172 + $transformer = $this->createTestTransformer('app.bsky.feed.post'); 173 + $this->mapper->register('app.bsky.feed.post', $transformer); 174 + 175 + $this->assertEquals(1, $this->mapper->count()); 176 + 177 + $this->mapper->clear(); 178 + 179 + $this->assertEquals(0, $this->mapper->count()); 180 + } 181 + 182 + public function test_it_tries_from_array_with_missing_transformer(): void 183 + { 184 + $result = $this->mapper->tryFromArray('unknown.type', []); 185 + 186 + $this->assertNull($result); 187 + } 188 + 189 + public function test_it_tries_from_array_with_existing_transformer(): void 190 + { 191 + $transformer = $this->createTestTransformer('app.bsky.feed.post'); 192 + $this->mapper->register('app.bsky.feed.post', $transformer); 193 + 194 + $result = $this->mapper->tryFromArray('app.bsky.feed.post', ['text' => 'Hello']); 195 + 196 + $this->assertNotNull($result); 197 + $this->assertEquals('Hello', $result->text); 198 + } 199 + 200 + public function test_it_tries_to_array_with_missing_transformer(): void 201 + { 202 + $result = $this->mapper->tryToArray('unknown.type', new \stdClass()); 203 + 204 + $this->assertNull($result); 205 + } 206 + 207 + public function test_it_tries_to_array_with_existing_transformer(): void 208 + { 209 + $transformer = $this->createTestTransformer('app.bsky.feed.post'); 210 + $this->mapper->register('app.bsky.feed.post', $transformer); 211 + 212 + $model = (object) ['text' => 'Hello']; 213 + $result = $this->mapper->tryToArray('app.bsky.feed.post', $model); 214 + 215 + $this->assertNotNull($result); 216 + $this->assertEquals(['text' => 'Hello'], $result); 217 + } 218 + 219 + public function test_it_counts_transformers(): void 220 + { 221 + $this->assertEquals(0, $this->mapper->count()); 222 + 223 + $this->mapper->register('app.bsky.feed.post', $this->createTestTransformer('app.bsky.feed.post')); 224 + 225 + $this->assertEquals(1, $this->mapper->count()); 226 + 227 + $this->mapper->register('app.bsky.feed.repost', $this->createTestTransformer('app.bsky.feed.repost')); 228 + 229 + $this->assertEquals(2, $this->mapper->count()); 230 + } 231 + 232 + public function test_it_uses_wildcard_transformer(): void 233 + { 234 + $transformer = $this->createWildcardTransformer('app.bsky.feed.*'); 235 + $this->mapper->register('app.bsky.feed.*', $transformer); 236 + 237 + $this->assertTrue($this->mapper->has('app.bsky.feed.post')); 238 + $this->assertTrue($this->mapper->has('app.bsky.feed.repost')); 239 + $this->assertFalse($this->mapper->has('app.bsky.graph.follow')); 240 + } 241 + 242 + public function test_it_prefers_exact_match_over_wildcard(): void 243 + { 244 + $wildcardTransformer = $this->createWildcardTransformer('app.bsky.feed.*'); 245 + $exactTransformer = $this->createTestTransformer('app.bsky.feed.post'); 246 + 247 + $this->mapper->register('app.bsky.feed.*', $wildcardTransformer); 248 + $this->mapper->register('app.bsky.feed.post', $exactTransformer); 249 + 250 + $retrieved = $this->mapper->getTransformer('app.bsky.feed.post'); 251 + 252 + $this->assertSame($exactTransformer, $retrieved); 253 + } 254 + 255 + public function test_it_chains_register_calls(): void 256 + { 257 + $transformer1 = $this->createTestTransformer('type1'); 258 + $transformer2 = $this->createTestTransformer('type2'); 259 + 260 + $result = $this->mapper 261 + ->register('type1', $transformer1) 262 + ->register('type2', $transformer2); 263 + 264 + $this->assertSame($this->mapper, $result); 265 + $this->assertTrue($this->mapper->has('type1')); 266 + $this->assertTrue($this->mapper->has('type2')); 267 + } 268 + 269 + public function test_it_chains_register_many_calls(): void 270 + { 271 + $result = $this->mapper->registerMany([ 272 + 'type1' => $this->createTestTransformer('type1'), 273 + 'type2' => $this->createTestTransformer('type2'), 274 + ]); 275 + 276 + $this->assertSame($this->mapper, $result); 277 + } 278 + 279 + public function test_it_chains_unregister_calls(): void 280 + { 281 + $this->mapper->register('type1', $this->createTestTransformer('type1')); 282 + 283 + $result = $this->mapper->unregister('type1'); 284 + 285 + $this->assertSame($this->mapper, $result); 286 + } 287 + 288 + public function test_it_chains_clear_calls(): void 289 + { 290 + $result = $this->mapper->clear(); 291 + 292 + $this->assertSame($this->mapper, $result); 293 + } 294 + 295 + protected function createTestTransformer(string $type): Transformer 296 + { 297 + return new class ($type) implements Transformer { 298 + public function __construct(protected string $type) 299 + { 300 + } 301 + 302 + public function fromArray(array $data): mixed 303 + { 304 + return (object) $data; 305 + } 306 + 307 + public function toArray(mixed $model): array 308 + { 309 + return (array) $model; 310 + } 311 + 312 + public function supports(string $type): bool 313 + { 314 + return $type === $this->type; 315 + } 316 + }; 317 + } 318 + 319 + protected function createWildcardTransformer(string $pattern): Transformer 320 + { 321 + return new class ($pattern) implements Transformer { 322 + public function __construct(protected string $pattern) 323 + { 324 + } 325 + 326 + public function fromArray(array $data): mixed 327 + { 328 + return (object) $data; 329 + } 330 + 331 + public function toArray(mixed $model): array 332 + { 333 + return (array) $model; 334 + } 335 + 336 + public function supports(string $type): bool 337 + { 338 + $regex = '/^'.str_replace('\\*', '.*', preg_quote($this->pattern, '/')).'$/'; 339 + 340 + return (bool) preg_match($regex, $type); 341 + } 342 + }; 343 + } 344 + }