Skip to content

Commit 4b7eb8e

Browse files
CopilothuangdijiaCopilot
authored
Add extensible DTO export functionality with TypeScript and multi-format support (#925)
* Initial plan * Add TypeScript export command for ValidatedDTO Co-authored-by: huangdijia <8337659+huangdijia@users.noreply.github.com> * Enhance TypeScript export command with improved error handling and edge cases Co-authored-by: huangdijia <8337659+huangdijia@users.noreply.github.com> * Refactor: Rename command to dto:export-ts and split exporter logic Co-authored-by: huangdijia <8337659+huangdijia@users.noreply.github.com> * Refactor: Rename namespace to Exporter and add ExporterInterface Co-authored-by: huangdijia <8337659+huangdijia@users.noreply.github.com> * Refactor: Split AbstractExporter and rename command to ExportDTOCommand with --lang option Co-authored-by: huangdijia <8337659+huangdijia@users.noreply.github.com> * Refactor test files to remove unnecessary slashes and fix imports Removed unnecessary leading backslashes from class names and exception references in ExportDTOCommandTest.php and TypeScriptExporterTest.php. Also improved code style consistency and fixed minor formatting issues in test cases. Co-Authored-By: Deeka Wong <8337659+huangdijia@users.noreply.github.com> * Refactor exception handling and imports in DTO exporter Replaces fully qualified exception class names with imported Exception and InvalidArgumentException in ExportDTOCommand, AbstractExporter, and TypeScriptExporter. Also improves code style for consistency and adds missing imports for reflection types in TypeScriptExporter. Co-Authored-By: Deeka Wong <8337659+huangdijia@users.noreply.github.com> * Refactor: Comment out LongCast and DoubleCast type mappings in TypeScriptExporter * Update test for DTO with inherited public properties Renames and updates the test to check handling of DTOs with inherited public properties instead of no public properties. Adjusts expectations to match the new behavior, ensuring inherited properties like 'lazyValidation' are included in the TypeScript export. Co-Authored-By: Deeka Wong <8337659+huangdijia@users.noreply.github.com> * Refactor: Change configure method visibility to protected in ExportDTOCommand * 更新 TypeScriptExporterTest.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor: Improve readability and consistency in TypeScriptExporterTest by formatting anonymous class methods * Refactor: Use anonymous class for nullable properties in TypeScriptExporterTest * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor: 移除未使用的 DoubleCast 和 LongCast 引用 * 更新 ExporterInterface.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * 更新 ExportDTOCommandTest.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * 更新 TypeScriptExporter.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * 修复: 在 ExportDTOCommand 和 TypeScriptExporter 中移除命名空间前缀 * 修复: 修改目录创建权限为 0755 * Update tests/ValidatedDTO/Unit/ExportDTOCommandTest.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * 优化: 使用闭包获取 DTO 类名,简化代码逻辑 * 优化: 使用 match 表达式简化类型映射逻辑,改进代码可读性 * 优化: 重构 isDTOClass 方法,使用静态数组简化父类检查逻辑 * 优化: 替换 Exception 为 Throwable,改进错误处理逻辑 * Security and code quality improvements based on review feedback Co-authored-by: huangdijia <8337659+huangdijia@users.noreply.github.com> * Fix: Code style improvements for CS Fixer compliance Co-authored-by: huangdijia <8337659+huangdijia@users.noreply.github.com> * 优化: 在 ExportDTOCommand 和 TypeScriptExporter 中添加常量以支持更多类型 * 优化: 改进文件路径验证逻辑,增强安全性;移除不必要的 Error 引用;添加 dtoClass 属性注释 * 优化: 将 ExportDTOCommand 的构造函数参数从 ConfigInterface 替换为 ContainerInterface,并更新测试用例以适应新的依赖注入方式 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: huangdijia <8337659+huangdijia@users.noreply.github.com> Co-authored-by: Deeka Wong <huangdijia@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 8dfeffb commit 4b7eb8e

5 files changed

Lines changed: 479 additions & 0 deletions

File tree

src/Command/ExportDTOCommand.php

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* This file is part of friendsofhyperf/components.
6+
*
7+
* @link https://github.com/friendsofhyperf/components
8+
* @document https://github.com/friendsofhyperf/components/blob/main/README.md
9+
* @contact huangdijia@gmail.com
10+
*/
11+
12+
namespace FriendsOfHyperf\ValidatedDTO\Command;
13+
14+
use Exception;
15+
use FriendsOfHyperf\ValidatedDTO\Exporter\ExporterInterface;
16+
use FriendsOfHyperf\ValidatedDTO\Exporter\TypeScriptExporter;
17+
use InvalidArgumentException;
18+
use Psr\Container\ContainerInterface;
19+
use RuntimeException;
20+
use Symfony\Component\Console\Command\Command as SymfonyCommand;
21+
use Symfony\Component\Console\Input\InputArgument;
22+
use Symfony\Component\Console\Input\InputInterface;
23+
use Symfony\Component\Console\Input\InputOption;
24+
use Symfony\Component\Console\Output\OutputInterface;
25+
26+
class ExportDTOCommand extends SymfonyCommand
27+
{
28+
public const DEFAULT_LANGUAGE = 'typescript';
29+
30+
public const SUPPORTED_LANGUAGES = ['typescript', 'ts'];
31+
32+
public const FILE_PERMISSIONS = 0755;
33+
34+
protected InputInterface $input;
35+
36+
protected OutputInterface $output;
37+
38+
public function __construct(protected ContainerInterface $container)
39+
{
40+
parent::__construct('dto:export');
41+
}
42+
43+
/**
44+
* Execute the console command.
45+
*/
46+
public function execute(InputInterface $input, OutputInterface $output): int
47+
{
48+
$this->input = $input;
49+
$this->output = $output;
50+
51+
$class = $input->getArgument('class');
52+
$outputPath = $input->getOption('output');
53+
$lang = $input->getOption('lang');
54+
55+
try {
56+
$exporter = $this->getExporter($lang);
57+
$exported = $exporter->export($class);
58+
59+
if ($outputPath) {
60+
$this->writeToFile($outputPath, $exported);
61+
$output->writeln(sprintf('<info>DTO exported to %s</info>', $outputPath));
62+
} else {
63+
$output->writeln($exported);
64+
}
65+
66+
return 0;
67+
} catch (InvalidArgumentException $e) {
68+
$output->writeln(sprintf('<fg=red>%s</>', $e->getMessage()));
69+
return 1;
70+
} catch (Exception $e) {
71+
$output->writeln(sprintf('<error>Error: %s</error>', $e->getMessage()));
72+
return 1;
73+
}
74+
}
75+
76+
protected function configure(): void
77+
{
78+
foreach ($this->getArguments() as $argument) {
79+
$this->addArgument(...$argument);
80+
}
81+
82+
foreach ($this->getOptions() as $option) {
83+
$this->addOption(...$option);
84+
}
85+
86+
$this->setDescription('Export DTO classes to various formats.');
87+
$this->setAliases([]);
88+
}
89+
90+
protected function getExporter(string $lang): ExporterInterface
91+
{
92+
return match ($lang) {
93+
'typescript', 'ts' => $this->container->get(TypeScriptExporter::class),
94+
default => throw new InvalidArgumentException("Unsupported language: {$lang}"),
95+
};
96+
}
97+
98+
protected function writeToFile(string $path, string $content): void
99+
{
100+
// Validate path to prevent directory traversal attacks
101+
if (str_contains($path, '..') || str_contains($path, '~')) {
102+
throw new InvalidArgumentException('Invalid file path provided.');
103+
}
104+
105+
$directory = dirname($path);
106+
if (! is_dir($directory)) {
107+
mkdir($directory, self::FILE_PERMISSIONS, true);
108+
}
109+
110+
if (file_exists($path) && ! $this->input->getOption('force')) {
111+
throw new RuntimeException(sprintf('File %s already exists! Use --force to overwrite.', $path));
112+
}
113+
114+
file_put_contents($path, $content);
115+
}
116+
117+
/**
118+
* Get the console command arguments.
119+
*/
120+
protected function getArguments(): array
121+
{
122+
return [
123+
['class', InputArgument::REQUIRED, 'The fully qualified class name of the DTO'],
124+
];
125+
}
126+
127+
/**
128+
* Get the console command options.
129+
*/
130+
protected function getOptions(): array
131+
{
132+
return [
133+
['output', 'o', InputOption::VALUE_OPTIONAL, 'Output file path', null],
134+
['force', 'f', InputOption::VALUE_NONE, 'Overwrite existing files'],
135+
['lang', 'l', InputOption::VALUE_OPTIONAL, 'Export language (typescript|ts)', self::DEFAULT_LANGUAGE],
136+
];
137+
}
138+
}

src/ConfigProvider.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public function __invoke(): array
2020
return [
2121
'commands' => [
2222
Command\MakeDTOCommand::class,
23+
Command\ExportDTOCommand::class,
2324
],
2425

2526
'publish' => [

src/Exporter/AbstractExporter.php

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* This file is part of friendsofhyperf/components.
6+
*
7+
* @link https://github.com/friendsofhyperf/components
8+
* @document https://github.com/friendsofhyperf/components/blob/main/README.md
9+
* @contact huangdijia@gmail.com
10+
*/
11+
12+
namespace FriendsOfHyperf\ValidatedDTO\Exporter;
13+
14+
use InvalidArgumentException;
15+
use ReflectionClass;
16+
use ReflectionException;
17+
use ReflectionProperty;
18+
use Throwable;
19+
20+
abstract class AbstractExporter implements ExporterInterface
21+
{
22+
/**
23+
* Export a DTO class.
24+
*/
25+
public function export(string $className): string
26+
{
27+
if (! class_exists($className)) {
28+
throw new InvalidArgumentException("Class {$className} does not exist.");
29+
}
30+
31+
$reflection = new ReflectionClass($className);
32+
33+
if (! $this->isDTOClass($reflection)) {
34+
throw new InvalidArgumentException("Class {$className} is not a DTO class.");
35+
}
36+
37+
return $this->generate($reflection);
38+
}
39+
40+
/**
41+
* Check if the class is a DTO class.
42+
*/
43+
protected function isDTOClass(ReflectionClass $reflection): bool
44+
{
45+
static $dtoClasses = [
46+
\FriendsOfHyperf\ValidatedDTO\ValidatedDTO::class,
47+
\FriendsOfHyperf\ValidatedDTO\SimpleDTO::class,
48+
];
49+
50+
$parentClass = $reflection->getParentClass();
51+
while ($parentClass) {
52+
if (in_array($parentClass->getName(), $dtoClasses, true)) {
53+
return true;
54+
}
55+
$parentClass = $parentClass->getParentClass();
56+
}
57+
58+
return false;
59+
}
60+
61+
/**
62+
* Get public properties from reflection.
63+
*/
64+
protected function getPublicProperties(ReflectionClass $reflection): array
65+
{
66+
return array_filter(
67+
$reflection->getProperties(ReflectionProperty::IS_PUBLIC),
68+
fn ($property) => ! $property->isStatic()
69+
);
70+
}
71+
72+
/**
73+
* Get casts from DTO.
74+
*/
75+
protected function getCasts(ReflectionClass $reflection): array
76+
{
77+
if (! $reflection->hasMethod('casts')) {
78+
return [];
79+
}
80+
81+
try {
82+
$instance = $reflection->newInstanceWithoutConstructor();
83+
$castsMethod = $reflection->getMethod('casts');
84+
$castsMethod->setAccessible(true);
85+
$casts = $castsMethod->invoke($instance);
86+
return is_array($casts) ? $casts : [];
87+
} catch (ReflectionException) {
88+
// If we can't access the casts method or create instance, continue without them
89+
return [];
90+
} catch (Throwable) {
91+
// If there's a fatal error during method invocation, continue without casts
92+
return [];
93+
}
94+
}
95+
96+
/**
97+
* Generate the export content from reflection.
98+
*/
99+
abstract protected function generate(ReflectionClass $reflection): string;
100+
}

src/Exporter/ExporterInterface.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* This file is part of friendsofhyperf/components.
6+
*
7+
* @link https://github.com/friendsofhyperf/components
8+
* @document https://github.com/friendsofhyperf/components/blob/main/README.md
9+
* @contact huangdijia@gmail.com
10+
*/
11+
12+
namespace FriendsOfHyperf\ValidatedDTO\Exporter;
13+
14+
interface ExporterInterface
15+
{
16+
/**
17+
* Export a DTO class to the target format.
18+
*/
19+
public function export(string $className): string;
20+
}

0 commit comments

Comments
 (0)