From e4797f2790e042846c158b070726dc29316bfcbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Robles?= Date: Thu, 12 Oct 2023 18:27:24 +0200 Subject: [PATCH] add DTO TypeScript types generation command --- config/data-transfer-objects.php | 2 + src/Attributes/AsType.php | 14 ++ src/Commands/DtoTypesGenerateCommand.php | 128 ++++++++++++++++++ src/ServiceProvider.php | 3 +- src/TypeGenerator.php | 163 +++++++++++++++++++++++ 5 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 src/Attributes/AsType.php create mode 100644 src/Commands/DtoTypesGenerateCommand.php create mode 100644 src/TypeGenerator.php diff --git a/config/data-transfer-objects.php b/config/data-transfer-objects.php index 86ce64a..f6be223 100644 --- a/config/data-transfer-objects.php +++ b/config/data-transfer-objects.php @@ -4,4 +4,6 @@ 'normalise_properties' => true, + 'types_generation_file_name' => null, + ]; diff --git a/src/Attributes/AsType.php b/src/Attributes/AsType.php new file mode 100644 index 0000000..5f980db --- /dev/null +++ b/src/Attributes/AsType.php @@ -0,0 +1,14 @@ +confirm('Are you sure you want to generate types from your data transfer objects?', $this->option('force'))) { + return 1; + } + + $sourceDirectory = app_path($this->option('source')); + + if (! file_exists($sourceDirectory) || ! is_dir($sourceDirectory)) { + $this->error('Path does not exists'); + + return 2; + } + + $destinationDirectory = $this->option('output'); + + if ( + (! file_exists($destinationDirectory) || ! $this->filesystem->isWritable($destinationDirectory)) + && ! $this->filesystem->makeDirectory($destinationDirectory, 493, true) + ) { + $this->error('Permissions error, cannot create a directory under the destination path'); + + return 3; + } + + $dataTransferObjects = Collection::make((new Finder)->files()->in($sourceDirectory)) + ->map(fn ($file) => $file->getBasename('.php')) + ->sort() + ->values() + ->all(); + + $namespace = str_replace(DIRECTORY_SEPARATOR, '\\', Str::replaceFirst(app_path(), 'App', $sourceDirectory)); + + $garbageCollection = Collection::make([]); + + foreach ($dataTransferObjects as $dataTransferObject) { + $dataTransferObjectClass = implode('\\', [$namespace, $dataTransferObject]); + + if (! class_exists($dataTransferObjectClass) || ! is_a($dataTransferObjectClass, DataTransferObject::class, true)) { + continue; + } + + (new TypeGenerator($dataTransferObjectClass, $garbageCollection))->generate(); + } + + $filename = $this->option('filename'); + $configFilename = config('data-transfer-objects.types_generation_file_name'); + + if ($filename === 'types.ts' && $configFilename) { + $filename = $configFilename; + } + + $destinationFile = implode(DIRECTORY_SEPARATOR, [$destinationDirectory, $filename]); + + if ( + $this->filesystem->exists($destinationFile) + && ! $this->confirm('Are you sure you want to overwrite the output file?', $this->option('force')) + ) { + return 0; + } + + if (! $this->filesystem->put($destinationFile, $garbageCollection->join("\n\n"))) { + $this->error('Something happened and types file could not be written'); + + return 4; + } + + $this->info("Types file successfully generated at \"{$destinationFile}\""); + + return 0; + } + + /** + * Get the console command options. + * + * @return array + */ + protected function getOptions() + { + return [ + ['force', 'f', InputOption::VALUE_NONE, 'Force running without asking anything'], + ['replace', 'r', InputOption::VALUE_NONE, 'Replace existing types'], + ['output', 'o', InputOption::VALUE_OPTIONAL, 'Destination folder where to place generated types', resource_path('types')], + ['source', 's', InputOption::VALUE_OPTIONAL, 'Source folder where to look at for data transfer objects (must be relative to app folder)', 'DataTransferObjects'], + ['filename', null, InputOption::VALUE_OPTIONAL, 'Destination file name with types generated on it', 'types.ts'], + ]; + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index d9469ba..92a284e 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Support\ServiceProvider as BaseServiceProvider; use OpenSoutheners\LaravelDto\Commands\DtoMakeCommand; +use OpenSoutheners\LaravelDto\Commands\DtoTypesGenerateCommand; use OpenSoutheners\LaravelDto\Contracts\ValidatedDataTransferObject; class ServiceProvider extends BaseServiceProvider @@ -17,7 +18,7 @@ class ServiceProvider extends BaseServiceProvider public function boot() { if ($this->app->runningInConsole()) { - $this->commands([DtoMakeCommand::class]); + $this->commands([DtoMakeCommand::class, DtoTypesGenerateCommand::class]); } $this->app->beforeResolving( diff --git a/src/TypeGenerator.php b/src/TypeGenerator.php new file mode 100644 index 0000000..eae320b --- /dev/null +++ b/src/TypeGenerator.php @@ -0,0 +1,163 @@ + 'number', + 'float' => 'number', + 'bool' => 'boolean', + '\stdClass' => 'object', + ]; + + public function __construct(protected string $dataTransferObject, protected Collection $garbageCollection) + { + // + } + + public function generate(): void + { + $reflection = new ReflectionClass($this->dataTransferObject); + + $normalisesPropertiesKeys = config('data-transfer-objects.normalise_properties', true); + + if (! empty($reflection->getAttributes(NormaliseProperties::class))) { + $normalisesPropertiesKeys = true; + } + + /** @var array<\ReflectionProperty> $properties */ + $properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC); + $propertyInfoExtractor = PropertiesMapper::propertyInfoExtractor(); + + $exportedType = $this->getExportTypeName($reflection); + $exportAsString = "export type {$exportedType} = {\n"; + + foreach ($properties as $property) { + /** @var array<\Symfony\Component\PropertyInfo\Type> $propertyTypes */ + $propertyTypes = $propertyInfoExtractor->getTypes($this->dataTransferObject, $property->getName()); + $propertyType = reset($propertyTypes); + + $propertyTypeClass = $propertyType->getClassName(); + + if (is_a($propertyTypeClass, Authenticatable::class, true)) { + continue; + } + + $nullMark = $propertyType->isNullable() ? '?' : ''; + + $propertyTypeAsString = $this->extractTypeFromPropertyType($propertyType); + $propertyKeyAsString = $property->getName(); + + if ($normalisesPropertiesKeys) { + $propertyKeyAsString = Str::camel($propertyKeyAsString); + $propertyKeyAsString .= is_subclass_of($propertyTypeClass, Model::class) ? '_id' : ''; + } + + $exportAsString .= "\t{$propertyKeyAsString}{$nullMark}: {$propertyTypeAsString};\n"; + } + + $exportAsString .= "};"; + + $this->garbageCollection[$exportedType] = $exportAsString; + } + + protected function getExportTypeName(ReflectionClass $reflection): string + { + /** @var array<\ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\AsType>> $classAttributes */ + $classAttributes = $reflection->getAttributes(AsType::class); + + $classAttribute = reset($classAttributes); + + if (! $classAttribute) { + return $reflection->getShortName(); + } + + return $classAttribute->newInstance()->typeName; + } + + protected function extractTypeFromPropertyType(Type $propertyType): string + { + $propertyBuiltInType = $propertyType->getBuiltinType(); + $propertyTypeString = $propertyType->getClassName() ?? $propertyBuiltInType; + + return match (true) { + $propertyType->isCollection() => $this->extractCollectionType($propertyTypeString, $propertyType->getCollectionValueTypes()), + is_a($propertyTypeString, Model::class, true) => $this->extractModelType($propertyTypeString), + is_a($propertyTypeString, \BackedEnum::class, true) => $this->extractEnumType($propertyTypeString), + $propertyBuiltInType === 'object' && $propertyBuiltInType !== $propertyTypeString => $this->extractObjectType($propertyTypeString), + default => $this->builtInTypeToTypeScript($propertyType->getBuiltinType()), + }; + } + + protected function builtInTypeToTypeScript(string $identifier): string + { + return static::PHP_TO_TYPESCRIPT_VARIANT_TYPES[$identifier] ?? $identifier; + } + + /** + * Summary of extractObjectType + */ + protected function extractObjectType(string $objectClass): string + { + (new self($objectClass, $this->garbageCollection))->generate(); + + return class_basename($objectClass); + } + + /** + * Summary of extractEnumType + * + * @see https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums + */ + protected function extractEnumType(string $enumClass): string + { + $exportedType = class_basename($enumClass); + + if ($this->garbageCollection->has($exportedType)) { + return $exportedType; + } + + $exportsAsString = ''; + $exportsAsString .= "export const enum {$exportedType} {\n"; + + foreach ($enumClass::cases() as $case) { + $caseValueAsString = is_int($case->value) ? $case->value : "\"{$case->value}\""; + $exportsAsString .= "\t{$case->name} = {$caseValueAsString},\n"; + } + + $exportsAsString .= "};"; + + $this->garbageCollection[$exportedType] = $exportsAsString; + + return $exportedType; + } + + /** + * Summary of getCollectionType + * + * @param array<\Symfony\Component\PropertyInfo\Type> $collectedTypes + */ + protected function extractCollectionType(string $collection, array $collectedTypes): string + { + $collectedType = reset($collectedTypes); + + return $this->extractTypeFromPropertyType($collectedType); + } + + protected function extractModelType(string $modelClass): string + { + // TODO: Check type from Model's property's attribute or getRouteKeyName as fallback + // TODO: To be able to do the above need to generate types from models + return 'string'; + } +} \ No newline at end of file