diff --git a/composer.json b/composer.json index 8d8ecc6..2ed8b83 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,8 @@ "laravel/pint": "^1.21", "orchestra/testbench": "^9.0|^10.0", "pestphp/pest": "^2.0|^3.0", - "pestphp/pest-plugin-faker": "^2.0|^3.0" + "pestphp/pest-plugin-faker": "^2.0|^3.0", + "spatie/typescript-transformer": "^2.4" }, "scripts": { "lint": "pint", diff --git a/src/Support/TypeScriptCollector.php b/src/Support/TypeScriptCollector.php new file mode 100644 index 0000000..7c1f2b4 --- /dev/null +++ b/src/Support/TypeScriptCollector.php @@ -0,0 +1,41 @@ +shouldCollect($class)) { + return null; + } + + $reflector = ClassTypeReflector::create($class); + + // Always use our ValidatedDtoTransformer + $transformer = $this->config->buildTransformer(TypeScriptTransformer::class); + + return $transformer->transform( + $reflector->getReflectionClass(), + $reflector->getName() + ); + } + + protected function shouldCollect(ReflectionClass $class): bool + { + // Only collect classes that extend ValidatedDTO + if (! $class->isSubclassOf(SimpleDTO::class)) { + return false; + } + + return true; + } +} diff --git a/src/Support/TypeScriptTransformer.php b/src/Support/TypeScriptTransformer.php new file mode 100644 index 0000000..9c6f2dc --- /dev/null +++ b/src/Support/TypeScriptTransformer.php @@ -0,0 +1,99 @@ +config = $config; + } + + public function transform(ReflectionClass $class, string $name): ?TransformedType + { + if (! $this->canTransform($class)) { + return null; + } + + $missingSymbols = new MissingSymbolsCollection(); + $properties = $this->transformProperties($class, $missingSymbols); + + return TransformedType::create( + $class, + $name, + '{' . PHP_EOL . $properties . '}', + $missingSymbols + ); + } + + protected function canTransform(ReflectionClass $class): bool + { + return $class->isSubclassOf(SimpleDTO::class); + } + + protected function transformProperties( + ReflectionClass $class, + MissingSymbolsCollection $missingSymbols + ): string { + $properties = array_filter( + $class->getProperties(ReflectionProperty::IS_PUBLIC), + function (ReflectionProperty $property) { + // Exclude static properties + if ($property->isStatic()) { + return false; + } + + // Exclude specific properties by name + if (in_array($property->getName(), $this->excludedProperties)) { + return false; + } + + return true; + } + ); + + return array_reduce( + $properties, + function (string $carry, ReflectionProperty $property) use ($missingSymbols) { + $transformed = $this->reflectionToTypeScript( + $property, + $missingSymbols, + false, + new ReplaceDefaultsTypeProcessor($this->config->getDefaultTypeReplacements()) + ); + + if ($transformed === null) { + return $carry; + } + + $propertyName = $property->getName(); + + return "{$carry}{$propertyName}: {$transformed};" . PHP_EOL; + }, + '' + ); + } +} diff --git a/tests/Feature/TypeScriptCollectorTest.php b/tests/Feature/TypeScriptCollectorTest.php new file mode 100644 index 0000000..d7da5f9 --- /dev/null +++ b/tests/Feature/TypeScriptCollectorTest.php @@ -0,0 +1,75 @@ +getTransformedType($reflection); + + expect($type)->toBeNull(); +}); + +it('uses the TypeScriptTransformer for an eligible class', function () { + eval(' + namespace App\Data { + use WendellAdriel\ValidatedDTO\SimpleDTO; + use WendellAdriel\ValidatedDTO\Concerns\EmptyRules; + use WendellAdriel\ValidatedDTO\Concerns\EmptyCasts; + use WendellAdriel\ValidatedDTO\Concerns\EmptyDefaults; + class TransformerTestDTO1 extends SimpleDTO { + use EmptyRules, EmptyCasts, EmptyDefaults; + + public string $name; + } + } + '); + + $reflection = new ReflectionClass(\App\Data\TransformerTestDTO1::class); + + // Provide a config with no other conflicting transformers + $config = TypeScriptTransformerConfig::create() + ->transformers([\WendellAdriel\ValidatedDTO\Support\TypeScriptTransformer::class]); + + $collector = new TypeScriptCollector($config); + + $type = $collector->getTransformedType($reflection); + + expect($type)->not->toBeNull() + ->and($type->getTypeScriptName())->toBe('App.Data.TransformerTestDTO1'); +}); + +it('uses the TypeScriptTransformer for ResourceDTO', function () { + eval(' + namespace App\Data { + use WendellAdriel\ValidatedDTO\ResourceDTO; + use WendellAdriel\ValidatedDTO\Concerns\EmptyRules; + use WendellAdriel\ValidatedDTO\Concerns\EmptyCasts; + use WendellAdriel\ValidatedDTO\Concerns\EmptyDefaults; + class TransformerTestDTO2 extends ResourceDTO { + use EmptyRules, EmptyCasts, EmptyDefaults; + + public string $name; + } + } + '); + + $reflection = new ReflectionClass(\App\Data\TransformerTestDTO2::class); + + // Provide a config with no other conflicting transformers + $config = TypeScriptTransformerConfig::create() + ->transformers([\WendellAdriel\ValidatedDTO\Support\TypeScriptTransformer::class]); + + $collector = new TypeScriptCollector($config); + + $type = $collector->getTransformedType($reflection); + + expect($type)->not->toBeNull() + ->and($type->getTypeScriptName())->toBe('App.Data.TransformerTestDTO2'); +}); diff --git a/tests/Feature/TypeScriptTransformerTest.php b/tests/Feature/TypeScriptTransformerTest.php new file mode 100644 index 0000000..eeea8fa --- /dev/null +++ b/tests/Feature/TypeScriptTransformerTest.php @@ -0,0 +1,136 @@ +transform($reflection, 'IrrelevantName'); + + expect($type)->toBeNull(); +}); + +it('transforms a SimpleDTO with public properties into a TransformedType', function () { + eval(' + namespace App\Data { + use WendellAdriel\ValidatedDTO\SimpleDTO; + use WendellAdriel\ValidatedDTO\Concerns\EmptyRules; + use WendellAdriel\ValidatedDTO\Concerns\EmptyCasts; + use WendellAdriel\ValidatedDTO\Concerns\EmptyDefaults; + class TestTransformerDTO extends SimpleDTO { + use EmptyRules, EmptyCasts, EmptyDefaults; + + public string $name; + public int $age; + public static string $shouldNotAppear = "excluded"; + protected string $invisible = "excluded"; + } + } + '); + + $reflection = new ReflectionClass(\App\Data\TestTransformerDTO::class); + + $transformer = new TypeScriptTransformer(TypeScriptTransformerConfig::create()); + $type = $transformer->transform($reflection, 'TransformedDTO'); + + // Should only include public, non-static properties + expect($type)->toBeInstanceOf(TransformedType::class) + ->and($type->name)->toBe('TransformedDTO') + ->and($type->transformed)->toContain('name: string;') + ->and($type->transformed)->toContain('age: number;') + ->and($type->transformed)->not->toContain('shouldNotAppear') + ->and($type->transformed)->not->toContain('invisible'); +}); + +it('excludes properties listed in excludedProperties', function () { + eval(' + namespace App\Data { + use WendellAdriel\ValidatedDTO\ValidatedDTO; + use WendellAdriel\ValidatedDTO\Concerns\EmptyRules; + use WendellAdriel\ValidatedDTO\Concerns\EmptyCasts; + use WendellAdriel\ValidatedDTO\Concerns\EmptyDefaults; + class ExcludedPropertyDTO extends ValidatedDTO { + use EmptyRules, EmptyCasts, EmptyDefaults; + + public bool $lazyValidation = true; // excluded by default + public string $title; + } + } + '); + + $reflection = new ReflectionClass(\App\Data\ExcludedPropertyDTO::class); + + $transformer = new TypeScriptTransformer(TypeScriptTransformerConfig::create()); + $type = $transformer->transform($reflection, 'ExcludedProps'); + + expect($type->transformed)->not->toContain('lazyValidation:') + ->and($type->transformed)->toContain('title: string;') + ->and($type->getTypeScriptName())->toBe('App.Data.ExcludedProps'); +}); + +it('transforms a ValidatedDTO with nested DTO and enum property', function () { + eval(' + namespace App\Enums { + enum FakeStatusEnum: string { + case FIRST = "first"; + case SECOND = "second"; + } + } + '); + + eval(' + namespace App\Data { + use WendellAdriel\ValidatedDTO\ValidatedDTO; + use WendellAdriel\ValidatedDTO\Concerns\EmptyRules; + use WendellAdriel\ValidatedDTO\Concerns\EmptyCasts; + use WendellAdriel\ValidatedDTO\Concerns\EmptyDefaults; + class ChildDTO extends ValidatedDTO { + use EmptyRules, EmptyCasts, EmptyDefaults; + + public string $childField; + } + } + '); + + eval(' + namespace App\Data { + use WendellAdriel\ValidatedDTO\ValidatedDTO; + use WendellAdriel\ValidatedDTO\Concerns\EmptyRules; + use WendellAdriel\ValidatedDTO\Concerns\EmptyCasts; + use WendellAdriel\ValidatedDTO\Concerns\EmptyDefaults; + use App\Enums\FakeStatusEnum; + + class ParentDTO extends ValidatedDTO { + use EmptyRules, EmptyCasts, EmptyDefaults; + + public FakeStatusEnum $status; + public ChildDTO $child; + } + } + '); + + $reflection = new ReflectionClass(\App\Data\ParentDTO::class); + $transformer = new TypeScriptTransformer(TypeScriptTransformerConfig::create()); + $type = $transformer->transform($reflection, 'ComplexDTO'); + + expect($type)->toBeInstanceOf(TransformedType::class) + ->and($type->name)->toBe('ComplexDTO') + ->and($type->transformed)->toContain('status: {%App\Enums\FakeStatusEnum%};') + ->and($type->transformed)->toContain('child: {%App\Data\ChildDTO%};') + ->and($type->missingSymbols->all()) + // Missing Symbols contain references to other types. Once all types are + // transformed, the package will replace these references with their + // TypeScript types. When no type is found the type will default to any. + ->toContain(\App\Enums\FakeStatusEnum::class) + ->and($type->missingSymbols->all())->toContain(\App\Data\ChildDTO::class); +});