Parse and validate AT Protocol Lexicons with DTO generation for Laravel
at dev 14 kB view raw
1<?php 2 3namespace SocialDept\AtpSchema\Generator; 4 5use SocialDept\AtpSchema\Support\ExtensionManager; 6 7class TypeMapper 8{ 9 /** 10 * Naming converter instance. 11 */ 12 protected NamingConverter $naming; 13 14 /** 15 * Local definition map for resolving #refs. 16 * 17 * @var array<string, string> 18 */ 19 protected array $localDefinitions = []; 20 21 /** 22 * Extension manager instance. 23 */ 24 protected ExtensionManager $extensions; 25 26 /** 27 * Create a new TypeMapper. 28 */ 29 public function __construct(?NamingConverter $naming = null, ?ExtensionManager $extensions = null) 30 { 31 $this->naming = $naming ?? new NamingConverter(); 32 $this->extensions = $extensions ?? new ExtensionManager(); 33 } 34 35 /** 36 * Set local definitions for resolving local references. 37 * 38 * @param array<string, string> $localDefinitions Map of #ref => class name 39 */ 40 public function setLocalDefinitions(array $localDefinitions): void 41 { 42 $this->localDefinitions = $localDefinitions; 43 } 44 45 /** 46 * Map lexicon type to PHP type. 47 * 48 * @param array<string, mixed> $definition 49 */ 50 public function toPhpType(array $definition, bool $nullable = false): string 51 { 52 $type = $definition['type'] ?? 'unknown'; 53 54 $phpType = match ($type) { 55 'string' => $this->mapStringType($definition), 56 'integer' => 'int', 57 'boolean' => 'bool', 58 'number' => 'float', 59 'array' => $this->mapArrayType($definition), 60 'object' => $this->mapObjectType($definition), 61 'blob' => 'BlobReference', 62 'bytes' => 'string', 63 'cid-link' => 'string', 64 'unknown' => 'mixed', 65 'ref' => $this->mapRefType($definition), 66 'union' => $this->mapUnionType($definition), 67 default => 'mixed', 68 }; 69 70 if ($nullable && $phpType !== 'mixed') { 71 $phpType = '?'.$phpType; 72 } 73 74 return $this->extensions->filter('filter:type:phpType', $phpType, $definition, $nullable); 75 } 76 77 /** 78 * Map lexicon type to PHPDoc type. 79 * 80 * @param array<string, mixed> $definition 81 */ 82 public function toPhpDocType(array $definition, bool $nullable = false): string 83 { 84 $type = $definition['type'] ?? 'unknown'; 85 86 $docType = match ($type) { 87 'string' => $this->mapStringType($definition), 88 'integer' => 'int', 89 'boolean' => 'bool', 90 'number' => 'float', 91 'array' => $this->mapArrayDocType($definition), 92 'object' => $this->mapObjectDocType($definition), 93 'blob' => 'BlobReference', 94 'bytes' => 'string', 95 'cid-link' => 'string', 96 'unknown' => 'mixed', 97 'ref' => $this->mapRefDocType($definition), 98 'union' => $this->mapUnionDocType($definition), 99 default => 'mixed', 100 }; 101 102 if ($nullable && $docType !== 'mixed') { 103 $docType = $docType.'|null'; 104 } 105 106 return $this->extensions->filter('filter:type:phpDocType', $docType, $definition, $nullable); 107 } 108 109 /** 110 * Map string type. 111 * 112 * @param array<string, mixed> $definition 113 */ 114 protected function mapStringType(array $definition): string 115 { 116 // Check for datetime format 117 if (isset($definition['format']) && $definition['format'] === 'datetime') { 118 return 'Carbon'; 119 } 120 121 return 'string'; 122 } 123 124 /** 125 * Map array type. 126 * 127 * @param array<string, mixed> $definition 128 */ 129 protected function mapArrayType(array $definition): string 130 { 131 return 'array'; 132 } 133 134 /** 135 * Map array type for PHPDoc. 136 * 137 * @param array<string, mixed> $definition 138 */ 139 protected function mapArrayDocType(array $definition): string 140 { 141 if (! isset($definition['items'])) { 142 return 'array'; 143 } 144 145 $itemType = $this->toPhpDocType($definition['items']); 146 147 // array<mixed> is redundant, just use array 148 if ($itemType === 'mixed') { 149 return 'array'; 150 } 151 152 return "array<{$itemType}>"; 153 } 154 155 /** 156 * Map object type. 157 * 158 * @param array<string, mixed> $definition 159 */ 160 protected function mapObjectType(array $definition): string 161 { 162 return 'array'; 163 } 164 165 /** 166 * Map object type for PHPDoc. 167 * 168 * @param array<string, mixed> $definition 169 */ 170 protected function mapObjectDocType(array $definition): string 171 { 172 if (! isset($definition['properties'])) { 173 return 'array'; 174 } 175 176 // Build array shape annotation 177 $properties = []; 178 foreach ($definition['properties'] as $key => $propDef) { 179 $propType = $this->toPhpDocType($propDef); 180 $properties[] = "{$key}: {$propType}"; 181 } 182 183 if (empty($properties)) { 184 return 'array'; 185 } 186 187 return 'array{'.implode(', ', $properties).'}'; 188 } 189 190 /** 191 * Map reference type. 192 * 193 * @param array<string, mixed> $definition 194 */ 195 protected function mapRefType(array $definition): string 196 { 197 if (! isset($definition['ref'])) { 198 return 'mixed'; 199 } 200 201 $ref = $definition['ref']; 202 203 // Resolve local references using the local definitions map 204 if (str_starts_with($ref, '#')) { 205 return $this->localDefinitions[$ref] ?? 'mixed'; 206 } 207 208 // Handle NSID fragments (e.g., com.atproto.label.defs#selfLabels) 209 // Convert fragment to class name 210 if (str_contains($ref, '#')) { 211 [$baseNsid, $fragment] = explode('#', $ref, 2); 212 213 return $this->naming->toClassName($fragment); 214 } 215 216 // Convert NSID reference to fully qualified class name 217 $fqcn = $this->naming->nsidToClassName($ref); 218 219 // Extract short class name (last part after final backslash) 220 $parts = explode('\\', $fqcn); 221 222 return end($parts); 223 } 224 225 /** 226 * Map reference type for PHPDoc. 227 * 228 * @param array<string, mixed> $definition 229 */ 230 protected function mapRefDocType(array $definition): string 231 { 232 return $this->mapRefType($definition); 233 } 234 235 /** 236 * Map union type. 237 * 238 * @param array<string, mixed> $definition 239 */ 240 protected function mapUnionType(array $definition): string 241 { 242 // Open unions (closed=false or not set) should always be mixed 243 // because future schema versions could add more types 244 $isClosed = $definition['closed'] ?? false; 245 246 if (! $isClosed) { 247 return 'mixed'; 248 } 249 250 // For closed unions, extract external refs 251 $refs = $definition['refs'] ?? []; 252 $externalRefs = array_values(array_filter($refs, fn ($ref) => ! str_starts_with($ref, '#'))); 253 254 if (empty($externalRefs)) { 255 return 'mixed'; 256 } 257 258 // Build union type with all variants 259 $types = []; 260 foreach ($externalRefs as $ref) { 261 // Handle NSID fragments - convert fragment to class name 262 if (str_contains($ref, '#')) { 263 [$baseNsid, $fragment] = explode('#', $ref, 2); 264 $types[] = $this->naming->toClassName($fragment); 265 } else { 266 // Convert to fully qualified class name, then extract short name 267 $fqcn = $this->naming->nsidToClassName($ref); 268 $parts = explode('\\', $fqcn); 269 $types[] = end($parts); 270 } 271 } 272 273 // Return union type (e.g., "Theme|ThemeV2" or just "Theme" for single ref) 274 return implode('|', $types); 275 } 276 277 /** 278 * Map union type for PHPDoc. 279 * 280 * @param array<string, mixed> $definition 281 */ 282 protected function mapUnionDocType(array $definition): string 283 { 284 if (! isset($definition['refs'])) { 285 return 'mixed'; 286 } 287 288 // Open unions should be typed as mixed since future types could be added 289 $isClosed = $definition['closed'] ?? false; 290 if (! $isClosed) { 291 return 'mixed'; 292 } 293 294 // For closed unions, list all the specific types 295 $types = []; 296 foreach ($definition['refs'] as $ref) { 297 // Resolve local references using the local definitions map 298 if (str_starts_with($ref, '#')) { 299 $types[] = $this->localDefinitions[$ref] ?? 'mixed'; 300 301 continue; 302 } 303 304 // Handle NSID fragments - convert fragment to class name 305 if (str_contains($ref, '#')) { 306 [$baseNsid, $fragment] = explode('#', $ref, 2); 307 $types[] = $this->naming->toClassName($fragment); 308 309 continue; 310 } 311 312 // Convert to fully qualified class name, then extract short name 313 $fqcn = $this->naming->nsidToClassName($ref); 314 $parts = explode('\\', $fqcn); 315 $types[] = end($parts); 316 } 317 318 return implode('|', $types); 319 } 320 321 /** 322 * Check if type is nullable based on definition. 323 * 324 * @param array<string, mixed> $definition 325 */ 326 public function isNullable(array $definition, array $required = []): bool 327 { 328 // Check if explicitly marked as required 329 if (isset($definition['required']) && $definition['required'] === true) { 330 return false; 331 } 332 333 // Check if in required array 334 if (! empty($required)) { 335 return false; 336 } 337 338 // Default to nullable for optional fields 339 return true; 340 } 341 342 /** 343 * Get default value for a type. 344 * 345 * @param array<string, mixed> $definition 346 */ 347 public function getDefaultValue(array $definition): ?string 348 { 349 if (! array_key_exists('default', $definition)) { 350 return null; 351 } 352 353 $default = $definition['default']; 354 355 if ($default === null) { 356 return 'null'; 357 } 358 359 if (is_string($default)) { 360 return "'".addslashes($default)."'"; 361 } 362 363 if (is_bool($default)) { 364 return $default ? 'true' : 'false'; 365 } 366 367 if (is_numeric($default)) { 368 return (string) $default; 369 } 370 371 if (is_array($default)) { 372 return '[]'; 373 } 374 375 return null; 376 } 377 378 /** 379 * Check if type needs use statement. 380 * 381 * @param array<string, mixed> $definition 382 */ 383 public function needsUseStatement(array $definition): bool 384 { 385 $type = $definition['type'] ?? 'unknown'; 386 387 // Check for datetime format on strings 388 if ($type === 'string' && isset($definition['format']) && $definition['format'] === 'datetime') { 389 return true; 390 } 391 392 return in_array($type, ['ref', 'blob']); 393 } 394 395 /** 396 * Get use statements for type. 397 * 398 * @param array<string, mixed> $definition 399 * @return array<string> 400 */ 401 public function getUseStatements(array $definition): array 402 { 403 $type = $definition['type'] ?? 'unknown'; 404 405 if ($type === 'string' && isset($definition['format']) && $definition['format'] === 'datetime') { 406 return ['Carbon\\Carbon']; 407 } 408 409 if ($type === 'blob') { 410 return ['SocialDept\\AtpSchema\\Data\\BlobReference']; 411 } 412 413 if ($type === 'ref' && isset($definition['ref'])) { 414 $ref = $definition['ref']; 415 416 // Skip local references (starting with #) 417 if (str_starts_with($ref, '#')) { 418 return []; 419 } 420 421 // Handle NSID fragments - convert fragment to class name 422 if (str_contains($ref, '#')) { 423 [$baseNsid, $fragment] = explode('#', $ref, 2); 424 // For fragments, we need to include ALL segments of the base NSID 425 // Parse the NSID and convert each segment to PascalCase 426 $nsid = \SocialDept\AtpSchema\Parser\Nsid::parse($baseNsid); 427 $segments = $nsid->getSegments(); 428 $namespaceParts = array_map( 429 fn ($part) => $this->naming->toPascalCase($part), 430 $segments 431 ); 432 $namespace = $this->naming->getBaseNamespace() . '\\' . implode('\\', $namespaceParts); 433 $className = $this->naming->toClassName($fragment); 434 435 return [$namespace . '\\' . $className]; 436 } 437 438 return [$this->naming->nsidToClassName($ref)]; 439 } 440 441 if ($type === 'union' && isset($definition['refs'])) { 442 // Open unions don't need use statements since they're typed as mixed 443 $isClosed = $definition['closed'] ?? false; 444 if (! $isClosed) { 445 return []; 446 } 447 448 // For closed unions, import the referenced classes 449 $classes = []; 450 451 foreach ($definition['refs'] as $ref) { 452 // Skip local references 453 if (str_starts_with($ref, '#')) { 454 continue; 455 } 456 457 // Handle NSID fragments - convert fragment to class name 458 if (str_contains($ref, '#')) { 459 [$baseNsid, $fragment] = explode('#', $ref, 2); 460 // For fragments, we need to include ALL segments of the base NSID 461 $nsid = \SocialDept\AtpSchema\Parser\Nsid::parse($baseNsid); 462 $segments = $nsid->getSegments(); 463 $namespaceParts = array_map( 464 fn ($part) => $this->naming->toPascalCase($part), 465 $segments 466 ); 467 $namespace = $this->naming->getBaseNamespace() . '\\' . implode('\\', $namespaceParts); 468 $className = $this->naming->toClassName($fragment); 469 $classes[] = $namespace . '\\' . $className; 470 } else { 471 $classes[] = $this->naming->nsidToClassName($ref); 472 } 473 } 474 475 return $classes; 476 } 477 478 $uses = []; 479 480 return $this->extensions->filter('filter:type:useStatements', $uses, $definition); 481 } 482 483 /** 484 * Get the extension manager. 485 */ 486 public function getExtensions(): ExtensionManager 487 { 488 return $this->extensions; 489 } 490}