Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]]],
],
/*
|--------------------------------------------------------------------------
Expand Down Expand Up @@ -374,6 +375,9 @@ class UserController

// 分页响应
#[ApiResponse(new Page([UserResponse::class]), 200, '分页数据')]

// 分页响应(types写法,无需实例化)
#[ApiResponse(Page::class, 200, '分页数据', types: ['content' => [UserResponse::class]])]
```

**泛型支持示例:**
Expand Down Expand Up @@ -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<CodeResponse<X>>`)可继续用实例作为 `types` 的值:`types: ['content' => [new CodeResponse(PhpType::INT)]]`。
> 注意:`types` 仅在 `returnType` 为单个类名时生效,不支持 `[Page::class]` 这类数组包裹形式。

### 参数注解

#### `#[RequestBody]` - Body 参数
Expand Down
21 changes: 20 additions & 1 deletion README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]]],
],

/*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<CodeResponse<X>>`), 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
Expand Down
20 changes: 20 additions & 0 deletions example/DTO/SystemRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace HyperfExample\ApiDocs\DTO;

use Hyperf\ApiDocs\Annotation\ApiModelProperty;
use Hyperf\DTO\Annotation\Validation\Arr;
use Hyperf\DTO\Annotation\Validation\Validation;

trait SystemRequest
{

/**
* @var int[]
*/
#[ApiModelProperty('系统标识',example: [1])]
#[Arr]
#[Validation('integer', customKey: 'system.*')]
public array $system = [];

}
10 changes: 8 additions & 2 deletions publish/skills/api-docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —— 请求头(不常用)

Expand Down Expand Up @@ -168,6 +172,8 @@ class GlobalResponse
}
```

在 `ApiResponse` 中声明可变类型的具体类型时,优先用 `types` 映射写法(见上文 ApiResponse 章节),无需 new 实例。

## DTO 与文档联动

### 请求参数绑定
Expand Down Expand Up @@ -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` | 安全方案定义 |
Expand Down
36 changes: 33 additions & 3 deletions src/Annotation/ApiResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $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
Expand Down
3 changes: 1 addition & 2 deletions src/Ast/ResponseVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ class ResponseVisitor extends NodeVisitorAbstract
public BuilderFactory $factory;

public function __construct(
protected object $generateClass,
protected string $generateClassName,
protected array $propertyArr,
) {
Expand Down Expand Up @@ -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;
}
}
Expand Down
61 changes: 46 additions & 15 deletions src/Swagger/GenerateProxyClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $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;
Expand All @@ -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);
Expand All @@ -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();
Expand All @@ -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)) {
Expand All @@ -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);

Expand Down
12 changes: 8 additions & 4 deletions src/Swagger/GenerateResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ public function __construct(
protected SwaggerComponents $swaggerComponents,
protected SwaggerCommon $common,
protected GenerateProxyClass $genericProxyClass,
) {
}
) {}

/**
* 生成Response.
Expand Down Expand Up @@ -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)) {
// 生成代理类
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading
Loading