From f496d4c733988d66e7800f3427f019ff8bd5f350 Mon Sep 17 00:00:00 2001 From: tw Date: Tue, 22 Sep 2026 14:09:30 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(annotation):=20=E6=B7=BB=E5=8A=A0ApiRe?= =?UTF-8?q?sponse=E6=B3=A8=E8=A7=A3=E7=9A=84types=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增types参数用于类名+属性类型映射,无需实例化即可生成代理类 - 实现generateByTypes方法支持通过属性类型映射生成代理类 - 添加ApiResponse构造函数参数验证和类型检查逻辑 - 更新GenerateResponses支持types属性的处理 - 增加GenerateProxyClassTest测试用例验证新功能 - 修改README文档说明types参数使用方法 - 优化ResponseVisitor中的命名空间处理 --- README.md | 21 ++++++- README_EN.md | 21 ++++++- publish/skills/api-docs/SKILL.md | 10 ++- src/Annotation/ApiResponse.php | 36 ++++++++++- src/Ast/ResponseVisitor.php | 3 +- src/Swagger/GenerateProxyClass.php | 61 ++++++++++++++----- src/Swagger/GenerateResponses.php | 12 ++-- tests/GenerateProxyClassTest.php | 97 ++++++++++++++++++++++++++++++ tests/GenerateResponsesTest.php | 41 ++++++++++++- 9 files changed, 271 insertions(+), 31 deletions(-) create mode 100644 tests/GenerateProxyClassTest.php diff --git a/README.md b/README.md index fffba8f..1177b95 100644 --- a/README.md +++ b/README.md @@ -178,12 +178,13 @@ return [ /* |-------------------------------------------------------------------------- - | 全局responses,映射到ApiResponse注解对象 + | 全局responses,映射到ApiResponse注解对象(支持 returnType/types 键) |-------------------------------------------------------------------------- */ 'responses' => [ ['response' => 401, 'description' => 'Unauthorized'], ['response' => 500, 'description' => 'System error'], + // ['response' => 200, 'returnType' => Page::class, 'types' => ['content' => [UserResponse::class]]], ], /* |-------------------------------------------------------------------------- @@ -374,6 +375,9 @@ class UserController // 分页响应 #[ApiResponse(new Page([UserResponse::class]), 200, '分页数据')] + +// 分页响应(types写法,无需实例化) +#[ApiResponse(Page::class, 200, '分页数据', types: ['content' => [UserResponse::class]])] ``` **泛型支持示例:** @@ -410,6 +414,21 @@ public function page(#[RequestQuery] PageQuery $query): Page } ``` +也可以通过 `types` 参数以"类名 + 属性类型映射"的方式声明,无需实例化(键为 `#[ApiVariable]` 标记的属性名,值为类名/`PhpType`/实例/单元素数组): + +```php +#[ApiOperation('分页查询')] +#[GetMapping(path: 'page')] +#[ApiResponse(Page::class, types: ['content' => [UserResponse::class]])] +public function page(#[RequestQuery] PageQuery $query): Page +{ + // 返回分页数据 +} +``` + +> 嵌套的可变类型(如 `Page>`)可继续用实例作为 `types` 的值:`types: ['content' => [new CodeResponse(PhpType::INT)]]`。 +> 注意:`types` 仅在 `returnType` 为单个类名时生效,不支持 `[Page::class]` 这类数组包裹形式。 + ### 参数注解 #### `#[RequestBody]` - Body 参数 diff --git a/README_EN.md b/README_EN.md index 92034a9..0cab9c6 100644 --- a/README_EN.md +++ b/README_EN.md @@ -151,12 +151,13 @@ return [ /* |-------------------------------------------------------------------------- - | Global Responses + | Global Responses (mapped to ApiResponse annotation; supports returnType/types keys) |-------------------------------------------------------------------------- */ 'responses' => [ ['response' => 401, 'description' => 'Unauthorized'], ['response' => 500, 'description' => 'System error'], + // ['response' => 200, 'returnType' => Page::class, 'types' => ['content' => [UserResponse::class]]], ], /* @@ -378,6 +379,9 @@ class UserController // Paginated response #[ApiResponse(new Page([UserResponse::class]), 200, 'Paginated data')] + +// Paginated response (types style, no instantiation needed) +#[ApiResponse(Page::class, 200, 'Paginated data', types: ['content' => [UserResponse::class]])] ``` ### Parameter Annotations @@ -462,6 +466,21 @@ public function page(#[RequestQuery] PageQuery $query): Page } ``` +Alternatively, use the `types` parameter with a "class name + property type map" — no instantiation required (keys are property names marked with `#[ApiVariable]`; values are class names, `PhpType`, instances, or single-element arrays): + +```php +#[ApiOperation('Paginated query')] +#[GetMapping(path: 'page')] +#[ApiResponse(Page::class, types: ['content' => [UserResponse::class]])] +public function page(#[RequestQuery] PageQuery $query): Page +{ + // Return paginated data +} +``` + +> For nested variable types (e.g. `Page>`), pass an instance as the `types` value: `types: ['content' => [new CodeResponse(PhpType::INT)]]`. +> Note: `types` only works when `returnType` is a single class name; array-wrapped forms like `[Page::class]` are not supported. + ### Property Annotations #### `#[ApiModelProperty]` - Property Description diff --git a/publish/skills/api-docs/SKILL.md b/publish/skills/api-docs/SKILL.md index 9781aa7..dadbfdb 100644 --- a/publish/skills/api-docs/SKILL.md +++ b/publish/skills/api-docs/SKILL.md @@ -78,10 +78,14 @@ use Hyperf\DTO\Type\PhpType; #[ApiResponse(Address::class, 201)] // 指定返回类 + 状态码 #[ApiResponse([PhpType::INT], 204, '简单类型数组')] #[ApiResponse(new Page([Address::class]), 206)] // 对象实例示例 +// ApiVariable 可变类型可用 types 映射写法,无需实例化: +#[ApiResponse(Page::class, 207, '分页数据', types: ['content' => [Address::class]])] public function getUser(int $id) { } ``` -参数顺序:`returnType`、`response`(默认 `'200'`)、`description`。 +参数顺序:`returnType`、`response`(默认 `'200'`)、`description`、`types`。 + +`types`:键为返回类中 `#[ApiVariable]` 标记的属性名,值为类名 / `PhpType` / 实例 / 单元素数组(表示数组)。仅在 `returnType` 为单个类名时生效;嵌套可变类型可传实例,如 `types: ['content' => [new CodeResponse(PhpType::INT)]]`。 ### ApiHeader —— 请求头(不常用) @@ -168,6 +172,8 @@ class GlobalResponse } ``` +在 `ApiResponse` 中声明可变类型的具体类型时,优先用 `types` 映射写法(见上文 ApiResponse 章节),无需 new 实例。 + ## DTO 与文档联动 ### 请求参数绑定 @@ -416,7 +422,7 @@ class UserController | `global_return_responses_class` | 全局响应包装类 | | `validation_custom_attributes` | 用 `ApiModelProperty` 的值作为验证提示信息 | | `dto_default_value_level` | DTO 默认值等级:0 不设置;1 简单类型设默认值;2 复杂类型也设 null(慎用) | -| `responses` | 全局响应(如 401/500),映射为 `ApiResponse` | +| `responses` | 全局响应(如 401/500),映射为 `ApiResponse`(支持 `returnType`/`types` 键) | | `swagger.info` | 文档标题、版本、描述 | | `swagger.servers` | 服务地址列表 | | `swagger.components.securitySchemes` | 安全方案定义 | diff --git a/src/Annotation/ApiResponse.php b/src/Annotation/ApiResponse.php index 6ac42a9..9b2c75e 100644 --- a/src/Annotation/ApiResponse.php +++ b/src/Annotation/ApiResponse.php @@ -12,14 +12,44 @@ #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class ApiResponse extends AbstractMultipleAnnotation { + public null|array|object|string $returnType = null; - public null|string|object|array $returnType = null; + /** + * @param null|array|object|string $returnType 返回类型: 类名/简单类型字符串、实例(可变类型场景), 或以上种类的单元素数组(表示数组) + * @param null|int|string $response HTTP状态码 + * @param string $description 响应描述 + * @param array $types 可变属性类型映射: #[ApiVariable]属性名 => 类名字符串/PhpType/实例/单元素数组(表示数组), 无需实例化即可生成代理类; 仅当 returnType 为单个类名时生效 + */ public function __construct( - null|string|object|array $returnType = null, - public string|int|null $response = '200', + null|array|object|string $returnType = null, + public null|int|string $response = '200', public string $description = 'success', + public array $types = [], ) { $this->setReturnType($returnType); + $this->setTypes($types); + } + + protected function setTypes(array $types): void + { + foreach ($types as $property => $type) { + if (! is_string($property)) { + throw new ApiDocsException('ApiResponse: types key must be a property name string'); + } + $value = is_array($type) ? ($type[0] ?? null) : $type; + if ($value instanceof PhpType || is_object($value)) { + continue; + } + if (is_string($value) && ($this->isSimpleType($value) || class_exists($value))) { + continue; + } + throw new ApiDocsException('ApiResponse: Unsupported types value for property ' . $property); + } + } + + protected function isSimpleType(string $type): bool + { + return in_array($type, ['int', 'integer', 'string', 'float', 'double', 'bool', 'boolean', 'array', 'object', 'mixed'], true); } protected function setReturnType($returnType): void diff --git a/src/Ast/ResponseVisitor.php b/src/Ast/ResponseVisitor.php index 9609e45..d492a55 100644 --- a/src/Ast/ResponseVisitor.php +++ b/src/Ast/ResponseVisitor.php @@ -17,7 +17,6 @@ class ResponseVisitor extends NodeVisitorAbstract public BuilderFactory $factory; public function __construct( - protected object $generateClass, protected string $generateClassName, protected array $propertyArr, ) { @@ -51,7 +50,7 @@ public function leaveNode(Node $node) $node->name = new Node\Identifier($this->generateClassName); } if ($node instanceof Node\Stmt\Namespace_) { - $name = new Node\Name('ApiDocs\\Proxy'); + $name = new Node\Name('ApiDocs\Proxy'); $node->name = $name; } } diff --git a/src/Swagger/GenerateProxyClass.php b/src/Swagger/GenerateProxyClass.php index 8d4d087..307f779 100644 --- a/src/Swagger/GenerateProxyClass.php +++ b/src/Swagger/GenerateProxyClass.php @@ -57,18 +57,56 @@ public function getApiVariableClass(string $newClassname) */ public function generate(object $obj): string { - $ref = new ReflectionClass($obj); $classname = $obj::class; $properties = $this->getApiVariableClass($classname); if (empty($properties)) { return $classname; } - $propertyArr = []; + $propertyValues = []; foreach ($properties as $property) { // 获取变量值 - $propertyValue = $obj->{$property}; + $propertyValues[$property] = $obj->{$property}; + } + return $this->generateProxy($classname, $propertyValues); + } + /** + * 通过属性类型映射生成代理类(无需实例化). + * @param array $types 属性名 => 类名字符串/PhpType/实例/单元素数组 + */ + public function generateByTypes(string $classname, array $types): string + { + $classname = trim($classname, '\\'); + $properties = $this->getApiVariableClass($classname); + if (empty($types) || empty($properties)) { + return $classname; + } + foreach ($types as $property => $type) { + if (! in_array($property, $properties, true)) { + throw new ApiDocsException("{$classname}: \${$property} is not an ApiVariable property"); + } + } + // 未指定的可变属性按mixed处理(与实例写法中属性值为null一致) + $propertyValues = []; + foreach ($properties as $property) { + $propertyValues[$property] = $types[$property] ?? null; + } + return $this->generateProxy($classname, $propertyValues); + } + + /** + * 获取代理类对应的源类. + */ + public function getSourceClassname(string $proxyClassname): ?string + { + return $this->proxyClassArr[$proxyClassname] ?? null; + } + + protected function generateProxy(string $classname, array $propertyValues): string + { + $propertyArr = []; + foreach ($propertyValues as $property => $propertyValue) { $type = $this->swaggerCommon->getPhpType($propertyValue); if (is_object($propertyValue) && $type != '\stdClass') { $propertyClassname = $type; @@ -94,9 +132,10 @@ public function generate(object $obj): string } } + $ref = new ReflectionClass($classname); $file = new SplFileInfo($ref->getFileName()); $realPath = $file->getRealPath(); - [$generateNamespaceClassName, $content] = $this->phpParser($obj, $realPath, $propertyArr); + [$generateNamespaceClassName, $content] = $this->phpParser($classname, $realPath, $propertyArr); if (! isset($this->proxyClassArr[$generateNamespaceClassName])) { $this->putContents($generateNamespaceClassName, $content); @@ -106,14 +145,6 @@ public function generate(object $obj): string return $generateNamespaceClassName; } - /** - * 获取代理类对应的源类. - */ - public function getSourceClassname(string $proxyClassname): ?string - { - return $this->proxyClassArr[$proxyClassname] ?? null; - } - protected function putContents($generateNamespaceClassName, $content): void { $outputDir = $this->swaggerConfig->getProxyDir(); @@ -127,13 +158,13 @@ protected function putContents($generateNamespaceClassName, $content): void $classLoader->addClassMap([$generateNamespaceClassName => $filename]); } - protected function phpParser(object $generateClass, $filePath, $propertyArr): array + protected function phpParser(string $classname, $filePath, $propertyArr): array { $code = file_get_contents($filePath); $parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); $ast = $parser->parse($code); - $simpleClassName = $this->swaggerCommon->getSimpleClassName($generateClass::class); + $simpleClassName = $this->swaggerCommon->getSimpleClassName($classname); $generateClassName = $simpleClassName; foreach ($propertyArr as $type) { if (is_array($type)) { @@ -149,7 +180,7 @@ protected function phpParser(object $generateClass, $filePath, $propertyArr): ar } $traverser = new NodeTraverser(); - $resVisitor = make(ResponseVisitor::class, [$generateClass, $generateClassName, $propertyArr]); + $resVisitor = make(ResponseVisitor::class, [$generateClassName, $propertyArr]); $traverser->addVisitor($resVisitor); $ast = $traverser->traverse($ast); diff --git a/src/Swagger/GenerateResponses.php b/src/Swagger/GenerateResponses.php index 42c5eec..fddbac0 100644 --- a/src/Swagger/GenerateResponses.php +++ b/src/Swagger/GenerateResponses.php @@ -25,8 +25,7 @@ public function __construct( protected SwaggerComponents $swaggerComponents, protected SwaggerCommon $common, protected GenerateProxyClass $genericProxyClass, - ) { - } + ) {} /** * 生成Response. @@ -68,13 +67,17 @@ public function generate(): array // return $arr; // } - protected function getContent(array|object|string $returnTypeClassName): array + protected function getContent(array|object|string $returnTypeClassName, array $types = []): array { // 获取全局类 $globalReturnResponsesClass = $this->swaggerConfig->getGlobalReturnResponsesClass(); if ($globalReturnResponsesClass) { $returnTypeClassName = make($globalReturnResponsesClass, [$returnTypeClassName]); } + // 类名 + 属性类型映射(无需实例化) + if ($types !== [] && is_string($returnTypeClassName) && $this->genericProxyClass->getApiVariableClass($returnTypeClassName)) { + $returnTypeClassName = $this->genericProxyClass->generateByTypes($returnTypeClassName, $types); + } // 判断对象 if (is_object($returnTypeClassName)) { // 生成代理类 @@ -155,6 +158,7 @@ protected function getGlobalResp(): array $apiResponse->response = $value['response'] ?? null; $apiResponse->description = $value['description'] ?? null; ! empty($value['returnType']) && $apiResponse->returnType = $value['returnType']; + ! empty($value['types']) && $apiResponse->types = $value['types']; $resp[$apiResponse->response] = $this->getOAResp($apiResponse); } return $resp; @@ -167,7 +171,7 @@ protected function getOAResp(ApiResponse $apiResponse): OA\Response $response->description = $apiResponse->description; if (! empty($apiResponse->returnType)) { $returnType = $apiResponse->returnType; - $content = $this->getContent($returnType); + $content = $this->getContent($returnType, $apiResponse->types); $content && $response->content = $content; } return $response; diff --git a/tests/GenerateProxyClassTest.php b/tests/GenerateProxyClassTest.php new file mode 100644 index 0000000..e7a109a --- /dev/null +++ b/tests/GenerateProxyClassTest.php @@ -0,0 +1,97 @@ +makeGenerateProxyClass(); + $byTypes = $proxy->generateByTypes(Page::class, ['content' => [Address::class]]); + $this->assertSame('ApiDocs\Proxy\PageArrayAddress', $byTypes); + + $byInstance = $proxy->generate(new Page([new Address()])); + $this->assertSame($byTypes, $byInstance); + } + + public function testGenerateByTypesWithClassNameValue(): void + { + $proxy = $this->makeGenerateProxyClass(); + $proxyClass = $proxy->generateByTypes(Page::class, ['content' => [User::class]]); + $this->assertSame('ApiDocs\Proxy\PageArrayUser', $proxyClass); + } + + public function testGenerateByTypesWithInvalidPropertyThrows(): void + { + $proxy = $this->makeGenerateProxyClass(); + $this->expectException(ApiDocsException::class); + $proxy->generateByTypes(Page::class, ['notExist' => [Address::class]]); + } + + public function testGenerateByTypesReturnsOriginWhenNotApplicable(): void + { + $proxy = $this->makeGenerateProxyClass(); + $this->assertSame(Page::class, $proxy->generateByTypes(Page::class, [])); + // 无ApiVariable属性的类直接返回原类名 + $this->assertSame(Address::class, $proxy->generateByTypes(Address::class, ['name' => 'string'])); + } + + public function testApiResponseTypesValidation(): void + { + $apiResponse = new ApiResponse(Page::class, 200, 'ok', ['content' => [Address::class]]); + $this->assertSame(['content' => [Address::class]], $apiResponse->types); + + $this->expectException(ApiDocsException::class); + new ApiResponse(Page::class, 200, 'ok', ['content' => [123]]); + } + + public function testApiResponseTypesKeyMustBeString(): void + { + $this->expectException(ApiDocsException::class); + new ApiResponse(Page::class, 200, 'ok', [123 => Address::class]); + } + + private function makeGenerateProxyClass(): GenerateProxyClass + { + $swaggerConfig = m::mock(SwaggerConfig::class); + $swaggerConfig->shouldReceive('getProxyDir')->andReturn(sys_get_temp_dir() . '/api_docs_proxy_test/'); + $dtoConfig = m::mock(DtoConfig::class); + $dtoConfig->shouldReceive('isScanCacheable')->andReturn(false); + return new GenerateProxyClass($swaggerConfig, new SwaggerCommon(), $dtoConfig); + } +} diff --git a/tests/GenerateResponsesTest.php b/tests/GenerateResponsesTest.php index ae770d1..79ab5ed 100644 --- a/tests/GenerateResponsesTest.php +++ b/tests/GenerateResponsesTest.php @@ -14,7 +14,9 @@ use Hyperf\Di\MethodDefinitionCollectorInterface; use Hyperf\DTO\Scan\PropertyEnum; use Hyperf\DTO\Scan\PropertyManager; +use HyperfTest\ApiDocs\Request\Address; use HyperfTest\ApiDocs\Request\DemoBodyRequest; +use HyperfTest\ApiDocs\Request\Page; use Mockery as m; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -76,10 +78,43 @@ public function testGlobalResponseKeptWhenNotOverridden(): void $this->assertContains('Global System Error', $descriptions); } - private function makeGenerateResponses(SwaggerConfig $swaggerConfig, array $apiResponseArr): GenerateResponses + /** + * types属性映射写法应经generateByTypes生成代理类并输出对应schema. + */ + public function testApiResponseWithTypes(): void + { + $swaggerConfig = m::mock(SwaggerConfig::class); + $swaggerConfig->shouldReceive('getResponsesCode')->andReturn('200'); + $swaggerConfig->shouldReceive('getGlobalReturnResponsesClass')->andReturn(''); + $swaggerConfig->shouldReceive('getResponses')->andReturn([]); + + $proxy = m::mock(GenerateProxyClass::class); + $proxy->shouldReceive('getApiVariableClass')->with(Page::class)->andReturn(['content']); + $proxy->shouldReceive('getApiVariableClass')->with(m::any())->andReturn([]); + $proxy->shouldReceive('generateByTypes') + ->with(Page::class, ['content' => [Address::class]]) + ->andReturn(Address::class); + + $apiResponse = new ApiResponse(Page::class, 206, '分页数据', ['content' => [Address::class]]); + + $generateResponses = $this->makeGenerateResponses($swaggerConfig, [$apiResponse], $proxy, [Address::class]); + $responses = $generateResponses->generate(); + + $resp206 = null; + foreach ($responses as $response) { + if ((int) $response->response === 206) { + $resp206 = $response; + } + } + $this->assertNotNull($resp206); + $this->assertSame('分页数据', $resp206->description); + $this->assertSame('#/components/schemas/Address', $resp206->content['application/json']->schema->ref); + } + + private function makeGenerateResponses(SwaggerConfig $swaggerConfig, array $apiResponseArr, ?GenerateProxyClass $proxy = null, array $containerHasClasses = []): GenerateResponses { $container = m::mock(ContainerInterface::class); - $container->shouldReceive('has')->andReturn(false); + $container->shouldReceive('has')->andReturnUsing(fn ($class) => in_array($class, $containerHasClasses, true)); $container->shouldReceive('get')->with(MethodDefinitionCollectorInterface::class)->andReturn(new MethodDefinitionCollector()); $swaggerCommon = new SwaggerCommon(); @@ -92,7 +127,7 @@ private function makeGenerateResponses(SwaggerConfig $swaggerConfig, array $apiR $container, new SwaggerComponents($swaggerCommon, new PropertyManager($swaggerCommon, new PropertyEnum()), null), $swaggerCommon, - m::mock(GenerateProxyClass::class), + $proxy ?? m::mock(GenerateProxyClass::class), ); } } From f3e431079b06746161f99e841b254f867fe7558e Mon Sep 17 00:00:00 2001 From: tw Date: Tue, 22 Sep 2026 14:23:57 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(api-docs):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E6=A8=A1=E5=9E=8B=E5=92=8C=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=8F=82=E6=95=B0=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Page 类用于分页数据封装,包含总数和内容属性 - 添加 ApiVariable 注解支持接口文档自动生成 - 创建 SystemRequest trait 定义系统标识参数 - 集成 ApiModelProperty 和验证注解确保参数规范 - 支持数组类型的系统标识参数并配置验证规则 --- example/DTO/SystemRequest.php | 20 ++++++++++++++++++++ tests/Request/Page.php | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 example/DTO/SystemRequest.php create mode 100644 tests/Request/Page.php diff --git a/example/DTO/SystemRequest.php b/example/DTO/SystemRequest.php new file mode 100644 index 0000000..0529c99 --- /dev/null +++ b/example/DTO/SystemRequest.php @@ -0,0 +1,20 @@ +content = $content; + $this->total = $total; + } +}