From 18b4696ba272c0a4254d0509e0f56b35bae3bccf Mon Sep 17 00:00:00 2001 From: tangwei Date: Tue, 26 Aug 2025 21:07:36 +0800 Subject: [PATCH 01/43] update massage --- src/Listener/BootAppRouteListener.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Listener/BootAppRouteListener.php b/src/Listener/BootAppRouteListener.php index 24f0ebe..25fe91c 100644 --- a/src/Listener/BootAppRouteListener.php +++ b/src/Listener/BootAppRouteListener.php @@ -89,6 +89,6 @@ public function process(object $event): void self::$httpServerName = $httpServer['name']; $isKnife4j = Composer::hasPackage('tangwei/knife4j-ui'); $docHtml = $isKnife4j ? '/doc' : ''; - static::$massage = 'Swagger docs url at http://' . $httpServer['host'] . ':' . $httpServer['port'] . $prefix . $docHtml; + static::$massage = 'Swagger docs url at http://127.0.0.1:' . $httpServer['port'] . $prefix . $docHtml; } } From b5368acab2663e6097a2ce3ba639eabfe7fe691f Mon Sep 17 00:00:00 2001 From: tw Date: Tue, 7 Oct 2025 14:46:01 +0800 Subject: [PATCH 02/43] update massage --- src/Listener/BootAppRouteListener.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Listener/BootAppRouteListener.php b/src/Listener/BootAppRouteListener.php index 25fe91c..aa1dc59 100644 --- a/src/Listener/BootAppRouteListener.php +++ b/src/Listener/BootAppRouteListener.php @@ -89,6 +89,8 @@ public function process(object $event): void self::$httpServerName = $httpServer['name']; $isKnife4j = Composer::hasPackage('tangwei/knife4j-ui'); $docHtml = $isKnife4j ? '/doc' : ''; - static::$massage = 'Swagger docs url at http://127.0.0.1:' . $httpServer['port'] . $prefix . $docHtml; + + $host = $httpServer['host'] == '0.0.0.0' ? '127.0.0.1' : $httpServer['host']; + static::$massage = 'Swagger docs url at http://' . $host . ':' . $httpServer['port'] . $prefix . $docHtml; } } From cfa756c3725d89bc0b6f30e0b0819c6fa31b6080 Mon Sep 17 00:00:00 2001 From: tw Date: Mon, 15 Dec 2025 15:49:09 +0800 Subject: [PATCH 03/43] update README.md --- README.md | 792 +++++++++++++++++++++++++++----------- README_EN.md | 1043 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1607 insertions(+), 228 deletions(-) create mode 100644 README_EN.md diff --git a/README.md b/README.md index 1b6b8b9..1777884 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,49 @@ -# PHP Swagger Api Docs +# PHP Hyperf API Docs + [![Latest Stable Version](https://img.shields.io/packagist/v/tangwei/apidocs)](https://packagist.org/packages/tangwei/apidocs) [![Total Downloads](https://img.shields.io/packagist/dt/tangwei/apidocs)](https://packagist.org/packages/tangwei/apidocs) [![License](https://img.shields.io/packagist/l/tangwei/apidocs)](https://github.com/tw2066/api-docs) +[![PHP Version](https://img.shields.io/badge/php-%3E%3D8.1-blue)](https://www.php.net) -基于 [Hyperf](https://github.com/hyperf/hyperf) 框架的 swagger 文档生成组件,支持swoole/swow驱动 +[English](./README_EN.md) | 中文 -## 优点 +基于 [Hyperf](https://github.com/hyperf/hyperf) 框架的 Swagger/OpenAPI 文档自动生成组件,支持 Swoole/Swow 引擎,为您提供优雅的 API 文档解决方案。 -- 声明参数类型完成自动注入,参数映射到PHP类,根据类和注解自动生成Swagger文档 -- 代码DTO模式,可维护性好,扩展性好 -- 支持数组(类/简单类型),递归,嵌套 -- 支持注解数据校验 -- 支持api token -- 支持PHP8原生注解,PHP8.1枚举 -- 支持openapi 3.0 +## ✨ 特性 -## 使用须知 +- 🚀 **自动生成** - 基于 PHP 8 Attributes 自动生成 OpenAPI 3.0/3.1 文档 +- 🎯 **类型安全** - 支持 DTO 模式,参数自动映射到 PHP 类 +- 📝 **多种 UI** - 支持 Swagger UI、Knife4j、Redoc、RapiDoc、Scalar 等多种文档界面 +- ✅ **数据验证** - 集成 Hyperf 验证器,支持丰富的验证注解 +- 🔒 **安全认证** - 支持 API Token 和多种安全方案 +- 🔄 **类型支持** - 支持数组、递归、嵌套、枚举等复杂类型 +- 🎨 **灵活配置** - 可自定义全局响应格式、路由前缀等 +- 📦 **开箱即用** - 零配置即可使用,同时支持深度定制 -* php版本 >= 8.1,参数映射到PHP类不支持联合类型 -* 控制器中方法尽可能返回类(包含简单类型),这样会更好的生成文档 -* 当返回类的结果满足不了时,可以使用 #[ApiResponse] 注解 +## 📋 环境要求 -## 例子 +- PHP >= 8.1 +- Hyperf >= 3.0 +- Swoole >= 5.0 或 Swow -> 请参考[example目录](https://github.com/tw2066/api-docs/tree/master/example) +## 💡 使用须知 -## 安装 +- 控制器方法尽可能返回具体的类(包含简单类型),这样能更好地生成文档 +- 当返回类无法满足需求时,可使用 `#[ApiResponse]` 注解补充 -``` +## 📦 安装 + +```bash composer require tangwei/apidocs ``` -默认使用swagger-ui,可安装knife4j-ui(功能更强大) (可选) -``` +默认使用 Swagger UI,推荐安装 Knife4j UI(可选): + +```bash composer require tangwei/knife4j-ui ``` -## 使用 +## 🚀 快速开始 ### 1. 发布配置文件 @@ -44,12 +51,41 @@ composer require tangwei/knife4j-ui php bin/hyperf.php vendor:publish tangwei/apidocs ``` -#### 1.1 配置信息 +配置文件将发布到 `config/autoload/api_docs.php` -> config/autoload/api_docs.php +### 2. 基础配置 + +```php + env('APP_ENV') !== 'prod', + + // 文档访问路径 + 'prefix_url' => env('API_DOCS_PREFIX_URL', '/swagger'), + + // 基础信息 + 'swagger' => [ + 'info' => [ + 'title' => 'API 文档', + 'version' => '1.0.0', + 'description' => '项目 API 文档', + ], + 'servers' => [ + [ + 'url' => 'http://127.0.0.1:9501', + 'description' => 'API 服务器', + ], + ], + ], +]; +``` + +> 完整配置文件示例:config/autoload/api_docs.php
- 配置详情 + 完整配置说明(点击展开)

```php @@ -192,295 +228,510 @@ return [

+### 3. 启动服务 -### 2. 直接启动框架(需要有http服务) - -```shell script +```bash php bin/hyperf.php start +``` +启动成功后,访问 `http://your-host:9501/swagger` 即可查看 API 文档。 + +``` [INFO] Swagger docs url at http://0.0.0.0:9501/swagger -[INFO] TaskWorker#1 started. [INFO] Worker#0 started. [INFO] HTTP Server listening at 0.0.0.0:9501 ``` -* 看到`Swagger docs url`显示,表示文档生成成功 -* 访问`/swagger`可以看到swagger页面 -* 已安装[knife4j-ui](https://github.com/tw2066/knife4j-ui),访问`/swagger/doc`可以看到knife4j页面 -* 访问`/swagger/redoc`,可以看到[redoc](https://github.com/Redocly/redoc)页面 -* 访问`/swagger/scalar`,可以看到[scalar](https://github.com/scalar/scalar)页面 -* 访问`/swagger/rapidoc`,可以看到[rapidoc](https://github.com/rapi-doc/RapiDoc)页面 +## 📖 使用指南 + +### 基础示例 + +#### 1. 定义 DTO 类 + +```php + 1, 'username' => 'admin'], + ['id' => 2, 'username' => 'user'], + ]; + } + + #[PostMapping(path: 'create')] + #[ApiOperation(summary: '创建用户')] + public function create(#[RequestBody] #[Valid] UserRequest $request): array + { + return [ + 'id' => 1, + 'username' => $request->username, + 'age' => $request->age, + ]; + } +} +``` + +## 🎨 注解参考 + +### 控制器注解 + +#### `#[Api]` - 控制器标签 + +```php +#[Api( + tags: '用户管理', // 标签名称(支持数组) + description: '用户相关操作', // 描述 + position: 1, // 排序位置 + hidden: false // 是否隐藏 +)] +``` + +#### `#[ApiOperation]` - API 操作 + +```php +#[ApiOperation( + summary: '创建用户', // 摘要 + description: '详细描述', // 详细描述 + deprecated: false, // 是否废弃 + security: true, // 是否需要认证 + hidden: false // 是否隐藏 +)] +``` + +#### `#[ApiResponse]` - 响应定义 + +```php +// 简单类型响应 +#[ApiResponse(PhpType::INT, 200, '成功')] -## 注解 +// 对象响应 +#[ApiResponse(UserResponse::class, 200, '用户信息')] -> 命名空间:`Hyperf\DTO\Annotation\Contracts` +// 数组响应 +#[ApiResponse([UserResponse::class], 200, '用户列表')] -#### #[RequestBody] 注解 +// 分页响应 +#[ApiResponse(new Page([UserResponse::class]), 200, '分页数据')] +``` + +**泛型支持示例:** + +PHP 暂不支持泛型,可通过 `#[ApiVariable]` 实现: + +```php +use Hyperf\ApiDocs\Annotation\ApiVariable; + +class Page +{ + public int $total; + + #[ApiVariable] + public array $content; + + public function __construct(array $content, int $total = 0) + { + $this->content = $content; + $this->total = $total; + } +} +``` -- 获取Body参数 +控制器使用: ```php -public function add(#[RequestBody] DemoBodyRequest $request){} +#[ApiOperation('分页查询')] +#[GetMapping(path: 'page')] +#[ApiResponse(new Page([UserResponse::class]))] +public function page(#[RequestQuery] PageQuery $query): Page +{ + // 返回分页数据 +} ``` -#### #[RequestQuery] 注解 +### 参数注解 -- 获取GET参数 +#### `#[RequestBody]` - Body 参数 + +获取 POST/PUT/PATCH 请求的 JSON body 参数: ```php -public function add(#[RequestQuery] DemoQuery $request){} +public function create(#[RequestBody] #[Valid] UserRequest $request) +{ + // $request 自动填充 body 数据 +} ``` -#### #[RequestFormData] 注解 +#### `#[RequestQuery]` - Query 参数 -- 获取表单请求 +获取 URL 查询参数(GET 参数): ```php -public function fromData(#[RequestFormData] DemoFormData $formData){} +public function list(#[RequestQuery] #[Valid] QueryRequest $request) +{ + // $request 自动填充查询参数 +} ``` -- 获取文件(和表单一起使用) +#### `#[RequestFormData]` - 表单参数 + +获取表单数据(multipart/form-data): ```php #[ApiFormData(name: 'photo', format: 'binary')] +public function upload(#[RequestFormData] UploadRequest $formData) +{ + $file = $this->request->file('photo'); + // 处理文件上传 +} ``` -- 获取Body参数和GET参数 +#### `#[RequestHeader]` - 请求头参数 + +获取请求头信息: ```php -public function add(#[RequestBody] DemoBodyRequest $request, #[RequestQuery] DemoQuery $query){} +public function auth(#[RequestHeader] #[Valid] AuthHeader $header) +{ + // $header 自动填充请求头数据 +} ``` -#### #[ApiSecurity] 注解 +> ⚠️ **注意**:一个方法不能同时注入 `RequestBody` 和 `RequestFormData` + +### 属性注解 -- 优先级: 方法 > 类 > 全局 +#### `#[ApiModelProperty]` - 属性描述 ```php +#[ApiModelProperty( + value: '用户名', // 属性描述 + example: 'admin', // 示例值 + required: true, // 是否必填 + hidden: false // 是否隐藏 +)] +public string $username; +``` + +#### `#[ApiHeader]` - 请求头定义 + +```php +// 全局请求头(类级别) +#[ApiHeader('X-Request-Id')] + +// 方法级请求头 +#[ApiHeader( + name: 'Authorization', + required: true, + type: 'string', + description: 'Bearer token' +)] +``` + +#### `#[ApiSecurity]` - 安全认证 + +优先级:方法 > 类 > 全局 + +```php +// 使用默认认证 #[ApiSecurity('Authorization')] -public function getUserInfo(DemoToken $header){} + +// 方法级覆盖 +#[ApiOperation(summary: '登录', security: false)] // 不需要认证 ``` -> 注意: 一个方法,不能同时注入RequestBody和RequestFormData -#### #[ApiResponse] 注解 -* php暂不能定义数组类型,返回的数据类型不能完全满足 +## ✅ 数据验证 + +### 内置验证注解 + +组件提供丰富的验证注解支持: + +```php +use Hyperf\DTO\Annotation\Validation\*; + +class UserRequest +{ + #[Required] // 必填 + #[Max(50)] // 最大长度 + public string $username; - 当不能满足时,可以通过ApiResponse注解来解决 + #[Required] + #[Integer] // 整数 + #[Between(1, 120)] // 范围 + public int $age; - ```php - use Hyperf\ApiDocs\Annotation\ApiResponse; - use Hyperf\DTO\Type\PhpType; - - #[ApiResponse([PhpType::BOOL], 201)] - #[ApiResponse([PhpType::INT], 202)] - #[ApiResponse([PhpType::BOOL])] - public function test(){} - ``` + #[Email] // 邮箱格式 + public ?string $email; -* php暂不支持泛型,当返回存在相同结构时候,需要写很多类来返回 + #[Url] // URL 格式 + public ?string $website; - 例: 分页只有`content`结构是可变,可以通过`#[ApiVariable]`配合使用 + #[Regex('/^1[3-9]\d{9}$/')] // 正则验证 + public ?string $mobile; - ```php - use Hyperf\ApiDocs\Annotation\ApiVariable; - - class Page - { - public int $total; - - #[ApiVariable] - public array $content; - - public function __construct(array $content, int $total = 0) - { - $this->content = $content; - $this->total = $total; - } - } - ``` + #[In(['male', 'female'])] // 枚举值 + public ?string $gender; + + #[Date] // 日期格式 + public ?string $birthday; +} +``` - 控制器 +> 💡 **提示**:只需在控制器方法参数中添加 `#[Valid]` 注解即可启用验证 - ```php - #[ApiOperation('分页')] - #[GetMapping(path: 'activityPage')] - #[ApiResponse(new Page([ActivityResponse::class]))] - public function activityPage(#[RequestQuery] PageQuery $pageQuery): Page - { - $activityPage = Activity::paginate($pageQuery->getSize()); - $arr = []; - foreach ($activityPage as $activity) { - $arr[] = ActivityResponse::from($activity); - } - return new Page($arr, $activityPage->total()); - } - ``` +```php +public function create(#[RequestBody] #[Valid] UserRequest $request) +{ + // 验证自动执行 +} +``` - 通过`#[ApiResponse(new Page([ActivityResponse::class]))]`会生成相应的文档 +### 自定义验证 +#### 使用 Validation 注解 +```php +// 支持 Laravel 风格的验证规则 +#[Validation('required|string|min:3|max:50')] +public string $username; -## 示例 +// 数组元素验证 +#[Validation('integer', customKey: 'ids.*')] +public array $ids; +``` -### 控制器 +#### 自定义验证注解 ```php -#[Controller(prefix: '/demo')] -#[Api(tags: 'demo管理', position: 1)] -class DemoController extends AbstractController +name = $request->name; - var_dump($request); - return $contact; + parent::__construct($messages); } +} +``` - #[PutMapping(path: 'add')] - #[ApiOperation(summary: '提交body数据和get参数')] - public function add(#[RequestBody] DemoBodyRequest $request, #[RequestQuery] DemoQuery $query) - { - var_dump($query); - return json_encode($request, JSON_UNESCAPED_UNICODE); - } +使用自定义验证: - #[PostMapping(path: 'fromData')] - #[ApiOperation(summary: '表单提交')] - #[ApiFormData(name: 'photo', type: 'file')] - public function fromData(#[RequestFormData] DemoFormData $formData): bool - { - $file = $this->request->file('photo'); - var_dump($file); - var_dump($formData); - return true; - } +```php +use App\Validation\Mobile; - #[GetMapping(path: 'find/{id}/and/{in}')] - #[ApiOperation('查询单体记录')] - #[ApiHeader(name: 'test')] - public function find(int $id, float $in): array - { - return ['$id' => $id, '$in' => $in]; - } +class RegisterRequest +{ + #[Required] + #[Mobile] + public string $phone; } ``` -## 验证器 +## 🔧 高级特性 -### 基于框架的验证 +### 数组类型支持 -> 安装hyperf框架验证器[hyperf/validation](https://github.com/hyperf/validation), 并配置(已安装忽略) +#### 方法一:使用 PHPDoc -- 注解 - `Required` `Between` `Date` `Email` `Image` `Integer` `Nullable` `Numeric` `Url` `Validation` `...` -- 校验生效 +```php +/** + * @var Address[] + */ +#[ApiModelProperty('地址列表')] +public array $addresses; + +/** + * @var int[] + */ +#[ApiModelProperty('ID 列表')] +public array $ids; +``` -> 只需在控制器方法中加上 #[Valid] 注解 +#### 方法二:使用 ArrayType 注解 ```php -public function index(#[RequestQuery] #[Valid] DemoQuery $request){} -class DemoQuery +use Hyperf\DTO\Annotation\ArrayType; + +#[ApiModelProperty('地址列表')] +#[ArrayType(Address::class)] +public array $addresses; + +#[ApiModelProperty('标签列表')] +#[ArrayType('string')] +public array $tags; +``` + +### 嵌套对象 + +```php +class UserRequest { - #[ApiModelProperty('名称')] - #[Required] - #[Max(5)] - #[In(['qq','aa'])] public string $name; + + // 嵌套对象 + #[ApiModelProperty('地址信息')] + public Address $address; + + /** + * @var Address[] + */ + #[ApiModelProperty('多个地址')] + public array $addresses; +} - #[ApiModelProperty('正则')] - #[Str] - #[Regex('/^.+@.+$/i')] - #[StartsWith('aa,bb')] - #[Max(10)] - public string $email; - - #[ApiModelProperty('数量')] - #[Required] - #[Integer] - #[Between(1,5)] - public int $num; +class Address +{ + public string $province; + public string $city; + public string $street; } ``` -### 自定义注解验证 +### 枚举支持 -> 注解的验证支持框架所有验证, 组件提供了常用的注解用于验证 +```php +use Hyperf\DTO\Type\PhpType; -1. 使用自定义验证注解, 创建注解类继承`Hyperf\DTO\Annotation\Validation\BaseValidation` -2. 重写`$rule`属性或`getRule`方法 +enum StatusEnum: int +{ + case PENDING = 0; + case ACTIVE = 1; + case INACTIVE = 2; +} -```php -//示例 -#[Attribute(Attribute::TARGET_PROPERTY)] -class Image extends BaseValidation +class OrderRequest { - protected $rule = 'image'; + #[ApiModelProperty('订单状态')] + public StatusEnum $status; } ``` -### 验证器Validation +### 全局响应格式 -1. 大家都习惯了框架的`required|date|after:start_date`写法 +配置全局响应包装类: ```php -//可以通过Validation实现 -#[Validation('required|date|after:start_date')] +// config/autoload/api_docs.php +return [ + 'global_return_responses_class' => \App\DTO\GlobalResponse::class, +]; ``` -2. 需要支持数组里面是int数据情况 `'intArr.*' => 'integer'`的情况 +定义全局响应类: ```php -//可以通过Validation中customKey来自定义key实现 -#[Validation('integer', customKey: 'intArr.*')] -public array $intArr; -``` + PHP原生暂不支持`int[]`或`Class[]`类型, 使用示例 + #[ApiModelProperty('消息')] + public string $message = 'success'; -```php - /** - * class类型映射数组. - * @var \App\DTO\Address[] - */ - #[ApiModelProperty('地址')] - public array $addressArr; + #[ApiVariable] + #[ApiModelProperty('响应数据')] + public mixed $data = null; +} +``` - /** - * 简单类型映射数组. - * @var int[] - */ - #[ApiModelProperty('int类型的数组')] - public array $intArr; +### 文件上传 - /** - * 通过注解映射数组. - */ - #[ApiModelProperty('string类型的数组')] - #[ArrayType('string')] - public array $stringArr; +```php +#[PostMapping(path: 'upload')] +#[ApiOperation(summary: '文件上传')] +#[ApiFormData(name: 'file', format: 'binary', required: true)] +#[ApiFormData(name: 'description', type: 'string')] +public function upload(#[RequestFormData] UploadRequest $request) +{ + $file = $this->request->file('file'); + // 处理文件上传 + return ['url' => '/uploads/file.jpg']; +} ``` -### `AutoController`注解 +## 🎭 多种 UI 界面 + +访问不同的 UI 界面: -> 控制器中使用`AutoController`注解,只收集了`POST`方法 +- **Swagger UI**: `http://your-host:9501/swagger` +- **Knife4j**: `http://your-host:9501/swagger/knife4j` +- **Redoc**: `http://your-host:9501/swagger/redoc` +- **RapiDoc**: `http://your-host:9501/swagger/rapidoc` +- **Scalar**: `http://your-host:9501/swagger/scalar` -## DTO数据映射 +## ⚙️ 配置参考 -> api-docs引入到dto组件 +### DTO 数据映射 -### 注解 +> api-docs 依赖 DTO 组件,更多详情请查看 [DTO 文档](https://github.com/hyperf/dto) -#### Dto注解 +#### `#[Dto]` 注解 -标记为dto类 +标记为 DTO 类: ```php use Hyperf\DTO\Annotation\Dto; @@ -491,12 +742,12 @@ class DemoQuery } ``` -* 可以设置返回枚举`#[Dto(Convert::SNAKE)]`, 批量转换下划线返回的key -* `Dto`注解不会生成文档, 要生成对应文档使用`JSONField`注解 +- 可以设置返回格式 `#[Dto(Convert::SNAKE)]`,批量转换为下划线格式的 key +- `Dto` 注解不会生成文档,要生成对应文档使用 `JSONField` 注解 -#### JSONField注解 +#### `#[JSONField]` 注解 -用于设置属性的别名 +用于设置属性的别名: ```php use Hyperf\DTO\Annotation\Dto; @@ -508,21 +759,27 @@ class DemoQuery #[ApiModelProperty('这是一个别名')] #[JSONField('alias_name')] #[Required] - public string $name; + public string $name; } ``` -* 设置JSONField后会生成代理类,生成`alias_name`属性 -* 接受和返回字段都以`alias_name` 为准 +- 设置 `JSONField` 后会生成代理类,生成 `alias_name` 属性 +- 接收和返回字段都以 `alias_name` 为准 + +### RPC 支持 + +[返回 PHP 对象](https://hyperf.wiki/3.1/#/zh-cn/json-rpc?id=%e8%bf%94%e5%9b%9e-php-%e5%af%b9%e8%b1%a1) + +aspects.php 中配置: -## RPC [返回PHP对象](https://hyperf.wiki/3.1/#/zh-cn/json-rpc?id=%e8%bf%94%e5%9b%9e-php-%e5%af%b9%e8%b1%a1) -> aspects.php中配置 ```php return [ \Hyperf\DTO\Aspect\ObjectNormalizerAspect::class ] ``` -> 当框架导入 symfony/serializer (^5.0) 和 symfony/property-access (^5.0) 后,并在 dependencies.php 中配置一下映射关系 + +当框架导入 `symfony/serializer (^5.0)` 和 `symfony/property-access (^5.0)` 后,在 dependencies.php 中配置映射关系: + ```php use Hyperf\Serializer\SerializerFactory; use Hyperf\Serializer\Serializer; @@ -532,22 +789,101 @@ return [ ]; ``` -## Phar 打包器 +## 💡 最佳实践 -```shell -# 1.启动生成代理类和注解缓存 -php bin/hyperf.php start -# 2.打包 -php bin/hyperf.php phar:build +### 1. DTO 类设计 + +- 使用有意义的类名,如 `CreateUserRequest`、`UserResponse` +- 为每个属性添加 `ApiModelProperty` 注解 +- 分离 Request 和 Response 定义 +- 合理使用验证注解 + +### 2. 控制器设计 + +- 使用 `Api` 注解对控制器分组 +- 为每个方法添加 `ApiOperation` 描述 +- 尽可能返回具体类型而非 `array` +- 合理使用 `ApiResponse` 定义响应格式 + +### 3. 安全性 + +- 生产环境禁用文档服务 +- 使用 `ApiSecurity` 控制 API 认证 +- 使用 `hidden: true` 隐藏敏感接口 + +### 4. 性能优化 + +- 开发环境使用文档,生产环境禁用 +- 合理使用缓存 +- 避免过深的嵌套结构 + +## 📚 常见问题 + +### Q: 文档没有生成? + +A: 检查以下几点: +1. 配置文件中 `enable` 是否为 `true` +2. 查看日志是否有错误信息 + +### Q: 如何定义数组类型? + +A: 使用 PHPDoc 注释或 `ArrayType` 注解: + +```php +/** + * @var User[] + */ +public array $users; + +// 或 +#[ArrayType(User::class)] +public array $users; +``` + +### Q: 如何隐藏某些接口? + +A: 使用 `hidden` 参数: + +```php +#[Api(hidden: true)] // 隐藏整个控制器 + +#[ApiOperation(summary: '测试', hidden: true)] // 隐藏单个接口 ``` -## Swagger界面 +### Q: 如何自定义响应格式? + +A: 使用 `ApiResponse` 注解或配置全局响应类: + +```php +#[ApiResponse(UserResponse::class, 200, '成功')] +public function getUser(): UserResponse +{ + return new UserResponse(); +} +``` + +### Q: 支持哪些验证规则? + +A: 支持所有 Hyperf Validation 规则。详见 [Hyperf 验证器文档](https://hyperf.wiki/3.1/#/zh-cn/validation)。 + +### Q: `AutoController` 注解支持吗? + +A: 支持,但只会收集 `POST` 方法。建议使用标准路由注解以获得更好的文档生成效果。 + +## 📖 示例项目 + +> 完整示例请参考 [example 目录](https://github.com/tw2066/api-docs/tree/master/example) + +## 🔗 相关链接 -![hMvJnQ](https://gitee.com/tw666/source/raw/master/img/swagger.png) +- [Hyperf 官方文档](https://hyperf.wiki) +- [OpenAPI 规范](https://swagger.io/specification/) +- [Swagger UI](https://swagger.io/tools/swagger-ui/) +- [Knife4j](https://doc.xiaominfo.com/) +- [示例项目](https://github.com/tw2066/api-docs/tree/master/example) -## PHP Accessor +--- -生成类访问器(Getter & Setter) +如果这个项目对你有帮助,请给个 ⭐ Star! -推荐使用[free2one/hyperf-php-accessor](https://github.com/kkguan/hyperf-php-accessor) diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..8454f62 --- /dev/null +++ b/README_EN.md @@ -0,0 +1,1043 @@ +# Hyperf API Docs + +[![Latest Stable Version](https://img.shields.io/packagist/v/tangwei/apidocs)](https://packagist.org/packages/tangwei/apidocs) +[![Total Downloads](https://img.shields.io/packagist/dt/tangwei/apidocs)](https://packagist.org/packages/tangwei/apidocs) +[![License](https://img.shields.io/packagist/l/tangwei/apidocs)](https://github.com/tw2066/api-docs) +[![PHP Version](https://img.shields.io/badge/php-%3E%3D8.1-blue)](https://www.php.net) + +English | [中文](./README.md) + +Automatic Swagger/OpenAPI documentation generator for the [Hyperf](https://github.com/hyperf/hyperf) framework, supporting Swoole/Swow engines, providing an elegant and powerful API documentation solution. + +## ✨ Features + +- 🚀 **Auto Generation** - Automatically generate OpenAPI 3.0 documentation based on PHP 8 Attributes +- 🎯 **Type Safety** - Support DTO mode with automatic parameter mapping to PHP classes +- 📝 **Multiple UIs** - Support Swagger UI, Knife4j, Redoc, RapiDoc, Scalar, and more +- ✅ **Data Validation** - Integrate Hyperf validator with rich validation annotations +- 🔒 **Security** - Support API Token and multiple security schemes +- 🔄 **Type Support** - Support arrays, recursion, nesting, enums, and other complex types +- 🎨 **Flexible Config** - Customizable global response format, route prefix, etc. +- 📦 **Out of Box** - Zero configuration ready to use with deep customization support + +## 📋 Requirements + +- PHP >= 8.1 +- Hyperf >= 3.0 +- Swoole >= 5.0 or Swow + +## 💡 Important Notes + +- Union types are not supported for parameter mapping to PHP classes +- Controller methods should return specific types (including simple types) for better documentation generation +- Use `#[ApiResponse]` annotation when return types cannot fully express the response structure + +## 📦 Installation + +```bash +composer require tangwei/apidocs +``` + +By default, Swagger UI is used. You can optionally install Knife4j UI (recommended): + +```bash +composer require tangwei/knife4j-ui +``` + +## 🚀 Quick Start + +### 1. Publish Configuration + +```bash +php bin/hyperf.php vendor:publish tangwei/apidocs +``` + +Configuration file will be published to `config/autoload/api_docs.php` + +
+ Complete Configuration Reference (Click to expand) +

+ +> Full configuration example: config/autoload/api_docs.php + +```php + env('APP_ENV') !== 'prod', + + /* + |-------------------------------------------------------------------------- + | Swagger File Format + |-------------------------------------------------------------------------- + | + | Supports json and yaml + | + */ + 'format' => 'json', + + /* + |-------------------------------------------------------------------------- + | Swagger File Output Path + |-------------------------------------------------------------------------- + */ + 'output_dir' => BASE_PATH . '/runtime/container', + + /* + |-------------------------------------------------------------------------- + | Proxy Class Path + |-------------------------------------------------------------------------- + */ + 'proxy_dir' => BASE_PATH . '/runtime/container/proxy', + + /* + |-------------------------------------------------------------------------- + | Route Prefix + |-------------------------------------------------------------------------- + */ + 'prefix_url' => env('API_DOCS_PREFIX_URL', '/swagger'), + + /* + |-------------------------------------------------------------------------- + | Swagger Resources CDN Path + |-------------------------------------------------------------------------- + */ + 'prefix_swagger_resources' => 'https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.27.1', + + /* + |-------------------------------------------------------------------------- + | Global Response Class + |-------------------------------------------------------------------------- + | + | Global response format like: [code=>200, data=>null] + | Use with ApiVariable annotation, see GlobalResponse class example + | Response format can be unified using AOP + | + */ + // 'global_return_responses_class' => GlobalResponse::class, + + /* + |-------------------------------------------------------------------------- + | Replace Validation Attributes + |-------------------------------------------------------------------------- + | + | Use ApiModelProperty annotation values for validation error messages + | + */ + 'validation_custom_attributes' => true, + + /* + |-------------------------------------------------------------------------- + | DTO Default Value Level + |-------------------------------------------------------------------------- + | + | 0: Default (no default values) + | 1: Simple types get default values, complex types with ? get null + | - Simple type defaults: int:0 float:0 string:'' bool:false array:[] mixed:null + | 2: (Use with caution) Includes level 1 and complex types (except union) get null + | + */ + 'dto_default_value_level' => 0, + + /* + |-------------------------------------------------------------------------- + | Global Responses + |-------------------------------------------------------------------------- + */ + 'responses' => [ + ['response' => 401, 'description' => 'Unauthorized'], + ['response' => 500, 'description' => 'System error'], + ], + + /* + |-------------------------------------------------------------------------- + | Swagger Basic Configuration + |-------------------------------------------------------------------------- + | + | This maps to OpenAPI object + | + */ + 'swagger' => [ + 'info' => [ + 'title' => 'API Documentation', + 'version' => '1.0.0', + 'description' => 'API Documentation', + ], + 'servers' => [ + [ + 'url' => 'http://127.0.0.1:9501', + 'description' => 'API Server', + ], + ], + 'components' => [ + 'securitySchemes' => [ + [ + 'securityScheme' => 'Authorization', + 'type' => 'apiKey', + 'in' => 'header', + 'name' => 'Authorization', + ], + ], + ], + 'security' => [ + ['Authorization' => []], + ], + 'externalDocs' => [ + 'description' => 'GitHub', + 'url' => 'https://github.com/tw2066/api-docs', + ], + ], +]; +``` +

+
+ +### 2. Basic Configuration + +```php + env('APP_ENV') !== 'prod', + + // Documentation access path + 'prefix_url' => env('API_DOCS_PREFIX_URL', '/swagger'), + + // Basic information + 'swagger' => [ + 'info' => [ + 'title' => 'API Documentation', + 'version' => '1.0.0', + 'description' => 'Project API Documentation', + ], + 'servers' => [ + [ + 'url' => 'http://127.0.0.1:9501', + 'description' => 'API Server', + ], + ], + ], +]; +``` + +### 3. Start Server + +```bash +php bin/hyperf.php start +``` + +After successful startup, visit `http://your-host:9501/swagger` to view the API documentation. + +``` +[INFO] Swagger docs url at http://0.0.0.0:9501/swagger +[INFO] Worker#0 started. +[INFO] HTTP Server listening at 0.0.0.0:9501 +``` + +## 📖 Usage Guide + +### Basic Example + +#### 1. Define DTO Class + +```php + 1, 'username' => 'admin'], + ['id' => 2, 'username' => 'user'], + ]; + } + + #[PostMapping(path: 'create')] + #[ApiOperation(summary: 'Create user')] + public function create(#[RequestBody] #[Valid] UserRequest $request): array + { + return [ + 'id' => 1, + 'username' => $request->username, + 'age' => $request->age, + ]; + } +} +``` + +## 🎨 Annotation Reference + +### Controller Annotations + +#### `#[Api]` - Controller Tag + +```php +#[Api( + tags: 'User Management', // Tag name (supports array) + description: 'User operations', // Description + position: 1, // Sort position + hidden: false // Whether to hide +)] +``` + +#### `#[ApiOperation]` - API Operation + +```php +#[ApiOperation( + summary: 'Create user', // Summary + description: 'Detailed description', // Detailed description + deprecated: false, // Whether deprecated + security: true, // Whether authentication required + hidden: false // Whether to hide +)] +``` + +#### `#[ApiResponse]` - Response Definition + +```php +// Simple type response +#[ApiResponse(PhpType::INT, 200, 'Success')] + +// Object response +#[ApiResponse(UserResponse::class, 200, 'User information')] + +// Array response +#[ApiResponse([UserResponse::class], 200, 'User list')] + +// Paginated response +#[ApiResponse(new Page([UserResponse::class]), 200, 'Paginated data')] +``` + +### Parameter Annotations + +#### `#[RequestBody]` - Body Parameters + +Get JSON body parameters from POST/PUT/PATCH requests: + +```php +public function create(#[RequestBody] #[Valid] UserRequest $request) +{ + // $request automatically populated with body data +} +``` + +#### `#[RequestQuery]` - Query Parameters + +Get URL query parameters (GET parameters): + +```php +public function list(#[RequestQuery] #[Valid] QueryRequest $request) +{ + // $request automatically populated with query parameters +} +``` + +#### `#[RequestFormData]` - Form Parameters + +Get form data (multipart/form-data): + +```php +#[ApiFormData(name: 'photo', format: 'binary')] +public function upload(#[RequestFormData] UploadRequest $formData) +{ + $file = $this->request->file('photo'); + // Handle file upload +} +``` + +#### `#[RequestHeader]` - Header Parameters + +Get request header information: + +```php +public function auth(#[RequestHeader] #[Valid] AuthHeader $header) +{ + // $header automatically populated with header data +} +``` + +**Generic Type Support Example:** + +PHP doesn't natively support generics, but you can achieve similar functionality using `#[ApiVariable]`: + +```php +use Hyperf\ApiDocs\Annotation\ApiVariable; + +class Page +{ + public int $total; + + #[ApiVariable] + public array $content; + + public function __construct(array $content, int $total = 0) + { + $this->content = $content; + $this->total = $total; + } +} +``` + +Controller usage: + +```php +#[ApiOperation('Paginated query')] +#[GetMapping(path: 'page')] +#[ApiResponse(new Page([UserResponse::class]))] +public function page(#[RequestQuery] PageQuery $query): Page +{ + // Return paginated data +} +``` + +### Property Annotations + +#### `#[ApiModelProperty]` - Property Description + +```php +#[ApiModelProperty( + value: 'Username', // Property description + example: 'admin', // Example value + required: true, // Whether required + hidden: false // Whether to hide +)] +public string $username; +``` + +#### `#[ApiHeader]` - Header Definition + +```php +// Global header (class level) +#[ApiHeader('X-Request-Id')] + +// Method level header +#[ApiHeader( + name: 'Authorization', + required: true, + type: 'string', + description: 'Bearer token' +)] +``` + +#### `#[ApiSecurity]` - Security Authentication + +Priority: Method > Class > Global + +```php +// Use default authentication +#[ApiSecurity('Authorization')] + +// Method level override +#[ApiOperation(summary: 'Login', security: false)] // No authentication required +``` + +> ⚠️ **Note**: A method cannot inject both `RequestBody` and `RequestFormData` simultaneously + +## ✅ Data Validation + +### Built-in Validation Annotations + +The component provides rich validation annotations: + +```php +use Hyperf\DTO\Annotation\Validation\*; + +class UserRequest +{ + #[Required] // Required + #[Max(50)] // Max length + public string $username; + + #[Required] + #[Integer] // Integer + #[Between(1, 120)] // Range + public int $age; + + #[Email] // Email format + public ?string $email; + + #[Url] // URL format + public ?string $website; + + #[Regex('/^1[3-9]\d{9}$/')] // Regex validation + public ?string $mobile; + + #[In(['male', 'female'])] // Enum values + public ?string $gender; + + #[Date] // Date format + public ?string $birthday; +} +``` + +> 💡 **Tip**: Simply add the `#[Valid]` annotation to controller method parameters to enable validation + +```php +public function create(#[RequestBody] #[Valid] UserRequest $request) +{ + // Validation is automatically executed +} +``` + +### Custom Validation + +#### Using Validation Annotation + +```php +// Support Laravel-style validation rules +#[Validation('required|string|min:3|max:50')] +public string $username; + +// Array element validation +#[Validation('integer', customKey: 'ids.*')] +public array $ids; +``` + +#### Custom Validation Annotation + +```php + \App\DTO\GlobalResponse::class, +]; +``` + +Define global response class: + +```php +request->file('file'); + // Handle file upload + return ['url' => '/uploads/file.jpg']; +} +``` + +## 🔧 Advanced Features + +### Array Type Support + +#### Method 1: Using PHPDoc + +```php +/** + * @var Address[] + */ +#[ApiModelProperty('Address list')] +public array $addresses; + +/** + * @var int[] + */ +#[ApiModelProperty('ID list')] +public array $ids; +``` + +#### Method 2: Using ArrayType Annotation + +```php +use Hyperf\DTO\Annotation\ArrayType; + +#[ApiModelProperty('Address list')] +#[ArrayType(Address::class)] +public array $addresses; + +#[ApiModelProperty('Tag list')] +#[ArrayType('string')] +public array $tags; +``` + +### Nested Objects + +```php +class UserRequest +{ + public string $name; + + // Nested object + #[ApiModelProperty('Address info')] + public Address $address; + + /** + * @var Address[] + */ + #[ApiModelProperty('Multiple addresses')] + public array $addresses; +} + +class Address +{ + public string $province; + public string $city; + public string $street; +} +``` + +### Enum Support + +```php +use Hyperf\DTO\Type\PhpType; + +enum StatusEnum: int +{ + case PENDING = 0; + case ACTIVE = 1; + case INACTIVE = 2; +} + +class OrderRequest +{ + #[ApiModelProperty('Order status')] + public StatusEnum $status; +} +``` + +### Global Response Format + +Configure global response wrapper class: + +```php +// config/autoload/api_docs.php +return [ + 'global_return_responses_class' => \App\DTO\GlobalResponse::class, +]; +``` + +Define global response class: + +```php +request->file('file'); + // Handle file upload + return ['url' => '/uploads/file.jpg']; +} +``` + +## 🎭 Multiple UI Interfaces + +Access different UI interfaces: + +- **Swagger UI**: `http://your-host:9501/swagger` +- **Knife4j**: `http://your-host:9501/swagger/knife4j` +- **Redoc**: `http://your-host:9501/swagger/redoc` +- **RapiDoc**: `http://your-host:9501/swagger/rapidoc` +- **Scalar**: `http://your-host:9501/swagger/scalar` + +## ⚙️ Configuration Reference + +### DTO Data Mapping + +> api-docs depends on the DTO component. For more details, see [DTO Documentation](https://github.com/hyperf/dto) + +#### `#[Dto]` Annotation + +Mark as DTO class: + +```php +use Hyperf\DTO\Annotation\Dto; + +#[Dto] +class DemoQuery +{ +} +``` + +- Can set return format `#[Dto(Convert::SNAKE)]` to batch convert keys to snake_case +- `Dto` annotation doesn't generate documentation, use `JSONField` annotation to generate docs + +#### `#[JSONField]` Annotation + +Used to set property aliases: + +```php +use Hyperf\DTO\Annotation\Dto; +use Hyperf\DTO\Annotation\JSONField; + +#[Dto] +class DemoQuery +{ + #[ApiModelProperty('This is an alias')] + #[JSONField('alias_name')] + #[Required] + public string $name; +} +``` + +- Setting `JSONField` generates proxy class with `alias_name` property +- Both request and response use `alias_name` as the field name + +### RPC Support + +[Return PHP Object](https://hyperf.wiki/3.1/#/en/json-rpc?id=returning-php-objects) + +Configure in aspects.php: + +```php +return [ + \Hyperf\DTO\Aspect\ObjectNormalizerAspect::class +] +``` + +After importing `symfony/serializer (^5.0)` and `symfony/property-access (^5.0)`, configure mapping in dependencies.php: + +```php +use Hyperf\Serializer\SerializerFactory; +use Hyperf\Serializer\Serializer; + +return [ + Hyperf\Contract\NormalizerInterface::class => new SerializerFactory(Serializer::class), +]; +``` + +## 💡 Best Practices + +### 1. DTO Class Design + +- Use meaningful class names like `CreateUserRequest`, `UserResponse` +- Add `ApiModelProperty` annotation for each property +- Separate Request and Response definitions +- Use validation annotations appropriately + +### 2. Controller Design + +- Use `Api` annotation to group controllers +- Add `ApiOperation` description for each method +- Return specific types instead of `array` when possible +- Use `ApiResponse` to define response formats properly + +### 3. Security + +- Disable documentation service in production +- Use `ApiSecurity` to control API authentication +- Use `hidden: true` to hide sensitive endpoints + +### 4. Performance Optimization + +- Use documentation in development, disable in production +- Use caching appropriately +- Avoid deeply nested structures + +## 📚 FAQ + +### Q: Documentation not generated? + +A: Check the following: +1. Is `enable` set to `true` in config file +2. Is `#[Api]` annotation added to controller +3. Is route annotation added to method (e.g., `#[GetMapping]`) +4. Check logs for errors + +### Q: How to define array types? + +A: Use PHPDoc comments or `ArrayType` annotation: + +```php +/** + * @var User[] + */ +public array $users; + +// Or +#[ArrayType(User::class)] +public array $users; +``` + +### Q: How to hide certain endpoints? + +A: Use `hidden` parameter: + +```php +#[Api(hidden: true)] // Hide entire controller + +#[ApiOperation(summary: 'Test', hidden: true)] // Hide single endpoint +``` + +### Q: How to customize response format? + +A: Use `ApiResponse` annotation or configure global response class: + +```php +#[ApiResponse(UserResponse::class, 200, 'Success')] +public function getUser(): UserResponse +{ + return new UserResponse(); +} +``` + +### Q: What validation rules are supported? + +A: All Hyperf Validation rules are supported. See [Hyperf Validation Documentation](https://hyperf.wiki/3.1/#/en/validation). + +### Q: Does `AutoController` annotation work? + +A: Yes, but it only collects `POST` methods. It's recommended to use standard route annotations for better documentation generation. + +## 📖 Example Project + +> For complete examples, see the [example directory](https://github.com/tw2066/api-docs/tree/master/example) + +## 🔗 Related Links + +- [Hyperf Official Documentation](https://hyperf.wiki) +- [OpenAPI Specification](https://swagger.io/specification/) +- [Swagger UI](https://swagger.io/tools/swagger-ui/) +- [Knife4j](https://doc.xiaominfo.com/) +- [Example Project](https://github.com/tw2066/api-docs/tree/master/example) + +## 📝 Changelog + +See [CHANGELOG](CHANGELOG.md) for detailed version updates. + +## 🤝 Contributing + +Issues and Pull Requests are welcome! + +1. Fork this repository +2. Create a feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## 📜 License + +[MIT License](LICENSE) + +## ❤️ Acknowledgments + +- [Hyperf](https://github.com/hyperf/hyperf) - Excellent coroutine PHP framework +- [Swagger PHP](https://github.com/zircote/swagger-php) - PHP Swagger generator +- [Knife4j](https://gitee.com/xiaoym/knife4j) - Excellent API documentation tool + +--- + +If this project helps you, please give it a ⭐ Star! From 6cf3ed83fe76ff2e140d56e3446f4688ca4469e4 Mon Sep 17 00:00:00 2001 From: tw Date: Thu, 5 Feb 2026 09:48:53 +0800 Subject: [PATCH 04/43] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/Controller/DemoController.php | 7 +++++ example/DTO/Request/DemoDatabaseRequest.php | 29 +++++++++++++++++++ example/DTO/Request/DemoQuery.php | 31 ++++----------------- 3 files changed, 42 insertions(+), 25 deletions(-) create mode 100644 example/DTO/Request/DemoDatabaseRequest.php diff --git a/example/Controller/DemoController.php b/example/Controller/DemoController.php index a9c7683..e8537b5 100644 --- a/example/Controller/DemoController.php +++ b/example/Controller/DemoController.php @@ -35,6 +35,7 @@ use HyperfExample\ApiDocs\DTO\Header\DemoToken; use HyperfExample\ApiDocs\DTO\PageQuery; use HyperfExample\ApiDocs\DTO\Request\DemoBodyRequest; +use HyperfExample\ApiDocs\DTO\Request\DemoDatabaseRequest; use HyperfExample\ApiDocs\DTO\Request\DemoFormData; use HyperfExample\ApiDocs\DTO\Request\DemoQuery; use HyperfExample\ApiDocs\DTO\Response\ActivityResponse; @@ -216,4 +217,10 @@ public function city(): CityResponse dump($city); return $city; } + #[PostMapping(path: 'db')] + public function db(#[RequestBody] #[Valid] DemoDatabaseRequest $request): int + { + dump($request); + return 1; + } } diff --git a/example/DTO/Request/DemoDatabaseRequest.php b/example/DTO/Request/DemoDatabaseRequest.php new file mode 100644 index 0000000..a9006f0 --- /dev/null +++ b/example/DTO/Request/DemoDatabaseRequest.php @@ -0,0 +1,29 @@ +test123456; - } - - /** - * @return array - */ - public function toArray(): array - { - return []; - // TODO: Implement toArray() method. - } + + } From 723c49ad97c3acf054da9903584361be60aa619b Mon Sep 17 00:00:00 2001 From: tw Date: Thu, 5 Feb 2026 10:37:03 +0800 Subject: [PATCH 05/43] =?UTF-8?q?Scalar=20=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E4=B8=AAurl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Swagger/SwaggerUiController.php | 9 ++++++++- src/web/scalar.html | 7 +++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Swagger/SwaggerUiController.php b/src/Swagger/SwaggerUiController.php index f28985d..1a48c83 100644 --- a/src/Swagger/SwaggerUiController.php +++ b/src/Swagger/SwaggerUiController.php @@ -51,9 +51,16 @@ public function rapidoc(): PsrResponseInterface public function scalar(): PsrResponseInterface { // https://github.com/scalar/scalar + $serverNameAll = array_reverse($this->swaggerOpenApi->serverNameAll); + $urls = ''; + foreach ($serverNameAll as $serverName) { + $url = $this->getSwaggerFileUrl($serverName); + $urls .= "{url: '{$url}', title: '{$serverName} server'},"; + } $filePath = $this->docsWebPath . '/scalar.html'; $contents = file_get_contents($filePath); - $contents = str_replace('{{$url}}', BootAppRouteListener::$httpServerName . '.' . $this->swaggerConfig->getFormat(), $contents); + $contents = str_replace('"{{$urls}}"', $urls, $contents); + return $this->response->withAddedHeader('content-type', 'text/html')->withBody(new SwooleStream($contents)); } diff --git a/src/web/scalar.html b/src/web/scalar.html index 15bec06..518ee3a 100644 --- a/src/web/scalar.html +++ b/src/web/scalar.html @@ -17,10 +17,9 @@ From bc17a0fc52860f29ac235b14392b822984f2445d Mon Sep 17 00:00:00 2001 From: tw Date: Wed, 11 Feb 2026 16:15:16 +0800 Subject: [PATCH 06/43] =?UTF-8?q?=E6=94=AF=E6=8C=81json=E6=95=B0=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/Controller/DemoController.php | 11 ++++++++++ example/DTO/Request/DemoQuery.php | 2 +- src/Swagger/GenerateParameters.php | 31 +++++++++++++++++++++------ src/Swagger/SwaggerCommon.php | 2 +- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/example/Controller/DemoController.php b/example/Controller/DemoController.php index e8537b5..a349c33 100644 --- a/example/Controller/DemoController.php +++ b/example/Controller/DemoController.php @@ -65,6 +65,17 @@ public function api(#[RequestQuery] #[Valid] DemoQuery $request): DataType return new DataType(); } + /** + * @param DemoQuery[] $request + */ + #[ApiOperation(summary: '查询测试POST Arr')] + #[PostMapping(path: 'apiArr')] + public function apiArr(#[RequestBody] #[Valid] array $request, Address $address): array + { + dump($request); + return $request; + } + #[ApiOperation(summary: '查询测试POST')] #[PostMapping(path: 'api')] #[ApiHeader(name: 'test', required: true, type: 'string')] diff --git a/example/DTO/Request/DemoQuery.php b/example/DTO/Request/DemoQuery.php index df7d9e0..79bf406 100644 --- a/example/DTO/Request/DemoQuery.php +++ b/example/DTO/Request/DemoQuery.php @@ -22,7 +22,7 @@ class DemoQuery #[ApiModelProperty('类型')] #[In(['a', 'b'])] - private string $type; + public string $type; diff --git a/src/Swagger/GenerateParameters.php b/src/Swagger/GenerateParameters.php index abce312..f941b6f 100644 --- a/src/Swagger/GenerateParameters.php +++ b/src/Swagger/GenerateParameters.php @@ -16,6 +16,7 @@ use Hyperf\DTO\ApiAnnotation; use Hyperf\DTO\DtoConfig; use Hyperf\DTO\Scan\MethodParametersManager; +use Hyperf\DTO\Scan\Property; use Hyperf\DTO\Scan\PropertyManager; use OpenApi\Attributes as OA; use Psr\Container\ContainerInterface; @@ -67,12 +68,19 @@ public function generate(): array continue; } + $methodParameter = $this->methodParametersManager->getMethodParameter($this->controller, $this->action, $paramName); + if ($parameterClassName === 'array' && $methodParameter->isRequestBody()) { + $requestBody = new OA\RequestBody(); + $requestBody->required = true; + $property = $this->methodParametersManager->getProperty($this->controller, $this->action, $paramName); + $requestBody->content = $this->getContent($property->arrClassName ?? '', property: $property); + $result['requestBody'] = $requestBody; + } + if ($this->container->has($parameterClassName)) { - $methodParameter = $this->methodParametersManager->getMethodParameter($this->controller, $this->action, $paramName); if ($methodParameter == null) { continue; } - if ($methodParameter->isRequestBody()) { $requestBody = new OA\RequestBody(); $requestBody->required = true; @@ -177,21 +185,32 @@ public function getParameterArrByClass(string $parameterClassName, string $in): return $parameters; } - protected function getContent(string $className, string $mediaTypeStr = 'application/json'): array + protected function getContent(string $className, string $mediaTypeStr = 'application/json', ?Property $property = null): array { $arr = []; $mediaType = new OA\MediaType(); $mediaType->mediaType = $mediaTypeStr; - $mediaType->schema = $this->getJsonContent($className); + $mediaType->schema = $this->getJsonContent($className, $property); $arr[] = $mediaType; return $arr; } - protected function getJsonContent(string $className): OA\JsonContent + protected function getJsonContent(string $className, ?Property $property = null): OA\JsonContent { $jsonContent = new OA\JsonContent(); $this->swaggerComponents->generateSchemas($className); - $jsonContent->ref = $this->common->getComponentsName($className); + if ($property?->phpSimpleType == 'array') { + $jsonContent->type = 'array'; + $items = new OA\Items(); + if ($property->arrClassName) { + $items->ref = $this->common->getComponentsName($property->arrClassName); + } else { + $items->type = $this->common->getSwaggerType($property->arrSimpleType); + } + $jsonContent->items = $items; + } else { + $jsonContent->ref = $this->common->getComponentsName($className); + } return $jsonContent; } diff --git a/src/Swagger/SwaggerCommon.php b/src/Swagger/SwaggerCommon.php index 6873808..c8f2ba5 100644 --- a/src/Swagger/SwaggerCommon.php +++ b/src/Swagger/SwaggerCommon.php @@ -32,7 +32,7 @@ public function simpleClassNameClear(): void */ public function getSimpleClassName(?string $className): string { - if ($className === null) { + if (empty($className)) { $className = 'Null'; } $className = ltrim($className, '\\'); From c6e11f2bf68fa0bf2b86bf8d7546927176ac7c7d Mon Sep 17 00:00:00 2001 From: tangwei Date: Wed, 11 Feb 2026 21:15:41 +0800 Subject: [PATCH 07/43] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E6=93=8D=E4=BD=9CID-route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Swagger/SwaggerPaths.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Swagger/SwaggerPaths.php b/src/Swagger/SwaggerPaths.php index 6410e32..35c86dc 100644 --- a/src/Swagger/SwaggerPaths.php +++ b/src/Swagger/SwaggerPaths.php @@ -148,6 +148,7 @@ protected function getClassMethodPath(string $fullClassName, string $methodName) */ protected function getOperationId(string $route, string $methods): string { + $route = str_replace(['{','}'], '', $route); $operationId = Str::camel(str_replace('/', '_', $route)); if (empty($operationId)) { $operationId = '-'; From ef386ea1e45c93c12cbe537e299b7f324b630db6 Mon Sep 17 00:00:00 2001 From: tangwei Date: Wed, 11 Feb 2026 21:16:30 +0800 Subject: [PATCH 08/43] =?UTF-8?q?=E4=BF=AE=E5=A4=8DisRequestBody=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Swagger/GenerateParameters.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Swagger/GenerateParameters.php b/src/Swagger/GenerateParameters.php index f941b6f..a7014a5 100644 --- a/src/Swagger/GenerateParameters.php +++ b/src/Swagger/GenerateParameters.php @@ -69,7 +69,7 @@ public function generate(): array } $methodParameter = $this->methodParametersManager->getMethodParameter($this->controller, $this->action, $paramName); - if ($parameterClassName === 'array' && $methodParameter->isRequestBody()) { + if ($parameterClassName === 'array' && $methodParameter?->isRequestBody()) { $requestBody = new OA\RequestBody(); $requestBody->required = true; $property = $this->methodParametersManager->getProperty($this->controller, $this->action, $paramName); From 73fed96cc0a4b7b75dfd9dd083ad8e91ec38c9a0 Mon Sep 17 00:00:00 2001 From: tw Date: Wed, 25 Feb 2026 10:54:26 +0800 Subject: [PATCH 09/43] composer update --- .github/workflows/test.yml | 4 ++-- composer.json | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 184788b..b00426b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,7 +3,7 @@ name: PHPUnit on: [ push, pull_request ] env: - SWOOLE_VERSION: '5.1.4' + SWOOLE_VERSION: '6.1.6' SWOW_VERSION: 'develop' jobs: @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ ubuntu-latest ] - php-version: [ '8.1','8.2','8.3' ] + php-version: [ '8.1','8.2','8.3','8.4' ] engine: [ 'swoole' ] max-parallel: 5 steps: diff --git a/composer.json b/composer.json index dbf5c48..d6111ef 100644 --- a/composer.json +++ b/composer.json @@ -19,11 +19,12 @@ ], "require": { "php": ">=8.1", - "tangwei/dto": "~3.1.0", - "zircote/swagger-php": "^4.8||^5.1" + "tangwei/dto": "~3.2.0", + "zircote/swagger-php": "^5.1||^6.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", + "hyperf/laminas-mime": "^3.0", "mockery/mockery": "^1.0", "phpstan/phpstan": "^1.0", "phpunit/phpunit": ">=7.0", @@ -47,7 +48,7 @@ "config": "Hyperf\\ApiDocs\\ConfigProvider" }, "branch-alias": { - "dev-master": "3.1.x-dev" + "dev-master": "3.2.x-dev" } }, "config": { From 97d256238b6a986adc4cf6378e508a25a49fe0f9 Mon Sep 17 00:00:00 2001 From: tw Date: Thu, 26 Feb 2026 15:43:55 +0800 Subject: [PATCH 10/43] Adaptation PHP-Parser v5 --- src/Ast/ResponseVisitor.php | 2 +- src/Swagger/GenerateProxyClass.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Ast/ResponseVisitor.php b/src/Ast/ResponseVisitor.php index a00875a..9609e45 100644 --- a/src/Ast/ResponseVisitor.php +++ b/src/Ast/ResponseVisitor.php @@ -48,7 +48,7 @@ public function leaveNode(Node $node) } } if ($node instanceof Node\Stmt\Class_) { - $node->name = $this->generateClassName; + $node->name = new Node\Identifier($this->generateClassName); } if ($node instanceof Node\Stmt\Namespace_) { $name = new Node\Name('ApiDocs\\Proxy'); diff --git a/src/Swagger/GenerateProxyClass.php b/src/Swagger/GenerateProxyClass.php index 39591cb..8433347 100644 --- a/src/Swagger/GenerateProxyClass.php +++ b/src/Swagger/GenerateProxyClass.php @@ -129,7 +129,7 @@ protected function putContents($generateNamespaceClassName, $content): void protected function phpParser(object $generateClass, $filePath, $propertyArr): array { $code = file_get_contents($filePath); - $parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); + $parser = (new ParserFactory())->createForNewestSupportedVersion(); $ast = $parser->parse($code); $simpleClassName = $this->swaggerCommon->getSimpleClassName($generateClass::class); From 87f6c65d95c5b9f31f7af29ccd7bf031f1bde103 Mon Sep 17 00:00:00 2001 From: tw Date: Sat, 28 Feb 2026 11:54:58 +0800 Subject: [PATCH 11/43] =?UTF-8?q?=E6=9B=B4=E6=96=B0swagger=E8=B5=84?= =?UTF-8?q?=E6=BA=905.32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- publish/api_docs.php | 2 +- src/web/swagger.html | 90 +++++++++++++++++--------------------------- 2 files changed, 35 insertions(+), 57 deletions(-) diff --git a/publish/api_docs.php b/publish/api_docs.php index 7439ff7..7c61452 100644 --- a/publish/api_docs.php +++ b/publish/api_docs.php @@ -52,7 +52,7 @@ | 设置swagger资源路径,cdn资源 |-------------------------------------------------------------------------- */ - 'prefix_swagger_resources' => 'https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.27.1', + 'prefix_swagger_resources' => 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.0', /* |-------------------------------------------------------------------------- diff --git a/src/web/swagger.html b/src/web/swagger.html index acc49e5..4cad069 100644 --- a/src/web/swagger.html +++ b/src/web/swagger.html @@ -1,60 +1,38 @@ - - - - Swagger UI - - - - - - -
- - - - - + window.ui = ui; + }; + + From 298593e0be07cabc7c908c0aea4510fcef4522ae Mon Sep 17 00:00:00 2001 From: tw Date: Thu, 26 Mar 2026 10:07:45 +0800 Subject: [PATCH 12/43] add test --- tests/AnnotationTest.php | 239 ++++++++++++++++++++++++++++++ tests/ApiDocsExceptionTest.php | 43 ++++++ tests/Request/DemoBodyRequest.php | 5 + tests/SwaggerCommonTest.php | 130 +++++++++++++--- tests/SwaggerConfigTest.php | 211 ++++++++++++++++++++++++++ tests/SwaggerPathsTest.php | 139 +++++++++++++++++ tests/SwaggerSchemasTest.php | 127 +++++++++++----- tests/SwaggerUiControllerTest.php | 187 +++++++++++++++++++++++ 8 files changed, 1030 insertions(+), 51 deletions(-) create mode 100644 tests/AnnotationTest.php create mode 100644 tests/ApiDocsExceptionTest.php create mode 100644 tests/SwaggerConfigTest.php create mode 100644 tests/SwaggerPathsTest.php create mode 100644 tests/SwaggerUiControllerTest.php diff --git a/tests/AnnotationTest.php b/tests/AnnotationTest.php new file mode 100644 index 0000000..0b2b825 --- /dev/null +++ b/tests/AnnotationTest.php @@ -0,0 +1,239 @@ +assertEquals(['tag1', 'tag2'], $api->tags); + $this->assertEquals('Test description', $api->description); + $this->assertEquals(1, $api->position); + $this->assertFalse($api->hidden); + } + + public function testApiAnnotationWithHidden(): void + { + $api = new Api(tags: null, description: '', position: 0, hidden: true); + + $this->assertTrue($api->hidden); + } + + public function testApiOperationAnnotation(): void + { + $apiOperation = new ApiOperation( + summary: 'Test summary', + description: 'Test description', + hidden: false, + security: true, + deprecated: false + ); + + $this->assertEquals('Test summary', $apiOperation->summary); + $this->assertEquals('Test description', $apiOperation->description); + $this->assertFalse($apiOperation->hidden); + $this->assertTrue($apiOperation->security); + $this->assertFalse($apiOperation->deprecated); + } + + public function testApiOperationWithHidden(): void + { + $apiOperation = new ApiOperation(hidden: true); + + $this->assertTrue($apiOperation->hidden); + } + + public function testApiModelAnnotation(): void + { + $apiModel = new ApiModel('Test model description'); + + $this->assertEquals('Test model description', $apiModel->value); + } + + public function testApiModelPropertyAnnotation(): void + { + $apiModelProperty = new ApiModelProperty( + value: 'Test property', + example: 'example_value', + hidden: false, + required: true, + simpleType: null + ); + + $this->assertEquals('Test property', $apiModelProperty->value); + $this->assertEquals('example_value', $apiModelProperty->example); + $this->assertFalse($apiModelProperty->hidden); + $this->assertTrue($apiModelProperty->required); + } + + public function testApiModelPropertyWithPhpTypeEnum(): void + { + $phpType = PhpType::STRING; + $apiModelProperty = new ApiModelProperty( + value: 'Test property', + simpleType: $phpType + ); + + $this->assertEquals('string', $apiModelProperty->phpType); + } + + public function testApiResponseAnnotation(): void + { + $apiResponse = new ApiResponse( + returnType: null, + response: '200', + description: 'Success' + ); + + $this->assertEquals('200', $apiResponse->response); + $this->assertEquals('Success', $apiResponse->description); + } + + public function testApiResponseWithNullReturnType(): void + { + $apiResponse = new ApiResponse( + returnType: null, + response: '200', + description: 'Success' + ); + + $this->assertNull($apiResponse->returnType); + } + + public function testApiResponseWithEmptyArrayReturnType(): void + { + $apiResponse = new ApiResponse( + returnType: [], + response: '200', + description: 'Success' + ); + + $this->assertEquals('array', $apiResponse->returnType); + } + + public function testApiResponseThrowsExceptionForUnsupportedType(): void + { + $this->expectException(ApiDocsException::class); + $this->expectExceptionMessage('ApiResponse: Unsupported data type'); + + new ApiResponse( + returnType: 'unsupported_string_type', + response: '200', + description: 'Success' + ); + } + + public function testApiHeaderAnnotation(): void + { + $apiHeader = new ApiHeader( + name: 'Authorization', + required: true, + type: 'string', + default: Generator::UNDEFINED, + description: 'Bearer token', + format: Generator::UNDEFINED, + hidden: false + ); + + $this->assertEquals('Authorization', $apiHeader->name); + $this->assertTrue($apiHeader->required); + $this->assertEquals('string', $apiHeader->type); + $this->assertEquals('Bearer token', $apiHeader->description); + $this->assertFalse($apiHeader->hidden); + $this->assertEquals('header', $apiHeader->getIn()); + } + + public function testApiFormDataAnnotation(): void + { + $apiFormData = new ApiFormData( + name: 'file', + required: true, + type: 'string', + default: Generator::UNDEFINED, + description: 'Upload file', + format: Generator::UNDEFINED, + hidden: false + ); + + $this->assertEquals('file', $apiFormData->name); + $this->assertTrue($apiFormData->required); + $this->assertEquals('formData', $apiFormData->getIn()); + } + + public function testApiSecurityAnnotation(): void + { + $apiSecurity = new ApiSecurity( + name: 'BearerAuth', + value: [] + ); + + $this->assertEquals('BearerAuth', $apiSecurity->name); + $this->assertEquals([], $apiSecurity->value); + } + + public function testApiVariableAnnotation(): void + { + $apiVariable = new ApiVariable(value: 'test_value'); + + $this->assertEquals('test_value', $apiVariable->value); + } + + public function testBaseParamGetIn(): void + { + $apiHeader = new ApiHeader(name: 'test'); + $this->assertEquals('header', $apiHeader->getIn()); + + $apiFormData = new ApiFormData(name: 'test'); + $this->assertEquals('formData', $apiFormData->getIn()); + } + + public function testApiAnnotationAttributeTargets(): void + { + $apiReflection = new ReflectionClass(Api::class); + $attributes = $apiReflection->getAttributes(); + + $foundAttribute = false; + foreach ($attributes as $attribute) { + if ($attribute->getName() === 'Attribute') { + $foundAttribute = true; + $args = $attribute->getArguments(); + $this->assertContains(Attribute::TARGET_CLASS, $args); + } + } + $this->assertTrue($foundAttribute); + } + + public function testPhpTypeEnumValues(): void + { + $this->assertEquals('bool', PhpType::BOOL->getValue()); + $this->assertEquals('float', PhpType::FLOAT->getValue()); + $this->assertEquals('string', PhpType::STRING->getValue()); + $this->assertEquals('array', PhpType::ARRAY->getValue()); + $this->assertEquals('object', PhpType::OBJECT->getValue()); + $this->assertEquals('int', PhpType::INT->getValue()); + } +} diff --git a/tests/ApiDocsExceptionTest.php b/tests/ApiDocsExceptionTest.php new file mode 100644 index 0000000..4d67436 --- /dev/null +++ b/tests/ApiDocsExceptionTest.php @@ -0,0 +1,43 @@ +assertEquals('File not found: /path/to/file.json', $exception->getMessage()); + } + + public function testDirectoryCreationFailedException(): void + { + $exception = ApiDocsException::directoryCreationFailed('/path/to/dir'); + + $this->assertEquals('Failed to create directory: /path/to/dir', $exception->getMessage()); + } + + public function testInvalidClassException(): void + { + $exception = ApiDocsException::invalidClass('NonExistentClass'); + + $this->assertEquals('Invalid class: NonExistentClass', $exception->getMessage()); + } + + public function testTypeResolutionFailedException(): void + { + $exception = ApiDocsException::typeResolutionFailed('User', 'name'); + + $this->assertEquals('Type resolution failed for field: User::name', $exception->getMessage()); + } +} diff --git a/tests/Request/DemoBodyRequest.php b/tests/Request/DemoBodyRequest.php index 6118d6e..fe0ff92 100644 --- a/tests/Request/DemoBodyRequest.php +++ b/tests/Request/DemoBodyRequest.php @@ -47,6 +47,11 @@ public function setBo(bool $bo): void $this->bo = $bo; } + public function getBo(): bool + { + return $this->bo; + } + } diff --git a/tests/SwaggerCommonTest.php b/tests/SwaggerCommonTest.php index b2fc610..438dd6d 100644 --- a/tests/SwaggerCommonTest.php +++ b/tests/SwaggerCommonTest.php @@ -11,6 +11,7 @@ use Mockery as m; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; +use ReflectionClass; /** * @internal @@ -24,49 +25,144 @@ protected function tearDown(): void AnnotationCollector::clear(); } - public function testGetSimpleClassName() + public function testGetSimpleClassName(): void { $container = m::mock(ContainerInterface::class); $container->shouldReceive('has')->andReturn(true); $container->shouldReceive('get')->with(MethodDefinitionCollectorInterface::class)->andReturn(new MethodDefinitionCollector()); $swaggerCommon = new SwaggerCommon(); + $simpleClassName = $swaggerCommon->getSimpleClassName('Hyperf\ApiDocs\Address'); - $this->assertEquals($simpleClassName, 'Address'); + $this->assertEquals('Address', $simpleClassName); + $simpleClassName = $swaggerCommon->getSimpleClassName('\ApiDocs\Address_1'); - $this->assertEquals($simpleClassName, 'Address_1'); + $this->assertEquals('Address_1', $simpleClassName); + $simpleClassName = $swaggerCommon->getSimpleClassName('\ApiDocs\Address'); - $this->assertEquals($simpleClassName, 'Address_2'); + $this->assertEquals('Address_2', $simpleClassName); + $simpleClassName = $swaggerCommon->getSimpleClassName('Address'); - $this->assertEquals($simpleClassName, 'Address_3'); + $this->assertEquals('Address_3', $simpleClassName); + $simpleClassName = $swaggerCommon->getSimpleClassName('\Hyperf\ApiDocs\Address'); - $this->assertEquals($simpleClassName, 'Address'); + $this->assertEquals('Address', $simpleClassName); $simpleClassName = $swaggerCommon->getSimpleClassName('Hyperf\ApiDocs\City'); - $this->assertEquals($simpleClassName, 'City'); + $this->assertEquals('City', $simpleClassName); + $simpleClassName = $swaggerCommon->getSimpleClassName('\ApiDocs\City'); - $this->assertEquals($simpleClassName, 'City_1'); + $this->assertEquals('City_1', $simpleClassName); + $simpleClassName = $swaggerCommon->getSimpleClassName('\ApiDocs\City_1'); - $this->assertEquals($simpleClassName, 'City_1_1'); + $this->assertEquals('City_1_1', $simpleClassName); + $simpleClassName = $swaggerCommon->getSimpleClassName('\ApiDocs\City_1_1'); - $this->assertEquals($simpleClassName, 'City_1_1_1'); + $this->assertEquals('City_1_1_1', $simpleClassName); $simpleClassName = $swaggerCommon->getSimpleClassName('\ApiDocs\DTO\City1'); - $this->assertEquals($simpleClassName, 'City1'); + $this->assertEquals('City1', $simpleClassName); + $simpleClassName = $swaggerCommon->getSimpleClassName('\ApiDocs\City'); - $this->assertEquals($simpleClassName, 'City_1'); + $this->assertEquals('City_1', $simpleClassName); + $simpleClassName = $swaggerCommon->getSimpleClassName('\City'); - $this->assertEquals($simpleClassName, 'City_2'); + $this->assertEquals('City_2', $simpleClassName); $simpleClassName = $swaggerCommon->getSimpleClassName('\ApiDocs\DTO\City_2'); - $this->assertEquals($simpleClassName, 'City_2_1'); + $this->assertEquals('City_2_1', $simpleClassName); $simpleClassName = $swaggerCommon->getSimpleClassName('int'); - $this->assertEquals($simpleClassName, 'Int'); + $this->assertEquals('Int', $simpleClassName); + $simpleClassName = $swaggerCommon->getSimpleClassName('Int'); - $this->assertEquals($simpleClassName, 'Int_1'); + $this->assertEquals('Int_1', $simpleClassName); + $simpleClassName = $swaggerCommon->getSimpleClassName('\Int'); - $this->assertEquals($simpleClassName, 'Int_1'); + $this->assertEquals('Int_1', $simpleClassName); $swaggerCommon->simpleClassNameClear(); } + + public function testGetSimpleClassNameWithEmptyValue(): void + { + $swaggerCommon = new SwaggerCommon(); + $simpleClassName = $swaggerCommon->getSimpleClassName(''); + $this->assertEquals('Null', $simpleClassName); + } + + public function testGetSimpleClassNameWithNullValue(): void + { + $swaggerCommon = new SwaggerCommon(); + $simpleClassName = $swaggerCommon->getSimpleClassName(null); + $this->assertEquals('Null', $simpleClassName); + } + + public function testGetSwaggerType(): void + { + $swaggerCommon = new SwaggerCommon(); + + $this->assertEquals('integer', $swaggerCommon->getSwaggerType('int')); + $this->assertEquals('integer', $swaggerCommon->getSwaggerType('integer')); + $this->assertEquals('boolean', $swaggerCommon->getSwaggerType('boolean')); + $this->assertEquals('boolean', $swaggerCommon->getSwaggerType('bool')); + $this->assertEquals('number', $swaggerCommon->getSwaggerType('double')); + $this->assertEquals('number', $swaggerCommon->getSwaggerType('float')); + $this->assertEquals('number', $swaggerCommon->getSwaggerType('number')); + $this->assertEquals('array', $swaggerCommon->getSwaggerType('array')); + $this->assertEquals('object', $swaggerCommon->getSwaggerType('object')); + $this->assertEquals('string', $swaggerCommon->getSwaggerType('string')); + $this->assertEquals('null', $swaggerCommon->getSwaggerType('unknown')); + } + + public function testGetSimpleType2SwaggerType(): void + { + $swaggerCommon = new SwaggerCommon(); + + $this->assertEquals('integer', $swaggerCommon->getSimpleType2SwaggerType('int')); + $this->assertEquals('integer', $swaggerCommon->getSimpleType2SwaggerType('integer')); + $this->assertEquals('boolean', $swaggerCommon->getSimpleType2SwaggerType('boolean')); + $this->assertEquals('boolean', $swaggerCommon->getSimpleType2SwaggerType('bool')); + $this->assertEquals('number', $swaggerCommon->getSimpleType2SwaggerType('double')); + $this->assertEquals('number', $swaggerCommon->getSimpleType2SwaggerType('float')); + $this->assertEquals('string', $swaggerCommon->getSimpleType2SwaggerType('string')); + $this->assertEquals('string', $swaggerCommon->getSimpleType2SwaggerType('mixed')); + $this->assertNull($swaggerCommon->getSimpleType2SwaggerType('array')); + $this->assertNull($swaggerCommon->getSimpleType2SwaggerType('object')); + } + + public function testGetComponentsName(): void + { + $swaggerCommon = new SwaggerCommon(); + $swaggerCommon->getSimpleClassName('Hyperf\ApiDocs\Request\User'); + $componentName = $swaggerCommon->getComponentsName('Hyperf\ApiDocs\Request\User'); + $this->assertEquals('#/components/schemas/User', $componentName); + } + + public function testSimpleClassNameClear(): void + { + $swaggerCommon = new SwaggerCommon(); + $swaggerCommon->getSimpleClassName('Test\Class\Name'); + $swaggerCommon->simpleClassNameClear(); + + $reflection = new ReflectionClass($swaggerCommon); + $property = $reflection->getProperty('classNameCache'); + $property->setAccessible(true); + $this->assertEmpty($property->getValue($swaggerCommon)); + } + + public function testInstanceIsolation(): void + { + $swaggerCommon1 = new SwaggerCommon(); + $swaggerCommon2 = new SwaggerCommon(); + + $swaggerCommon1->getSimpleClassName('Hyperf\ApiDocs\TestClass'); + $this->assertEquals('TestClass', $swaggerCommon1->getSimpleClassName('Hyperf\ApiDocs\TestClass')); + + $this->assertEquals('TestClass', $swaggerCommon2->getSimpleClassName('Hyperf\ApiDocs\TestClass')); + + $swaggerCommon1->simpleClassNameClear(); + + $this->assertEquals('TestClass', $swaggerCommon1->getSimpleClassName('Hyperf\ApiDocs\TestClass')); + $this->assertEquals('TestClass', $swaggerCommon2->getSimpleClassName('Hyperf\ApiDocs\TestClass')); + } } diff --git a/tests/SwaggerConfigTest.php b/tests/SwaggerConfigTest.php new file mode 100644 index 0000000..0aa23de --- /dev/null +++ b/tests/SwaggerConfigTest.php @@ -0,0 +1,211 @@ +shouldReceive('get')->with('api_docs', null)->andReturn([]); + + $config = new SwaggerConfig($configInterface); + + $config->setPrefixUrl('/swagger/'); + $this->assertEquals('/swagger', $config->getPrefixUrl()); + + $config->setPrefixUrl('api-docs'); + $this->assertEquals('/api-docs', $config->getPrefixUrl()); + + $config->setPrefixUrl('///multiple///slashes///'); + $this->assertEquals('/multiple///slashes', $config->getPrefixUrl()); + } + + public function testIsEnableDefaultsToFalse(): void + { + $configInterface = m::mock(ConfigInterface::class); + $configInterface->shouldReceive('get')->with('api_docs', null)->andReturn([]); + + $config = new SwaggerConfig($configInterface); + + $this->assertFalse($config->isEnable()); + } + + public function testGetOutputDir(): void + { + $configInterface = m::mock(ConfigInterface::class); + $configInterface->shouldReceive('get')->with('api_docs', null)->andReturn(['output_dir' => '/custom/output']); + + $config = new SwaggerConfig($configInterface); + + $this->assertEquals('/custom/output', $config->getOutputDir()); + } + + public function testGetProxyDirFallsBackToDefault(): void + { + $configInterface = m::mock(ConfigInterface::class); + $configInterface->shouldReceive('get')->with('api_docs', null)->andReturn([]); + + $config = new SwaggerConfig($configInterface); + + $expected = BASE_PATH . '/runtime/container/proxy/'; + $this->assertEquals($expected, $config->getProxyDir()); + } + + public function testSetProxyDirNormalizesPath(): void + { + $configInterface = m::mock(ConfigInterface::class); + $configInterface->shouldReceive('get')->with('api_docs', null)->andReturn([]); + + $config = new SwaggerConfig($configInterface); + + $config->setProxyDir('/custom/proxy/'); + $this->assertEquals('/custom/proxy/', $config->getProxyDir()); + } + + public function testGetPrefixUrlFallsBackToDefault(): void + { + $configInterface = m::mock(ConfigInterface::class); + $configInterface->shouldReceive('get')->with('api_docs', null)->andReturn([]); + + $config = new SwaggerConfig($configInterface); + + $this->assertEquals('swagger', $config->getPrefixUrl()); + } + + public function testIsValidationCustomAttributesDefaultsToFalse(): void + { + $configInterface = m::mock(ConfigInterface::class); + $configInterface->shouldReceive('get')->with('api_docs', null)->andReturn([]); + + $config = new SwaggerConfig($configInterface); + + $this->assertFalse($config->isValidationCustomAttributes()); + } + + public function testGetResponsesDefaultsToEmptyArray(): void + { + $configInterface = m::mock(ConfigInterface::class); + $configInterface->shouldReceive('get')->with('api_docs', null)->andReturn([]); + + $config = new SwaggerConfig($configInterface); + + $this->assertEquals([], $config->getResponses()); + } + + public function testGetSwaggerDefaultsToEmptyArray(): void + { + $configInterface = m::mock(ConfigInterface::class); + $configInterface->shouldReceive('get')->with('api_docs', null)->andReturn([]); + + $config = new SwaggerConfig($configInterface); + + $this->assertEquals([], $config->getSwagger()); + } + + public function testGetResponsesCodeDefaultsTo200(): void + { + $configInterface = m::mock(ConfigInterface::class); + $configInterface->shouldReceive('get')->with('api_docs', null)->andReturn([]); + + $config = new SwaggerConfig($configInterface); + + $this->assertEquals('200', $config->getResponsesCode()); + } + + public function testGetFormatDefaultsToJson(): void + { + $configInterface = m::mock(ConfigInterface::class); + $configInterface->shouldReceive('get')->with('api_docs', null)->andReturn([]); + + $config = new SwaggerConfig($configInterface); + + $this->assertEquals('json', $config->getFormat()); + } + + public function testGetFormatReturnsYamlWhenConfigured(): void + { + $configInterface = m::mock(ConfigInterface::class); + $configInterface->shouldReceive('get')->with('api_docs', null)->andReturn(['format' => 'yaml']); + + $config = new SwaggerConfig($configInterface); + + $this->assertEquals('yaml', $config->getFormat()); + } + + public function testGetFormatAlwaysReturnsYamlForUnknownFormat(): void + { + $configInterface = m::mock(ConfigInterface::class); + $configInterface->shouldReceive('get')->with('api_docs', null)->andReturn(['format' => 'xml']); + + $config = new SwaggerConfig($configInterface); + + $this->assertEquals('yaml', $config->getFormat()); + } + + public function testGetPrefixSwaggerResources(): void + { + $configInterface = m::mock(ConfigInterface::class); + $configInterface->shouldReceive('get')->with('api_docs', null)->andReturn([]); + + $config = new SwaggerConfig($configInterface); + + $expected = 'https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.27.1'; + $this->assertEquals($expected, $config->getPrefixSwaggerResources()); + } + + public function testGetGlobalReturnResponsesClassDefaultsToEmpty(): void + { + $configInterface = m::mock(ConfigInterface::class); + $configInterface->shouldReceive('get')->with('api_docs', null)->andReturn([]); + + $config = new SwaggerConfig($configInterface); + + $this->assertEquals('', $config->getGlobalReturnResponsesClass()); + } + + public function testFullConfiguration(): void + { + $configData = [ + 'enable' => true, + 'output_dir' => '/var/swagger', + 'proxy_dir' => '/var/proxy', + 'prefix_url' => '/api-docs', + 'validation_custom_attributes' => true, + 'responses' => [ + ['response' => 401, 'description' => 'Unauthorized'], + ], + 'swagger' => [ + 'info' => [ + 'title' => 'Test API', + 'version' => '1.0', + ], + ], + ]; + + $configInterface = m::mock(ConfigInterface::class); + $configInterface->shouldReceive('get')->with('api_docs', null)->andReturn($configData); + + $config = new SwaggerConfig($configInterface); + + $this->assertTrue($config->isEnable()); + $this->assertEquals('/var/swagger', $config->getOutputDir()); + $this->assertEquals('/var/proxy/', $config->getProxyDir()); + $this->assertEquals('/api-docs', $config->getPrefixUrl()); + $this->assertTrue($config->isValidationCustomAttributes()); + $this->assertCount(1, $config->getResponses()); + $this->assertEquals('Test API', $config->getSwagger()['info']['title']); + } +} \ No newline at end of file diff --git a/tests/SwaggerPathsTest.php b/tests/SwaggerPathsTest.php new file mode 100644 index 0000000..effd5dd --- /dev/null +++ b/tests/SwaggerPathsTest.php @@ -0,0 +1,139 @@ +getProperty('operationIds'); + + $this->assertIsArray($property->getValue($swaggerPaths)); + } + + public function testOperationIdInstanceIsolation(): void + { + $container = m::mock(ContainerInterface::class); + $config = m::mock(ConfigInterface::class); + $logger = m::mock(StdoutLoggerInterface::class); + $swaggerOpenApi = m::mock(SwaggerOpenApi::class); + $swaggerCommon = new SwaggerCommon(); + + $swaggerPaths1 = new SwaggerPaths( + 'http', + $config, + $logger, + $swaggerOpenApi, + $swaggerCommon + ); + + $swaggerPaths2 = new SwaggerPaths( + 'http2', + $config, + $logger, + $swaggerOpenApi, + $swaggerCommon + ); + + $reflection1 = new ReflectionClass($swaggerPaths1); + $property1 = $reflection1->getProperty('operationIds'); + $property1->setAccessible(true); + + $reflection2 = new ReflectionClass($swaggerPaths2); + $property2 = $reflection2->getProperty('operationIds'); + $property2->setAccessible(true); + + $property1->setValue($swaggerPaths1, ['test' => true]); + + $this->assertArrayNotHasKey('test', $property2->getValue($swaggerPaths2)); + } + + public function testGetClassMethodPath(): void + { + $container = m::mock(ContainerInterface::class); + $config = m::mock(ConfigInterface::class); + $logger = m::mock(StdoutLoggerInterface::class); + $swaggerOpenApi = m::mock(SwaggerOpenApi::class); + $swaggerCommon = new SwaggerCommon(); + + $swaggerPaths = new SwaggerPaths( + 'http', + $config, + $logger, + $swaggerOpenApi, + $swaggerCommon + ); + + $method = new ReflectionMethod($swaggerPaths, 'getClassMethodPath'); + $method->setAccessible(true); + + $result = $method->invoke($swaggerPaths, 'Hyperf\ApiDocs\Controller\UserController', 'getUser'); + + $this->assertStringContainsString('H.A.C.UserController', $result); + $this->assertStringContainsString('getUser', $result); + } + + public function testGetClassMethodPathWithShortNamespace(): void + { + $container = m::mock(ContainerInterface::class); + $config = m::mock(ConfigInterface::class); + $logger = m::mock(StdoutLoggerInterface::class); + $swaggerOpenApi = m::mock(SwaggerOpenApi::class); + $swaggerCommon = new SwaggerCommon(); + + $swaggerPaths = new SwaggerPaths( + 'http', + $config, + $logger, + $swaggerOpenApi, + $swaggerCommon + ); + + $method = new ReflectionMethod($swaggerPaths, 'getClassMethodPath'); + $method->setAccessible(true); + + $result = $method->invoke($swaggerPaths, 'Controller\UserController', 'index'); + + $this->assertStringContainsString('Controller', $result); + $this->assertStringContainsString('index', $result); + } +} diff --git a/tests/SwaggerSchemasTest.php b/tests/SwaggerSchemasTest.php index e71333e..57d9742 100644 --- a/tests/SwaggerSchemasTest.php +++ b/tests/SwaggerSchemasTest.php @@ -13,6 +13,7 @@ use Hyperf\DTO\Scan\PropertyManager; use HyperfTest\ApiDocs\Request\Address; use HyperfTest\ApiDocs\Request\DemoBodyRequest; +use HyperfTest\ApiDocs\Request\User; use Mockery as m; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -29,64 +30,122 @@ protected function tearDown(): void AnnotationCollector::clear(); } - public function testSchemas() + public function testSchemas(): void { $container = m::mock(ContainerInterface::class); $container->shouldReceive('has')->andReturn(true); $classname = DemoBodyRequest::class; - // dto $container->shouldReceive('get')->with(MethodDefinitionCollectorInterface::class)->andReturn(new MethodDefinitionCollector()); $swaggerCommon = new SwaggerCommon(); $swaggerComponents = new SwaggerComponents($swaggerCommon, new PropertyManager($swaggerCommon, new PropertyEnum()), null); $schemas = $swaggerComponents->generateSchemas($classname); $properties = $schemas->properties; - $this->assertEquals($properties[0]->property, 'int'); - $this->assertEquals($properties[0]->type, 'integer'); - $this->assertEquals($properties[0]->default, '12345'); - $this->assertEquals($properties[1]->property, 'str'); - $this->assertEquals($properties[1]->type, 'string'); - $this->assertEquals($properties[1]->default, 'hi'); + //dump($properties);die(); - $this->assertEquals($properties[2]->property, 'bo'); - $this->assertEquals($properties[2]->type, 'boolean'); - $this->assertEquals($properties[2]->default, true); + $this->assertEquals('int', $properties[0]->property); + $this->assertEquals('integer', $properties[0]->type); + $this->assertEquals('12345', $properties[0]->default); - $this->assertEquals($properties[3]->property, 'address'); - $this->assertEquals($properties[3]->ref, '#/components/schemas/Address'); - $this->assertEquals($properties[3]->default, true); + $this->assertEquals('str', $properties[1]->property); + $this->assertEquals('string', $properties[1]->type); + $this->assertEquals('hi', $properties[1]->default); - $this->assertEquals($properties[4]->property, 'addressList1'); - $this->assertEquals($properties[4]->type, 'array'); - $this->assertEquals($properties[4]->items->ref, '#/components/schemas/Address'); + $this->assertEquals('bo', $properties[2]->property); + $this->assertEquals('boolean', $properties[2]->type); - $this->assertEquals($properties[5]->property, 'addressList2'); - $this->assertEquals($properties[5]->type, 'array'); - $this->assertEquals($properties[5]->items->ref, '#/components/schemas/Address'); + $this->assertEquals('address', $properties[3]->property); + $this->assertEquals('#/components/schemas/Address', $properties[3]->ref); - $this->assertEquals($properties[6]->property, 'addressList3'); - $this->assertEquals($properties[6]->type, 'array'); - $this->assertEquals($properties[6]->items->ref, '#/components/schemas/Address'); + $this->assertEquals('addressList1', $properties[4]->property); + $this->assertEquals('array', $properties[4]->type); + $this->assertEquals('#/components/schemas/Address', $properties[4]->items->ref); - $this->assertEquals($properties[7]->property, 'intList1'); - $this->assertEquals($properties[7]->type, 'array'); - $this->assertEquals($properties[7]->items->type, 'integer'); + $this->assertEquals('addressList2', $properties[5]->property); + $this->assertEquals('array', $properties[5]->type); + $this->assertEquals('#/components/schemas/Address', $properties[5]->items->ref); - $this->assertEquals($properties[8]->property, 'intList2'); - $this->assertEquals($properties[8]->type, 'array'); - $this->assertEquals($properties[8]->items->type, 'integer'); + $this->assertEquals('addressList3', $properties[6]->property); + $this->assertEquals('array', $properties[6]->type); + $this->assertEquals('#/components/schemas/Address', $properties[6]->items->ref); + + $this->assertEquals('intList1', $properties[7]->property); + $this->assertEquals('array', $properties[7]->type); + $this->assertEquals('integer', $properties[7]->items->type); + + $this->assertEquals('intList2', $properties[8]->property); + $this->assertEquals('array', $properties[8]->type); + $this->assertEquals('integer', $properties[8]->items->type); $addressSchemas = $swaggerComponents->generateSchemas(Address::class); - // address class $addressProperties = $addressSchemas->properties; - $this->assertEquals($addressProperties[0]->property, 'name'); - $this->assertEquals($addressProperties[0]->type, 'string'); + $this->assertEquals('name', $addressProperties[0]->property); + $this->assertEquals('string', $addressProperties[0]->type); - $this->assertEquals($addressProperties[1]->property, 'user'); - $this->assertEquals($addressProperties[1]->ref, '#/components/schemas/User'); + $this->assertEquals('user', $addressProperties[1]->property); + $this->assertEquals('#/components/schemas/User', $addressProperties[1]->ref); $swaggerCommon->simpleClassNameClear(); } + + public function testGetPropertiesWithEmptyClassName(): void + { + $swaggerCommon = new SwaggerCommon(); + $swaggerComponents = new SwaggerComponents($swaggerCommon, new PropertyManager($swaggerCommon, new PropertyEnum()), null); + $result = $swaggerComponents->getProperties(''); + $this->assertEquals(['propertyArr' => [], 'requiredArr' => []], $result); + } + + public function testSchemasCaching(): void + { + $swaggerCommon = new SwaggerCommon(); + $swaggerComponents = new SwaggerComponents($swaggerCommon, new PropertyManager($swaggerCommon, new PropertyEnum()), null); + + $schema1 = $swaggerComponents->generateSchemas(User::class); + $schema2 = $swaggerComponents->generateSchemas(User::class); + + $this->assertSame($schema1, $schema2); + } + + public function testSchemasInstanceIsolation(): void + { + $swaggerCommon1 = new SwaggerCommon(); + $swaggerComponents1 = new SwaggerComponents($swaggerCommon1, new PropertyManager($swaggerCommon1, new PropertyEnum()), null); + + $swaggerCommon2 = new SwaggerCommon(); + $swaggerComponents2 = new SwaggerComponents($swaggerCommon2, new PropertyManager($swaggerCommon2, new PropertyEnum()), null); + + $schema1 = $swaggerComponents1->generateSchemas(User::class); + $schema2 = $swaggerComponents2->generateSchemas(User::class); + + $this->assertNotSame($schema1, $schema2); + $this->assertEquals($schema1->schema, $schema2->schema); + } + + public function testGetAndSetSchemas(): void + { + $swaggerCommon = new SwaggerCommon(); + $swaggerComponents = new SwaggerComponents($swaggerCommon, new PropertyManager($swaggerCommon, new PropertyEnum()), null); + + $schemas = $swaggerComponents->getSchemas(); + $this->assertIsArray($schemas); + + $swaggerComponents->setSchemas(['TestSchema' => 'value']); + $this->assertEquals(['TestSchema' => 'value'], $swaggerComponents->getSchemas()); + } + + public function testUserSchemaProperties(): void + { + $swaggerCommon = new SwaggerCommon(); + $swaggerComponents = new SwaggerComponents($swaggerCommon, new PropertyManager($swaggerCommon, new PropertyEnum()), null); + + $schemas = $swaggerComponents->generateSchemas(User::class); + $properties = $schemas->properties; + + $propertyNames = array_map(fn ($p) => $p->property, $properties); + $this->assertContains('name', $propertyNames); + $this->assertContains('age', $propertyNames); + } } diff --git a/tests/SwaggerUiControllerTest.php b/tests/SwaggerUiControllerTest.php new file mode 100644 index 0000000..5b9f320 --- /dev/null +++ b/tests/SwaggerUiControllerTest.php @@ -0,0 +1,187 @@ +shouldReceive('getPrefixUrl')->andReturn('/swagger'); + $config->shouldReceive('getFormat')->andReturn('json'); + + $response = m::mock(ResponseInterface::class); + + $controller = new SwaggerUiControllerTestable($config, $response, m::mock(SwaggerOpenApi::class)); + + $reflection = new ReflectionMethod($controller, 'sanitizeFilePath'); + + $result = $reflection->invoke($controller, '../../../etc/passwd'); + $this->assertStringNotContainsString('..', $result); + } + + public function testSanitizeFilePathRemovesBackslashes(): void + { + $config = m::mock(SwaggerConfig::class); + $config->shouldReceive('getPrefixUrl')->andReturn('/swagger'); + $config->shouldReceive('getFormat')->andReturn('json'); + + $response = m::mock(ResponseInterface::class); + + $controller = new SwaggerUiControllerTestable($config, $response, m::mock(SwaggerOpenApi::class)); + + $reflection = new ReflectionMethod($controller, 'sanitizeFilePath'); + + $result = $reflection->invoke($controller, '..\..\windows\system32'); + $this->assertStringNotContainsString('..', $result); + $this->assertStringNotContainsString('\\', $result); + } + + public function testSanitizeFilePathRemovesNullBytes(): void + { + $config = m::mock(SwaggerConfig::class); + $config->shouldReceive('getPrefixUrl')->andReturn('/swagger'); + $config->shouldReceive('getFormat')->andReturn('json'); + + $response = m::mock(ResponseInterface::class); + + $controller = new SwaggerUiControllerTestable($config, $response, m::mock(SwaggerOpenApi::class)); + + $reflection = new ReflectionMethod($controller, 'sanitizeFilePath'); + + $result = $reflection->invoke($controller, "file\0name"); + $this->assertStringNotContainsString("\0", $result); + } + + public function testSanitizeFilePathRemovesLeadingSlash(): void + { + $config = m::mock(SwaggerConfig::class); + $config->shouldReceive('getPrefixUrl')->andReturn('/swagger'); + $config->shouldReceive('getFormat')->andReturn('json'); + + $response = m::mock(ResponseInterface::class); + + $controller = new SwaggerUiControllerTestable($config, $response, m::mock(SwaggerOpenApi::class)); + + $reflection = new ReflectionMethod($controller, 'sanitizeFilePath'); + + $result = $reflection->invoke($controller, '/etc/passwd'); + $this->assertStringStartsNotWith('/', $result); + } + + public function testSanitizeFilePathWithNormalFile(): void + { + $config = m::mock(SwaggerConfig::class); + $config->shouldReceive('getPrefixUrl')->andReturn('/swagger'); + $config->shouldReceive('getFormat')->andReturn('json'); + + $response = m::mock(ResponseInterface::class); + + $controller = new SwaggerUiControllerTestable($config, $response, m::mock(SwaggerOpenApi::class)); + + $reflection = new ReflectionMethod($controller, 'sanitizeFilePath'); + + $result = $reflection->invoke($controller, 'swagger-ui.bundle.js'); + $this->assertEquals('swagger-ui.bundle.js', $result); + } + + public function testSwaggerResourcesReturnsCorrectFormat(): void + { + $config = m::mock(SwaggerConfig::class); + $config->shouldReceive('getPrefixUrl')->andReturn('/swagger'); + $config->shouldReceive('getFormat')->andReturn('json'); + + $response = m::mock(ResponseInterface::class); + + $openApi = m::mock(SwaggerOpenApi::class); + $openApi->serverNameAll = ['http', 'https']; + + $controller = new SwaggerUiControllerTestable($config, $response, $openApi); + + $result = $controller->swaggerResources(); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertEquals('https server', $result[0]['name']); + $this->assertEquals('https.json', $result[0]['url']); + } + + public function testSwaggerConfigReturnsCorrectFormat(): void + { + $config = m::mock(SwaggerConfig::class); + $config->shouldReceive('getPrefixUrl')->andReturn('/swagger'); + $config->shouldReceive('getFormat')->andReturn('json'); + + $response = m::mock(ResponseInterface::class); + + $openApi = m::mock(SwaggerOpenApi::class); + $openApi->serverNameAll = ['http']; + + $controller = new SwaggerUiControllerTestable($config, $response, $openApi); + + $result = $controller->swaggerConfig(); + + $this->assertIsArray($result); + $this->assertArrayHasKey('urls', $result); + $this->assertIsArray($result['urls']); + } +} + +class SwaggerUiControllerTestable extends SwaggerUiController +{ + public function __construct( + SwaggerConfig $swaggerConfig, + ResponseInterface $response, + SwaggerOpenApi $swaggerOpenApi + ) { + $this->swaggerConfig = $swaggerConfig; + $this->response = $response; + $this->swaggerOpenApi = $swaggerOpenApi; + } + + public function swaggerResources(): array + { + $serverNameAll = array_reverse($this->swaggerOpenApi->serverNameAll); + $urls = []; + foreach ($serverNameAll as $serverName) { + $urls[] = [ + 'name' => "{$serverName} server", + 'url' => $serverName . '.' . $this->swaggerConfig->getFormat(), + ]; + } + + return $urls; + } + + public function swaggerConfig(): array + { + $urls = $this->swaggerResources(); + $data['urls'] = $urls; + return $data; + } + + protected function sanitizeFilePath(string $file): string + { + $file = str_replace(['..', '\\', "\0"], '', $file); + return ltrim($file, '/'); + } +} From 0e7e118d2db0e12ac7548cc8c041c8b8ea276cdb Mon Sep 17 00:00:00 2001 From: tw Date: Thu, 26 Mar 2026 10:20:01 +0800 Subject: [PATCH 13/43] Optimize the code --- src/Exception/ApiDocsException.php | 19 +++++++++++++++++++ src/Swagger/GenerateProxyClass.php | 2 +- src/Swagger/GenerateResponses.php | 20 ++++++++++---------- src/Swagger/SwaggerCommon.php | 25 ++++++++++++------------- src/Swagger/SwaggerComponents.php | 16 ++++++++-------- src/Swagger/SwaggerController.php | 16 ++++++++-------- src/Swagger/SwaggerOpenApi.php | 2 +- src/Swagger/SwaggerPaths.php | 8 ++++---- src/Swagger/SwaggerUiController.php | 14 ++++++++++---- 9 files changed, 73 insertions(+), 49 deletions(-) diff --git a/src/Exception/ApiDocsException.php b/src/Exception/ApiDocsException.php index 1cd41be..838a0be 100644 --- a/src/Exception/ApiDocsException.php +++ b/src/Exception/ApiDocsException.php @@ -8,4 +8,23 @@ class ApiDocsException extends RuntimeException { + public static function fileNotFound(string $path): self + { + return new self("File not found: {$path}"); + } + + public static function directoryCreationFailed(string $path): self + { + return new self("Failed to create directory: {$path}"); + } + + public static function invalidClass(string $className): self + { + return new self("Invalid class: {$className}"); + } + + public static function typeResolutionFailed(string $className, string $field): self + { + return new self("Type resolution failed for field: {$className}::{$field}"); + } } diff --git a/src/Swagger/GenerateProxyClass.php b/src/Swagger/GenerateProxyClass.php index 8433347..763d8a0 100644 --- a/src/Swagger/GenerateProxyClass.php +++ b/src/Swagger/GenerateProxyClass.php @@ -32,7 +32,7 @@ public function __construct( $proxyDir = $this->swaggerConfig->getProxyDir(); if (file_exists($proxyDir) === false) { if (mkdir($proxyDir, 0755, true) === false) { - throw new ApiDocsException("Failed to create a directory : {$proxyDir}"); + throw ApiDocsException::directoryCreationFailed($proxyDir); } } } diff --git a/src/Swagger/GenerateResponses.php b/src/Swagger/GenerateResponses.php index f882cd2..fa0fc1b 100644 --- a/src/Swagger/GenerateResponses.php +++ b/src/Swagger/GenerateResponses.php @@ -56,16 +56,16 @@ public function generate(): array return array_values($arr); } -// protected function getReturnJsonContent(string $returnTypeClassName, bool $isArray = false): array -// { -// $arr = []; -// $mediaType = new OA\MediaType(); -// $mediaTypeStr = 'application/json'; -// $mediaType->schema = $this->getJsonContent($returnTypeClassName, $isArray); -// $arr[$mediaTypeStr] = $mediaType; -// $mediaType->mediaType = $mediaTypeStr; -// return $arr; -// } + // protected function getReturnJsonContent(string $returnTypeClassName, bool $isArray = false): array + // { + // $arr = []; + // $mediaType = new OA\MediaType(); + // $mediaTypeStr = 'application/json'; + // $mediaType->schema = $this->getJsonContent($returnTypeClassName, $isArray); + // $arr[$mediaTypeStr] = $mediaType; + // $mediaType->mediaType = $mediaTypeStr; + // return $arr; + // } protected function getContent(array|object|string $returnTypeClassName): array { diff --git a/src/Swagger/SwaggerCommon.php b/src/Swagger/SwaggerCommon.php index c8f2ba5..64c89cb 100644 --- a/src/Swagger/SwaggerCommon.php +++ b/src/Swagger/SwaggerCommon.php @@ -12,9 +12,9 @@ class SwaggerCommon extends DtoCommon { - protected static array $className = []; + protected array $classNameCache = []; - protected static array $simpleClassName = []; + protected array $simpleClassNameCache = []; public function getComponentsName(string $className): string { @@ -23,8 +23,8 @@ public function getComponentsName(string $className): string public function simpleClassNameClear(): void { - static::$className = []; - static::$simpleClassName = []; + $this->classNameCache = []; + $this->simpleClassNameCache = []; } /** @@ -36,8 +36,8 @@ public function getSimpleClassName(?string $className): string $className = 'Null'; } $className = ltrim($className, '\\'); - if (isset(self::$className[$className])) { - return self::$className[$className]; + if (isset($this->classNameCache[$className])) { + return $this->classNameCache[$className]; } $pos = strrpos($className, '\\'); $simpleClassName = $className; @@ -46,7 +46,7 @@ public function getSimpleClassName(?string $className): string } $simpleClassName = $this->getSimpleClassNameNum(ucfirst($simpleClassName)); - self::$className[$className] = $simpleClassName; + $this->classNameCache[$className] = $simpleClassName; return $simpleClassName; } @@ -89,7 +89,7 @@ public function getPhpType(mixed $type): string return $type->getValue(); } - if (is_object($type) && $type::class != 'stdClass') { + if (is_object($type) && $type::class !== 'stdClass') { return '\\' . $type::class; } if (is_string($type) && class_exists($type)) { @@ -98,7 +98,7 @@ public function getPhpType(mixed $type): string return 'mixed'; } - public function getPropertyDefaultValue(string $className, ReflectionProperty $reflectionProperty) + public function getPropertyDefaultValue(string $className, ReflectionProperty $reflectionProperty): mixed { $default = Generator::UNDEFINED; try { @@ -109,7 +109,6 @@ public function getPropertyDefaultValue(string $className, ReflectionProperty $r } catch (Throwable) { $fieldName = $reflectionProperty->getName(); $classVars = get_class_vars($className); - // 别名会获取不到默认值 if (isset($classVars[$fieldName])) { $default = $classVars[$fieldName]; } @@ -117,13 +116,13 @@ public function getPropertyDefaultValue(string $className, ReflectionProperty $r return $default; } - private function getSimpleClassNameNum(string $className, $num = 0): string + private function getSimpleClassNameNum(string $className, int $num = 0): string { $simpleClassName = $className . ($num > 0 ? '_' . $num : ''); - if (isset(self::$simpleClassName[$simpleClassName])) { + if (isset($this->simpleClassNameCache[$simpleClassName])) { return $this->getSimpleClassNameNum($className, $num + 1); } - self::$simpleClassName[$simpleClassName] = $num; + $this->simpleClassNameCache[$simpleClassName] = $num; return $simpleClassName; } } diff --git a/src/Swagger/SwaggerComponents.php b/src/Swagger/SwaggerComponents.php index 055d970..39772da 100644 --- a/src/Swagger/SwaggerComponents.php +++ b/src/Swagger/SwaggerComponents.php @@ -21,7 +21,7 @@ class SwaggerComponents { - protected static array $schemas = []; + protected array $schemas = []; public function __construct( protected SwaggerCommon $common, @@ -32,12 +32,12 @@ public function __construct( public function getSchemas(): array { - return self::$schemas; + return $this->schemas; } public function setSchemas(array $schemas): void { - self::$schemas = $schemas; + $this->schemas = $schemas; } public function getProperties(string $className): array @@ -135,7 +135,7 @@ public function getProperties(string $className): array $property->ref = $this->common->getComponentsName($propertyManager->className); $this->generateSchemas($propertyManager->className); } else { - throw new ApiDocsException("field:{$className}-{$fieldName} type resolved not found"); + throw ApiDocsException::typeResolutionFailed($className, $fieldName); } } $propertyArr[] = $property; @@ -146,8 +146,8 @@ public function getProperties(string $className): array public function generateSchemas(string $className) { $simpleClassName = $this->common->getSimpleClassName($className); - if (isset(static::$schemas[$simpleClassName])) { - return static::$schemas[$simpleClassName]; + if (isset($this->schemas[$simpleClassName])) { + return $this->schemas[$simpleClassName]; } $schema = new OA\Schema(); $schema->schema = $simpleClassName; @@ -160,7 +160,7 @@ public function generateSchemas(string $className) $schema->description = $apiModel->value; } $data['requiredArr'] && $schema->required = $data['requiredArr']; - self::$schemas[$simpleClassName] = $schema; - return self::$schemas[$simpleClassName]; + $this->schemas[$simpleClassName] = $schema; + return $this->schemas[$simpleClassName]; } } diff --git a/src/Swagger/SwaggerController.php b/src/Swagger/SwaggerController.php index 3c27819..9f6251f 100644 --- a/src/Swagger/SwaggerController.php +++ b/src/Swagger/SwaggerController.php @@ -27,7 +27,7 @@ class SwaggerController protected array $swaggerFileList; - public function __construct(protected SwaggerConfig $swaggerConfig, protected ResponseInterface $response,protected SwaggerOpenApi $swaggerOpenApi,) + public function __construct(protected SwaggerConfig $swaggerConfig, protected ResponseInterface $response, protected SwaggerOpenApi $swaggerOpenApi) { $this->outputDir = $this->swaggerConfig->getOutputDir(); $this->uiFileList = is_dir($this->swaggerUiPath) ? scandir($this->swaggerUiPath) : []; @@ -36,8 +36,8 @@ public function __construct(protected SwaggerConfig $swaggerConfig, protected Re public function getFile(string $file): PsrResponseInterface { - if (!in_array($file, $this->uiFileList)) { - throw new ApiDocsException('File does not exist'); + if (! in_array($file, $this->uiFileList)) { + throw ApiDocsException::fileNotFound($file); } $file = $this->swaggerUiPath . '/' . $file; return $this->fileResponse($file); @@ -46,8 +46,8 @@ public function getFile(string $file): PsrResponseInterface public function getJsonFile(string $httpName): PsrResponseInterface { $file = $httpName . '.json'; - if (!in_array($file, $this->swaggerFileList)) { - throw new ApiDocsException('File does not exist'); + if (! in_array($file, $this->swaggerFileList)) { + throw ApiDocsException::fileNotFound($file); } $filePath = $this->outputDir . '/' . $file; return $this->fileResponse($filePath); @@ -56,8 +56,8 @@ public function getJsonFile(string $httpName): PsrResponseInterface public function getYamlFile(string $httpName): PsrResponseInterface { $file = $httpName . '.yaml'; - if (!in_array($file, $this->swaggerFileList)) { - throw new ApiDocsException('File does not exist'); + if (! in_array($file, $this->swaggerFileList)) { + throw ApiDocsException::fileNotFound($file); } $filePath = $this->outputDir . '/' . $file; return $this->fileResponse($filePath); @@ -65,7 +65,7 @@ public function getYamlFile(string $httpName): PsrResponseInterface protected function fileResponse(string $filePath) { - if (!$this->pharRunning() && Constant::ENGINE == 'Swoole') { // phar报错 + if (! $this->pharRunning() && Constant::ENGINE == 'Swoole') { // phar报错 $stream = new SwooleFileStream($filePath); } elseif (Constant::ENGINE == 'Swow') { /* @phpstan-ignore-next-line */ diff --git a/src/Swagger/SwaggerOpenApi.php b/src/Swagger/SwaggerOpenApi.php index 5065b4a..6c8efbf 100644 --- a/src/Swagger/SwaggerOpenApi.php +++ b/src/Swagger/SwaggerOpenApi.php @@ -135,7 +135,7 @@ public function save(string $serverName): void $outputDir = $this->swaggerConfig->getOutputDir(); if (file_exists($outputDir) === false) { if (mkdir($outputDir, 0755, true) === false) { - throw new ApiDocsException("Failed to create a directory : {$outputDir}"); + throw ApiDocsException::directoryCreationFailed($outputDir); } } $outputFile = $outputDir . '/' . $serverName . '.' . $this->swaggerConfig->getFormat(); diff --git a/src/Swagger/SwaggerPaths.php b/src/Swagger/SwaggerPaths.php index 35c86dc..9113d5e 100644 --- a/src/Swagger/SwaggerPaths.php +++ b/src/Swagger/SwaggerPaths.php @@ -30,7 +30,7 @@ class SwaggerPaths protected array $classMethodArray = []; - protected static array $operationIds = []; + protected array $operationIds = []; public function __construct( public string $serverName, @@ -148,13 +148,13 @@ protected function getClassMethodPath(string $fullClassName, string $methodName) */ protected function getOperationId(string $route, string $methods): string { - $route = str_replace(['{','}'], '', $route); + $route = str_replace(['{', '}'], '', $route); $operationId = Str::camel(str_replace('/', '_', $route)); if (empty($operationId)) { $operationId = '-'; } - if (! isset(self::$operationIds[$operationId])) { - self::$operationIds[$operationId] = true; + if (! isset($this->operationIds[$operationId])) { + $this->operationIds[$operationId] = true; return $operationId; } return $this->getOperationId($operationId . ucfirst(strtolower($methods)), $methods); diff --git a/src/Swagger/SwaggerUiController.php b/src/Swagger/SwaggerUiController.php index 1a48c83..05a9261 100644 --- a/src/Swagger/SwaggerUiController.php +++ b/src/Swagger/SwaggerUiController.php @@ -98,10 +98,9 @@ public function swaggerConfig(): array public function knife4jFile(string $file): PsrResponseInterface { - $file = str_replace('..', '', $file); - $file = '/webjars/' . $file; - $file = $this->swaggerUiPath . '/' . $file; - return $this->fileResponse($file); + $file = $this->sanitizeFilePath($file); + $filePath = $this->swaggerUiPath . '/webjars/' . $file; + return $this->fileResponse($filePath); } public function favicon(): PsrResponseInterface @@ -109,4 +108,11 @@ public function favicon(): PsrResponseInterface $file = $this->docsWebPath . '/favicon.png'; return $this->fileResponse($file); } + + protected function sanitizeFilePath(string $file): string + { + $file = str_replace(['..', '\\', "\0"], '', $file); + + return ltrim($file, '/'); + } } From 8f4abb9a40bf66d9fc5213d4a8007c8cea8fe034 Mon Sep 17 00:00:00 2001 From: tw Date: Thu, 26 Mar 2026 11:23:54 +0800 Subject: [PATCH 14/43] update SWOOLE_VERSION --- composer.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d6111ef..7efe8e3 100644 --- a/composer.json +++ b/composer.json @@ -59,5 +59,7 @@ "test": "phpunit -c phpunit.xml --colors=always", "analyse": "phpstan analyse --memory-limit 1024M -l 0 ./src", "cs-fix": "php-cs-fixer fix src && php-cs-fixer fix tests" - } + }, + "minimum-stability": "dev", + "prefer-stable": true } From a4f9c9049b7411619d083f7da4019d8ca6998e0e Mon Sep 17 00:00:00 2001 From: tw Date: Thu, 26 Mar 2026 11:34:25 +0800 Subject: [PATCH 15/43] update SWOOLE_VERSION --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b00426b..22157c6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,7 +3,7 @@ name: PHPUnit on: [ push, pull_request ] env: - SWOOLE_VERSION: '6.1.6' + SWOOLE_VERSION: '6.1.7' SWOW_VERSION: 'develop' jobs: From d68486d25d77c69d72d6e140f9302a384be276bf Mon Sep 17 00:00:00 2001 From: tw Date: Thu, 26 Mar 2026 11:48:49 +0800 Subject: [PATCH 16/43] update composer.json --- composer.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 7efe8e3..d6111ef 100644 --- a/composer.json +++ b/composer.json @@ -59,7 +59,5 @@ "test": "phpunit -c phpunit.xml --colors=always", "analyse": "phpstan analyse --memory-limit 1024M -l 0 ./src", "cs-fix": "php-cs-fixer fix src && php-cs-fixer fix tests" - }, - "minimum-stability": "dev", - "prefer-stable": true + } } From bc2bd6984fbd7b059ccf28ae3d611cb9a03c33a2 Mon Sep 17 00:00:00 2001 From: tangwei Date: Thu, 26 Mar 2026 22:29:20 +0800 Subject: [PATCH 17/43] update test --- .github/workflows/test.yml | 80 ++++++++++++++------------------------ 1 file changed, 29 insertions(+), 51 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 22157c6..fbe6754 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,64 +2,42 @@ name: PHPUnit on: [ push, pull_request ] -env: - SWOOLE_VERSION: '6.1.7' - SWOW_VERSION: 'develop' - jobs: ci: - name: Test PHP ${{ matrix.php-version }} on ${{ matrix.engine }} - runs-on: "${{ matrix.os }}" + runs-on: ubuntu-latest strategy: matrix: - os: [ ubuntu-latest ] - php-version: [ '8.1','8.2','8.3','8.4' ] - engine: [ 'swoole' ] + hyperf-version: + - "8.1-alpine-v3.19-swoole" + - "8.2-alpine-v3.22-swoole" + - "8.3-alpine-v3.23-swoole" + - "8.4-alpine-v3.23-swoole" + fail-fast: false max-parallel: 5 + steps: - name: Checkout - uses: actions/checkout@v2 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - tools: phpize - ini-values: opcache.enable_cli=1 - coverage: none - - name: Setup Swoole - if: ${{ matrix.engine == 'swoole' }} + uses: actions/checkout@v4 + - name: Pull Hyperf Image + run: docker pull hyperf/hyperf:${{ matrix.hyperf-version }} + - name: Environment Info run: | - sudo apt-get update - sudo apt-get install libcurl4-openssl-dev - wget https://github.com/swoole/swoole-src/archive/v${SWOOLE_VERSION}.tar.gz -O swoole.tar.gz - mkdir -p swoole - tar -xf swoole.tar.gz -C swoole --strip-components=1 - rm swoole.tar.gz - cd swoole - phpize - ./configure --enable-openssl --enable-http2 --enable-swoole-curl --enable-swoole-json - make -j$(nproc) - sudo make install - sudo sh -c "echo extension=swoole > /etc/php/${{ matrix.php-version }}/cli/conf.d/swoole.ini" - php --ri swoole - - name: Setup Swow - if: ${{ matrix.engine == 'swow' }} + docker run --rm hyperf/hyperf:${{ matrix.hyperf-version }} \ + sh -c "php -v && php -m && php --ri swoole && composer -V" + - name: Composer Update run: | - wget https://github.com/swow/swow/archive/"${SWOW_VERSION}".tar.gz -O swow.tar.gz - mkdir -p swow - tar -xf swow.tar.gz -C swow --strip-components=1 - rm swow.tar.gz - cd swow/ext || exit - - phpize - ./configure --enable-debug - make -j "$(nproc)" - sudo make install - sudo sh -c "echo extension=swow > /etc/php/${{ matrix.php-version }}/cli/conf.d/swow.ini" - php --ri swow - - name: Setup Packages - run: composer require "tangwei/dto:dev-master" && composer update -o - - name: Run Test Cases + docker run --rm \ + -v ${{ github.workspace }}:/app -w /app hyperf/hyperf:${{ matrix.hyperf-version }} \ + sh -c "composer require "tangwei/dto:dev-master" && composer update -o" + - name: Composer Analyse + run: | + docker run --rm \ + -v ${{ github.workspace }}:/app -w /app hyperf/hyperf:${{ matrix.hyperf-version }} \ + sh -c "composer analyse" + - name: Run Unit Tests run: | - composer analyse - composer test + docker run --rm \ + -v ${{ github.workspace }}:/app -w /app hyperf/hyperf:${{ matrix.hyperf-version }} \ + sh -c "composer test" + + From 69295c7ca242bdd1a9022c85b2c1c0c22f35a4b5 Mon Sep 17 00:00:00 2001 From: tw Date: Fri, 27 Mar 2026 13:46:14 +0800 Subject: [PATCH 18/43] update test --- .github/workflows/{test.yml => test3.1.yml} | 10 +++--- .github/workflows/test3.2.yml | 40 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) rename .github/workflows/{test.yml => test3.1.yml} (88%) create mode 100644 .github/workflows/test3.2.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test3.1.yml similarity index 88% rename from .github/workflows/test.yml rename to .github/workflows/test3.1.yml index fbe6754..849ba9c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test3.1.yml @@ -1,9 +1,9 @@ -name: PHPUnit +name: PHPUnit3.1 on: [ push, pull_request ] jobs: - ci: + ci31: runs-on: ubuntu-latest strategy: matrix: @@ -13,7 +13,7 @@ jobs: - "8.3-alpine-v3.23-swoole" - "8.4-alpine-v3.23-swoole" fail-fast: false - max-parallel: 5 + max-parallel: 15 steps: - name: Checkout @@ -28,7 +28,7 @@ jobs: run: | docker run --rm \ -v ${{ github.workspace }}:/app -w /app hyperf/hyperf:${{ matrix.hyperf-version }} \ - sh -c "composer require "tangwei/dto:dev-master" && composer update -o" + sh -c "composer require hyperf/di:3.1.* && composer require tangwei/dto:dev-master && composer update -o" - name: Composer Analyse run: | docker run --rm \ @@ -39,5 +39,3 @@ jobs: docker run --rm \ -v ${{ github.workspace }}:/app -w /app hyperf/hyperf:${{ matrix.hyperf-version }} \ sh -c "composer test" - - diff --git a/.github/workflows/test3.2.yml b/.github/workflows/test3.2.yml new file mode 100644 index 0000000..c739586 --- /dev/null +++ b/.github/workflows/test3.2.yml @@ -0,0 +1,40 @@ +#name: PHPUnit3.2 +# +#on: [ push, pull_request ] +# +#jobs: +# ci32: +# runs-on: ubuntu-latest +# strategy: +# matrix: +# hyperf-version: +# - "8.2-alpine-v3.22-swoole" +# - "8.3-alpine-v3.23-swoole" +# - "8.4-alpine-v3.23-swoole" +# fail-fast: false +# max-parallel: 15 +# +# steps: +# - name: Checkout +# uses: actions/checkout@v4 +# - name: Pull Hyperf Image +# run: docker pull hyperf/hyperf:${{ matrix.hyperf-version }} +# - name: Environment Info +# run: | +# docker run --rm hyperf/hyperf:${{ matrix.hyperf-version }} \ +# sh -c "php -v && php -m && php --ri swoole && composer -V" +# - name: Composer Update +# run: | +# docker run --rm \ +# -v ${{ github.workspace }}:/app -w /app hyperf/hyperf:${{ matrix.hyperf-version }} \ +# sh -c "composer require hyperf/di:3.2.* && composer require tangwei/dto:dev-master && composer update -o" +# - name: Composer Analyse +# run: | +# docker run --rm \ +# -v ${{ github.workspace }}:/app -w /app hyperf/hyperf:${{ matrix.hyperf-version }} \ +# sh -c "composer analyse" +# - name: Run Unit Tests +# run: | +# docker run --rm \ +# -v ${{ github.workspace }}:/app -w /app hyperf/hyperf:${{ matrix.hyperf-version }} \ +# sh -c "composer test" From 8aa93c40a37ce3d079e950875f3825fbeb6da2e8 Mon Sep 17 00:00:00 2001 From: tw Date: Fri, 27 Mar 2026 14:00:38 +0800 Subject: [PATCH 19/43] update test --- .github/workflows/add_composer_stability.php | 43 +++++++++++ .github/workflows/test3.1.yml | 2 +- .github/workflows/test3.2.yml | 80 ++++++++++---------- 3 files changed, 84 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/add_composer_stability.php diff --git a/.github/workflows/add_composer_stability.php b/.github/workflows/add_composer_stability.php new file mode 100644 index 0000000..9fec7b3 --- /dev/null +++ b/.github/workflows/add_composer_stability.php @@ -0,0 +1,43 @@ + Date: Fri, 27 Mar 2026 15:56:58 +0800 Subject: [PATCH 20/43] test --- .github/workflows/test.yml | 79 +++++++++++++++++++++++++++++++++++ .github/workflows/test3.1.yml | 41 ------------------ .github/workflows/test3.2.yml | 40 ------------------ 3 files changed, 79 insertions(+), 81 deletions(-) create mode 100644 .github/workflows/test.yml delete mode 100644 .github/workflows/test3.1.yml delete mode 100644 .github/workflows/test3.2.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..08907a3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,79 @@ +name: PHPUnit3.1 + +on: [ push, pull_request ] + +jobs: + ci31: + runs-on: ubuntu-latest + container: hyperf/hyperf:${{ matrix.hyperf-version }} + strategy: + matrix: + hyperf-version: + - "8.1-alpine-v3.19-swoole" + - "8.2-alpine-v3.22-swoole" + - "8.3-alpine-v3.23-swoole" + - "8.4-alpine-v3.23-swoole" + fail-fast: false + max-parallel: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Environment + run: | + pwd + ls -al + php -v + php -m + php --ri swoole + composer -V + + - name: Install Dependencies + run: | + composer require hyperf/di:3.1.* --no-update + composer update -o --no-scripts + composer info + + - name: Static Analysis + run: composer analyse + + - name: Run Tests + run: composer test + + ci32: + runs-on: ubuntu-latest + container: hyperf/hyperf:${{ matrix.hyperf-version }} + strategy: + matrix: + hyperf-version: + - "8.2-alpine-v3.22-swoole" + - "8.3-alpine-v3.23-swoole" + - "8.4-alpine-v3.23-swoole" + fail-fast: false + max-parallel: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Environment + run: | + pwd + ls -al + php -v + php -m + php --ri swoole + composer -V + + - name: Install Dependencies + run: | + php .github/workflows/add_composer_stability.php + composer require hyperf/di:3.2.* + composer require tangwei/dto:dev-master + composer update -o + composer info + + - name: Static Analysis + run: composer analyse + + - name: Run Tests + run: composer test \ No newline at end of file diff --git a/.github/workflows/test3.1.yml b/.github/workflows/test3.1.yml deleted file mode 100644 index b3097dd..0000000 --- a/.github/workflows/test3.1.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: PHPUnit3.1 - -on: [ push, pull_request ] - -jobs: - ci31: - runs-on: ubuntu-latest - strategy: - matrix: - hyperf-version: - - "8.1-alpine-v3.19-swoole" - - "8.2-alpine-v3.22-swoole" - - "8.3-alpine-v3.23-swoole" - - "8.4-alpine-v3.23-swoole" - fail-fast: false - max-parallel: 15 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Pull Hyperf Image - run: docker pull hyperf/hyperf:${{ matrix.hyperf-version }} - - name: Environment Info - run: | - docker run --rm hyperf/hyperf:${{ matrix.hyperf-version }} \ - sh -c "php -v && php -m && php --ri swoole && composer -V" - - name: Composer Update - run: | - docker run --rm \ - -v ${{ github.workspace }}:/app -w /app hyperf/hyperf:${{ matrix.hyperf-version }} \ - sh -c "php .github/workflows/add_composer_stability.php && composer require hyperf/di:3.1.* && composer require tangwei/dto:dev-master && composer update -o" - - name: Composer Analyse - run: | - docker run --rm \ - -v ${{ github.workspace }}:/app -w /app hyperf/hyperf:${{ matrix.hyperf-version }} \ - sh -c "composer analyse" - - name: Run Unit Tests - run: | - docker run --rm \ - -v ${{ github.workspace }}:/app -w /app hyperf/hyperf:${{ matrix.hyperf-version }} \ - sh -c "composer test" diff --git a/.github/workflows/test3.2.yml b/.github/workflows/test3.2.yml deleted file mode 100644 index a2e4b6a..0000000 --- a/.github/workflows/test3.2.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: PHPUnit3.2 - -on: [ push, pull_request ] - -jobs: - ci32: - runs-on: ubuntu-latest - strategy: - matrix: - hyperf-version: - - "8.2-alpine-v3.22-swoole" - - "8.3-alpine-v3.23-swoole" - - "8.4-alpine-v3.23-swoole" - fail-fast: false - max-parallel: 15 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Pull Hyperf Image - run: docker pull hyperf/hyperf:${{ matrix.hyperf-version }} - - name: Environment Info - run: | - docker run --rm hyperf/hyperf:${{ matrix.hyperf-version }} \ - sh -c "php -v && php -m && php --ri swoole && composer -V" - - name: Composer Update - run: | - docker run --rm \ - -v ${{ github.workspace }}:/app -w /app hyperf/hyperf:${{ matrix.hyperf-version }} \ - sh -c "php .github/workflows/add_composer_stability.php && composer require hyperf/di:3.2.* && composer require tangwei/dto:dev-master && composer update -o" - - name: Composer Analyse - run: | - docker run --rm \ - -v ${{ github.workspace }}:/app -w /app hyperf/hyperf:${{ matrix.hyperf-version }} \ - sh -c "composer analyse" - - name: Run Unit Tests - run: | - docker run --rm \ - -v ${{ github.workspace }}:/app -w /app hyperf/hyperf:${{ matrix.hyperf-version }} \ - sh -c "composer test" From be025e77586b5dd3b633492720e7b501389438de Mon Sep 17 00:00:00 2001 From: tw Date: Fri, 27 Mar 2026 15:58:59 +0800 Subject: [PATCH 21/43] test --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08907a3..469423e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: PHPUnit3.1 +name: PHPUnit on: [ push, pull_request ] From 3dfb3ae32f5ba225e4b9a965e34afc1abedc7ac5 Mon Sep 17 00:00:00 2001 From: tw Date: Fri, 27 Mar 2026 16:00:06 +0800 Subject: [PATCH 22/43] test --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 469423e..93bd05c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: - name: Install Dependencies run: | composer require hyperf/di:3.1.* --no-update - composer update -o --no-scripts + composer update -o composer info - name: Static Analysis From f9e9fe6b90058eac6ab69698c96e59922f2c67d1 Mon Sep 17 00:00:00 2001 From: tw Date: Fri, 27 Mar 2026 16:02:15 +0800 Subject: [PATCH 23/43] test --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93bd05c..c98419c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,7 @@ jobs: - name: Install Dependencies run: | + php .github/workflows/add_composer_stability.php composer require hyperf/di:3.1.* --no-update composer update -o composer info From 130c31e309b559ef288f42cfba308879f6970fd1 Mon Sep 17 00:00:00 2001 From: tw Date: Mon, 30 Mar 2026 10:06:51 +0800 Subject: [PATCH 24/43] withHeader charset=utf-8 --- src/Swagger/SwaggerController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Swagger/SwaggerController.php b/src/Swagger/SwaggerController.php index 9f6251f..371ab58 100644 --- a/src/Swagger/SwaggerController.php +++ b/src/Swagger/SwaggerController.php @@ -50,7 +50,7 @@ public function getJsonFile(string $httpName): PsrResponseInterface throw ApiDocsException::fileNotFound($file); } $filePath = $this->outputDir . '/' . $file; - return $this->fileResponse($filePath); + return $this->fileResponse($filePath)->withHeader('content-type', 'application/json;charset=utf-8'); } public function getYamlFile(string $httpName): PsrResponseInterface @@ -60,7 +60,7 @@ public function getYamlFile(string $httpName): PsrResponseInterface throw ApiDocsException::fileNotFound($file); } $filePath = $this->outputDir . '/' . $file; - return $this->fileResponse($filePath); + return $this->fileResponse($filePath)->withHeader('content-type', 'text/yaml;charset=utf-8'); } protected function fileResponse(string $filePath) From f8437dcb394406d28c6d4616310b95fb07a767a0 Mon Sep 17 00:00:00 2001 From: tangwei Date: Mon, 30 Mar 2026 21:01:28 +0800 Subject: [PATCH 25/43] optimized code --- src/Swagger/SwaggerPaths.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Swagger/SwaggerPaths.php b/src/Swagger/SwaggerPaths.php index 9113d5e..d1cec73 100644 --- a/src/Swagger/SwaggerPaths.php +++ b/src/Swagger/SwaggerPaths.php @@ -30,7 +30,7 @@ class SwaggerPaths protected array $classMethodArray = []; - protected array $operationIds = []; + protected static array $operationIds = []; public function __construct( public string $serverName, @@ -148,13 +148,13 @@ protected function getClassMethodPath(string $fullClassName, string $methodName) */ protected function getOperationId(string $route, string $methods): string { - $route = str_replace(['{', '}'], '', $route); + $route = str_replace(['{','}'], '', $route); $operationId = Str::camel(str_replace('/', '_', $route)); if (empty($operationId)) { $operationId = '-'; } - if (! isset($this->operationIds[$operationId])) { - $this->operationIds[$operationId] = true; + if (! isset(self::$operationIds[$operationId])) { + self::$operationIds[$operationId] = [$route, $methods]; return $operationId; } return $this->getOperationId($operationId . ucfirst(strtolower($methods)), $methods); From affae80fb2edfbb1969d6531e33a1d8a4ff9d91c Mon Sep 17 00:00:00 2001 From: tw Date: Tue, 31 Mar 2026 13:58:47 +0800 Subject: [PATCH 26/43] =?UTF-8?q?=E6=94=AF=E6=8C=81ai=E8=AF=BB=E5=8F=96llm?= =?UTF-8?q?s.txt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++- src/Listener/BootAppRouteListener.php | 4 ++ src/Swagger/SwaggerConfig.php | 2 +- src/Swagger/SwaggerController.php | 35 ++++++++++- src/Swagger/SwaggerLlms.php | 84 +++++++++++++++++++++++++++ src/Swagger/SwaggerPaths.php | 17 ++++-- 6 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 src/Swagger/SwaggerLlms.php diff --git a/README.md b/README.md index 1777884..c2eb952 100644 --- a/README.md +++ b/README.md @@ -233,15 +233,16 @@ return [ ```bash php bin/hyperf.php start ``` - -启动成功后,访问 `http://your-host:9501/swagger` 即可查看 API 文档。 - ``` [INFO] Swagger docs url at http://0.0.0.0:9501/swagger [INFO] Worker#0 started. [INFO] HTTP Server listening at 0.0.0.0:9501 ``` +- 启动成功后,访问 `http://your-host:9501/swagger` 即可查看 API 文档。 +- 访问 `http://your-host:9501/swagger/llms.txt` 包含控制器每个Markdown页面的链接,可以用于Ai快速访问编程文档。 +- 其他服务访问 `http://your-host:9501/swagger/{service-name}.md` 访问 `{service-name}` 服务的 Markdown 文档。 + ## 📖 使用指南 ### 基础示例 diff --git a/src/Listener/BootAppRouteListener.php b/src/Listener/BootAppRouteListener.php index aa1dc59..2bf0bc6 100644 --- a/src/Listener/BootAppRouteListener.php +++ b/src/Listener/BootAppRouteListener.php @@ -82,6 +82,10 @@ public function process(object $event): void $route->get('/webjars/{file:.*}', [SwaggerUiController::class, 'knife4jFile']); $route->get('/favicon.ico', [SwaggerUiController::class, 'favicon']); + $route->get('/llms.txt', [SwaggerController::class, 'llmsMd']); + $route->get('/{httpName}.md', [SwaggerController::class, 'llmsMd']); + $route->get('/{httpName}/{operationId}.md', [SwaggerController::class, 'llmsDetailMd']); + $route->get('/{httpName}.json', [SwaggerController::class, 'getJsonFile']); $route->get('/{httpName}.yaml', [SwaggerController::class, 'getYamlFile']); $route->get('/{file}', [SwaggerController::class, 'getFile']); diff --git a/src/Swagger/SwaggerConfig.php b/src/Swagger/SwaggerConfig.php index b5217e4..145257a 100644 --- a/src/Swagger/SwaggerConfig.php +++ b/src/Swagger/SwaggerConfig.php @@ -67,7 +67,7 @@ public function setProxyDir(string $proxy_dir): void public function getPrefixUrl(): string { - return $this->prefix_url ?: 'swagger'; + return $this->prefix_url ?: '/swagger'; } public function isValidationCustomAttributes(): bool diff --git a/src/Swagger/SwaggerController.php b/src/Swagger/SwaggerController.php index 371ab58..e59086d 100644 --- a/src/Swagger/SwaggerController.php +++ b/src/Swagger/SwaggerController.php @@ -27,8 +27,12 @@ class SwaggerController protected array $swaggerFileList; - public function __construct(protected SwaggerConfig $swaggerConfig, protected ResponseInterface $response, protected SwaggerOpenApi $swaggerOpenApi) - { + public function __construct( + protected SwaggerConfig $swaggerConfig, + protected ResponseInterface $response, + protected SwaggerOpenApi $swaggerOpenApi, + protected SwaggerLlms $swaggerLlms, + ) { $this->outputDir = $this->swaggerConfig->getOutputDir(); $this->uiFileList = is_dir($this->swaggerUiPath) ? scandir($this->swaggerUiPath) : []; $this->swaggerFileList = scandir($this->outputDir); @@ -63,6 +67,33 @@ public function getYamlFile(string $httpName): PsrResponseInterface return $this->fileResponse($filePath)->withHeader('content-type', 'text/yaml;charset=utf-8'); } + public function llmsMd(string $httpName = 'http'): PsrResponseInterface + { + $prefix = $this->swaggerConfig->getPrefixUrl(); + $url = $this->swaggerConfig->getSwagger()['servers'][0]['url'] ?? ''; + if ($url) { + $prefix = $url . $prefix; + } + $file = $httpName . '.json'; + if (! in_array($file, $this->swaggerFileList)) { + throw ApiDocsException::fileNotFound($file); + } + $filePath = $this->outputDir . '/' . $file; + $content = $this->swaggerLlms->list($httpName, $filePath, $prefix); + return $this->response->raw($content); + } + + public function llmsDetailMd(string $httpName, string $operationId): PsrResponseInterface + { + $file = $httpName . '.json'; + if (! in_array($file, $this->swaggerFileList)) { + throw ApiDocsException::fileNotFound($file); + } + $filePath = $this->outputDir . '/' . $file; + $content = $this->swaggerLlms->detail($operationId, $filePath); + return $this->response->raw($content); + } + protected function fileResponse(string $filePath) { if (! $this->pharRunning() && Constant::ENGINE == 'Swoole') { // phar报错 diff --git a/src/Swagger/SwaggerLlms.php b/src/Swagger/SwaggerLlms.php new file mode 100644 index 0000000..855e2d0 --- /dev/null +++ b/src/Swagger/SwaggerLlms.php @@ -0,0 +1,84 @@ +getRouteByOperationId($operationId); + + $openapi = json_decode(file_get_contents($filePath), true); + $path = $openapi['paths'][$route][$methods]; + $components = $openapi['components']; + + $schemas = $this->getSchemas($path, $components); + $newComponents = []; + foreach ($schemas as $schema) { + $newComponents['components']['schemas'][$schema] = $components['schemas'][$schema]; + } + $openapi['paths'] = []; + $openapi['paths'][$route][$methods] = $path; + $openapi['components'] = $newComponents['components'] ?? []; + unset($openapi['tags'], $openapi['externalDocs']); + + $flags = Yaml::DUMP_OBJECT_AS_MAP ^ Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE; + $yaml = Yaml::dump($openapi, 10, 2, $flags); + $content = sprintf("# %s\n\n## %s\n\n", $path['summary'] ?? $path['operationId'], $path['description'] ?? ''); + $yaml = sprintf("```yaml\n%s\n```", $yaml); + return $content . $yaml; + } + + protected function getSchemas($data = [], array $components = []): array + { + $schemas = []; + foreach ($data as $item) { + if (is_array($item)) { + $schema = $this->getSchemas($item, $components); + if (! empty($schema)) { + $schemas = array_merge($schemas, $schema); + } + } + if (is_string($item) && Str::startsWith($item, '#/components/schemas/')) { + $tmp = str_replace('#/components/schemas/', '', $item); + $schemas[] = $tmp; + $schema = $this->getSchemas($components['schemas'][$tmp], $components); + if (! empty($schema)) { + $schemas = array_merge($schemas, $schema); + } + } + } + return array_unique($schemas); + } +} diff --git a/src/Swagger/SwaggerPaths.php b/src/Swagger/SwaggerPaths.php index d1cec73..6807ab5 100644 --- a/src/Swagger/SwaggerPaths.php +++ b/src/Swagger/SwaggerPaths.php @@ -129,6 +129,11 @@ public function addPath(string $className, string $methodName, string $route, st $this->swaggerOpenApi->getQueuePaths()->insert([$pathItem, $method], 0 - $position); } + public function getRouteByOperationId(string $operationId): array + { + return self::$operationIds[$operationId] ?? []; + } + /** * 获取类方法路径(快速定位后端代码). */ @@ -146,18 +151,22 @@ protected function getClassMethodPath(string $fullClassName, string $methodName) /** * 获取全局操作ID. */ - protected function getOperationId(string $route, string $methods): string + protected function getOperationId(string $route, string $methods, int $num = 1): string { - $route = str_replace(['{','}'], '', $route); - $operationId = Str::camel(str_replace('/', '_', $route)); + $newRoute = str_replace(['{', '}'], '', $route); + $methods = strtolower($methods); + $operationId = Str::camel(str_replace('/', '_', $newRoute) . '_' . $methods); if (empty($operationId)) { $operationId = '-'; } + if ($num > 1) { + $operationId .= $num; + } if (! isset(self::$operationIds[$operationId])) { self::$operationIds[$operationId] = [$route, $methods]; return $operationId; } - return $this->getOperationId($operationId . ucfirst(strtolower($methods)), $methods); + return $this->getOperationId($operationId, $methods, ++$num); } /** From 96ddbff0811596b64779fca60cd07c0321c1b028 Mon Sep 17 00:00:00 2001 From: tw Date: Tue, 31 Mar 2026 14:08:08 +0800 Subject: [PATCH 27/43] Repair SwaggerPathsTest --- tests/SwaggerPathsTest.php | 39 -------------------------------------- 1 file changed, 39 deletions(-) diff --git a/tests/SwaggerPathsTest.php b/tests/SwaggerPathsTest.php index effd5dd..aaa7b50 100644 --- a/tests/SwaggerPathsTest.php +++ b/tests/SwaggerPathsTest.php @@ -50,43 +50,6 @@ public function testOperationIdGeneration(): void $this->assertIsArray($property->getValue($swaggerPaths)); } - public function testOperationIdInstanceIsolation(): void - { - $container = m::mock(ContainerInterface::class); - $config = m::mock(ConfigInterface::class); - $logger = m::mock(StdoutLoggerInterface::class); - $swaggerOpenApi = m::mock(SwaggerOpenApi::class); - $swaggerCommon = new SwaggerCommon(); - - $swaggerPaths1 = new SwaggerPaths( - 'http', - $config, - $logger, - $swaggerOpenApi, - $swaggerCommon - ); - - $swaggerPaths2 = new SwaggerPaths( - 'http2', - $config, - $logger, - $swaggerOpenApi, - $swaggerCommon - ); - - $reflection1 = new ReflectionClass($swaggerPaths1); - $property1 = $reflection1->getProperty('operationIds'); - $property1->setAccessible(true); - - $reflection2 = new ReflectionClass($swaggerPaths2); - $property2 = $reflection2->getProperty('operationIds'); - $property2->setAccessible(true); - - $property1->setValue($swaggerPaths1, ['test' => true]); - - $this->assertArrayNotHasKey('test', $property2->getValue($swaggerPaths2)); - } - public function testGetClassMethodPath(): void { $container = m::mock(ContainerInterface::class); @@ -104,7 +67,6 @@ public function testGetClassMethodPath(): void ); $method = new ReflectionMethod($swaggerPaths, 'getClassMethodPath'); - $method->setAccessible(true); $result = $method->invoke($swaggerPaths, 'Hyperf\ApiDocs\Controller\UserController', 'getUser'); @@ -129,7 +91,6 @@ public function testGetClassMethodPathWithShortNamespace(): void ); $method = new ReflectionMethod($swaggerPaths, 'getClassMethodPath'); - $method->setAccessible(true); $result = $method->invoke($swaggerPaths, 'Controller\UserController', 'index'); From 7f5f32a401e3c3c1fe4f60376b9afa32d1a6837e Mon Sep 17 00:00:00 2001 From: tw Date: Tue, 31 Mar 2026 14:10:53 +0800 Subject: [PATCH 28/43] Repair testGetPrefixUrlFallsBackToDefault --- tests/SwaggerConfigTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/SwaggerConfigTest.php b/tests/SwaggerConfigTest.php index 0aa23de..07c0d7e 100644 --- a/tests/SwaggerConfigTest.php +++ b/tests/SwaggerConfigTest.php @@ -82,7 +82,7 @@ public function testGetPrefixUrlFallsBackToDefault(): void $config = new SwaggerConfig($configInterface); - $this->assertEquals('swagger', $config->getPrefixUrl()); + $this->assertEquals('/swagger', $config->getPrefixUrl()); } public function testIsValidationCustomAttributesDefaultsToFalse(): void From f8232306a7570d859d261a6b7f582534ccabf0f8 Mon Sep 17 00:00:00 2001 From: tw Date: Wed, 1 Apr 2026 09:11:29 +0800 Subject: [PATCH 29/43] update LLM description --- publish/api_docs.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/publish/api_docs.php b/publish/api_docs.php index 7c61452..2928ef4 100644 --- a/publish/api_docs.php +++ b/publish/api_docs.php @@ -111,7 +111,7 @@ 'info' => [ 'title' => 'API DOC', 'version' => '0.1', - 'description' => 'swagger api desc', + 'description' => 'Swagger api desc, API for LLM integration [LLM Usage Guide](/swagger/llms.txt)', ], 'servers' => [ [ From d42cc005611ca861a5c0187e50cd4118461cd13d Mon Sep 17 00:00:00 2001 From: tangwei Date: Tue, 9 Jun 2026 13:39:07 +0800 Subject: [PATCH 30/43] =?UTF-8?q?chore(deps):=20=E6=9B=B4=E6=96=B0=20PHP?= =?UTF-8?q?=20=E7=89=88=E6=9C=AC=E8=A6=81=E6=B1=82=E5=B9=B6=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E6=B5=8B=E8=AF=95=E7=9F=A9=E9=98=B5=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=20Hyperf=20=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 PHP 最低版本要求从 8.1 提升到 8.2 - 移除 composer.json 中对 swagger-php 的兼容性版本约束 - 从 GitHub Actions 测试工作流中移除多个 Hyperf 版本的测试矩阵配置 - 简化了依赖管理以支持最新的 PHP 和相关库版本 --- .github/workflows/test.yml | 1 - composer.json | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c98419c..d74fa63 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,6 @@ jobs: strategy: matrix: hyperf-version: - - "8.1-alpine-v3.19-swoole" - "8.2-alpine-v3.22-swoole" - "8.3-alpine-v3.23-swoole" - "8.4-alpine-v3.23-swoole" diff --git a/composer.json b/composer.json index d6111ef..2abc8da 100644 --- a/composer.json +++ b/composer.json @@ -18,9 +18,9 @@ } ], "require": { - "php": ">=8.1", + "php": ">=8.2", "tangwei/dto": "~3.2.0", - "zircote/swagger-php": "^5.1||^6.0" + "zircote/swagger-php": "^6.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", From f40c7080a5b04dcb56914164228a2c292db59d91 Mon Sep 17 00:00:00 2001 From: tangwei Date: Tue, 9 Jun 2026 13:45:37 +0800 Subject: [PATCH 31/43] =?UTF-8?q?chore(ci):=20=E7=A7=BB=E9=99=A4=E6=97=A7?= =?UTF-8?q?=E7=9A=84=20CI=20=E9=85=8D=E7=BD=AE=E5=B9=B6=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E6=96=B0=E7=9A=84=20ci32=20=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从 api-docs 仓库的 GitHub Actions 配置中删除 ci31 工作流 - 从 dto 仓库的 GitHub Actions 配置中删除 ci31 工作流 - 保留基于 Hyperf 8.2、8.3 和 8.4 版本的容器化测试环境配置 - 保持现有的 ci32 工作流继续运行在 Ubuntu 最新版本上 - 删除过时的依赖安装和静态分析步骤 - 简化 CI 流程以提高构建效率 --- .github/workflows/test.yml | 37 ------------------------------------- 1 file changed, 37 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d74fa63..4163805 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,43 +3,6 @@ name: PHPUnit on: [ push, pull_request ] jobs: - ci31: - runs-on: ubuntu-latest - container: hyperf/hyperf:${{ matrix.hyperf-version }} - strategy: - matrix: - hyperf-version: - - "8.2-alpine-v3.22-swoole" - - "8.3-alpine-v3.23-swoole" - - "8.4-alpine-v3.23-swoole" - fail-fast: false - max-parallel: 15 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Environment - run: | - pwd - ls -al - php -v - php -m - php --ri swoole - composer -V - - - name: Install Dependencies - run: | - php .github/workflows/add_composer_stability.php - composer require hyperf/di:3.1.* --no-update - composer update -o - composer info - - - name: Static Analysis - run: composer analyse - - - name: Run Tests - run: composer test - ci32: runs-on: ubuntu-latest container: hyperf/hyperf:${{ matrix.hyperf-version }} From e03c6895477ae8f1eee4429d5c793ef1e6a844a5 Mon Sep 17 00:00:00 2001 From: tangwei Date: Tue, 16 Jun 2026 16:33:38 +0800 Subject: [PATCH 32/43] =?UTF-8?q?chore(deps):=20=E6=9B=B4=E6=96=B0=20phpst?= =?UTF-8?q?an=20=E4=BE=9D=E8=B5=96=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2abc8da..a73de07 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "friendsofphp/php-cs-fixer": "^3.0", "hyperf/laminas-mime": "^3.0", "mockery/mockery": "^1.0", - "phpstan/phpstan": "^1.0", + "phpstan/phpstan": "^2.0", "phpunit/phpunit": ">=7.0", "symfony/var-dumper": "^5.1" }, From 830ed6f804b347bd56f11ba18565bf9ccf47f0bd Mon Sep 17 00:00:00 2001 From: tangwei Date: Thu, 18 Jun 2026 15:58:53 +0800 Subject: [PATCH 33/43] =?UTF-8?q?fix(security):=20=E8=A7=A3=E5=86=B3Swagge?= =?UTF-8?q?r=E6=96=87=E4=BB=B6=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Swagger/SwaggerController.php | 5 +++- src/Swagger/SwaggerUiController.php | 10 +++++++- tests/SwaggerUiControllerTest.php | 40 ++++++++++++++++++++++++----- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/Swagger/SwaggerController.php b/src/Swagger/SwaggerController.php index e59086d..695cf32 100644 --- a/src/Swagger/SwaggerController.php +++ b/src/Swagger/SwaggerController.php @@ -35,7 +35,10 @@ public function __construct( ) { $this->outputDir = $this->swaggerConfig->getOutputDir(); $this->uiFileList = is_dir($this->swaggerUiPath) ? scandir($this->swaggerUiPath) : []; - $this->swaggerFileList = scandir($this->outputDir); + if (! is_dir($this->outputDir) || ($swaggerFileList = scandir($this->outputDir)) === false) { + throw ApiDocsException::directoryCreationFailed($this->outputDir); + } + $this->swaggerFileList = $swaggerFileList; } public function getFile(string $file): PsrResponseInterface diff --git a/src/Swagger/SwaggerUiController.php b/src/Swagger/SwaggerUiController.php index 05a9261..437ef53 100644 --- a/src/Swagger/SwaggerUiController.php +++ b/src/Swagger/SwaggerUiController.php @@ -5,6 +5,7 @@ namespace Hyperf\ApiDocs\Swagger; use Hyperf\ApiDocs\Annotation\Api; +use Hyperf\ApiDocs\Exception\ApiDocsException; use Hyperf\ApiDocs\Listener\BootAppRouteListener; use Hyperf\HttpMessage\Stream\SwooleStream; use Psr\Http\Message\ResponseInterface as PsrResponseInterface; @@ -100,6 +101,11 @@ public function knife4jFile(string $file): PsrResponseInterface { $file = $this->sanitizeFilePath($file); $filePath = $this->swaggerUiPath . '/webjars/' . $file; + $realBasePath = realpath($this->swaggerUiPath . '/webjars'); + $realFilePath = realpath($filePath); + if ($realFilePath === false || $realBasePath === false || ! str_starts_with($realFilePath, $realBasePath . DIRECTORY_SEPARATOR)) { + throw ApiDocsException::fileNotFound($file); + } return $this->fileResponse($filePath); } @@ -111,7 +117,9 @@ public function favicon(): PsrResponseInterface protected function sanitizeFilePath(string $file): string { - $file = str_replace(['..', '\\', "\0"], '', $file); + do { + $file = str_replace(['..', '\\', "\0"], '', $file, $count); + } while ($count > 0); return ltrim($file, '/'); } diff --git a/tests/SwaggerUiControllerTest.php b/tests/SwaggerUiControllerTest.php index 5b9f320..5d6ab7d 100644 --- a/tests/SwaggerUiControllerTest.php +++ b/tests/SwaggerUiControllerTest.php @@ -39,6 +39,40 @@ public function testSanitizeFilePathRemovesDoubleDots(): void $this->assertStringNotContainsString('..', $result); } + public function testSanitizeFilePathPreventsDotDotSlashBypass(): void + { + // ....// 绕过:第一次 str_replace 移除 .. 后剩下 ../ + $config = m::mock(SwaggerConfig::class); + $config->shouldReceive('getPrefixUrl')->andReturn('/swagger'); + $config->shouldReceive('getFormat')->andReturn('json'); + + $response = m::mock(ResponseInterface::class); + + $controller = new SwaggerUiControllerTestable($config, $response, m::mock(SwaggerOpenApi::class)); + + $reflection = new ReflectionMethod($controller, 'sanitizeFilePath'); + + $result = $reflection->invoke($controller, '....//etc/passwd'); + $this->assertStringNotContainsString('..', $result, 'Bypass attempt ....// should not result in .. remaining'); + } + + public function testSanitizeFilePathPreventsMultipleBypassLayers(): void + { + // 多层嵌套绕过尝试 + $config = m::mock(SwaggerConfig::class); + $config->shouldReceive('getPrefixUrl')->andReturn('/swagger'); + $config->shouldReceive('getFormat')->andReturn('json'); + + $response = m::mock(ResponseInterface::class); + + $controller = new SwaggerUiControllerTestable($config, $response, m::mock(SwaggerOpenApi::class)); + + $reflection = new ReflectionMethod($controller, 'sanitizeFilePath'); + + $result = $reflection->invoke($controller, '....//....//....//etc/passwd'); + $this->assertStringNotContainsString('..', $result, 'Multi-layer bypass should not result in .. remaining'); + } + public function testSanitizeFilePathRemovesBackslashes(): void { $config = m::mock(SwaggerConfig::class); @@ -178,10 +212,4 @@ public function swaggerConfig(): array $data['urls'] = $urls; return $data; } - - protected function sanitizeFilePath(string $file): string - { - $file = str_replace(['..', '\\', "\0"], '', $file); - return ltrim($file, '/'); - } } From 9876456b8923de5e0418054c186deb43f8ec49f0 Mon Sep 17 00:00:00 2001 From: tangwei Date: Thu, 23 Jul 2026 11:01:54 +0800 Subject: [PATCH 34/43] =?UTF-8?q?docs(api):=20=E6=B7=BB=E5=8A=A0=20CLAUDE.?= =?UTF-8?q?md=20=E5=BC=80=E5=8F=91=E6=8C=87=E5=8D=97=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加详细的项目架构核心说明 - 更新环境要求至 PHP 8.2 和 Hyperf ~3.2 - 升级 swagger-ui 版本至 5.27.1 - 修正 knife4j 访问路径说明 - 更新 hyperf.wiki 链接至 3.2 版本 feat(swagger): 支持路径参数识别和循环引用处理 - 新增路由路径参数检测功能,仅将路径占位符参数生成为 path 类型 - 实现循环引用类的防无限递归处理机制 - 为非 DTO 扫描类属性提供反射类型兜底方案 test(swagger): 添加参数生成和响应覆盖测试用例 - 创建 GenerateParametersTest 验证路径参数识别 - 添加 GenerateResponsesTest 测试注解优先级覆盖 - 实现循环引用 DTO 测试用例 refactor(swagger): 优化组件初始化和数据合并顺序 - 调整 SwaggerComponents 类初始化顺序防止递归问题 - 修正 ApiResponse 注解与全局配置合并优先级 - 更新参数生成器构造函数注入路由信息 --- CLAUDE.md | 61 +++++++++++++++++++ README.md | 14 ++--- README_EN.md | 20 +++--- src/Swagger/GenerateParameters.php | 12 ++++ src/Swagger/GenerateResponses.php | 3 +- src/Swagger/SwaggerComponents.php | 21 ++++++- src/Swagger/SwaggerPaths.php | 2 +- tests/GenerateParametersTest.php | 90 +++++++++++++++++++++++++++ tests/GenerateResponsesTest.php | 98 ++++++++++++++++++++++++++++++ tests/Request/TreeNode.php | 19 ++++++ tests/Request/TreeSibling.php | 16 +++++ tests/SwaggerSchemasTest.php | 29 +++++++++ 12 files changed, 366 insertions(+), 19 deletions(-) create mode 100644 CLAUDE.md create mode 100644 tests/GenerateParametersTest.php create mode 100644 tests/GenerateResponsesTest.php create mode 100644 tests/Request/TreeNode.php create mode 100644 tests/Request/TreeSibling.php diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..83c905d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概述 + +`tangwei/apidocs` — 基于 Hyperf 的 Swagger/OpenAPI 3.x 文档自动生成组件。通过 PHP 8 Attributes 扫描控制器路由,在应用启动时生成 OpenAPI 描述文件,并内置多种文档 UI(Swagger UI、Knife4j、Redoc、RapiDoc、Scalar)及 llms.txt 输出。支持 Swoole / Swow / phar 部署。 + +## 常用命令 + +```bash +composer test # 运行全部测试(phpunit -c phpunit.xml) +vendor/bin/phpunit -c phpunit.xml --filter testMethodName tests/SwaggerPathsTest.php # 运行单个测试 +composer analyse # PHPStan 静态分析(-l 0,仅 src/) +composer cs-fix # php-cs-fixer 格式化 src 和 tests +``` + +CI 矩阵为 PHP 8.2/8.3/8.4 + Hyperf 3.2(pin `hyperf/di:3.2.*` + `tangwei/dto:dev-master`)。本包通过 `tangwei/dto ~3.2` 传递依赖 Hyperf ~3.2,不兼容 Hyperf 3.1。 + +## 架构核心 + +### 启动期生成流水线(理解本组件的关键) + +OpenAPI 文件**不是请求时生成的**,而是在应用启动时由事件监听器驱动: + +1. `BootAppRouteListener`(BootApplication 事件)— 在第一个 HTTP server 的路由上注册 `{prefix_url}` 路由组(UI 页面、`/webjars/*`、`{httpName}.json/yaml`、llms.txt 等),并把文档访问 URL 写入静态属性供 `AfterWorkerStartListener` 打印。 +2. `AfterDtoStartListener`(`Hyperf\DTO\Event\AfterDtoStart` 事件,由 tangwei/dto 在扫描完路由后发出)— **每个 server 触发一次**:遍历该 server 的全部路由 Handler,对每个 `控制器@方法` 调 `SwaggerPaths::addPath()` 解析注解生成 `OA\PathItem`,最后 `SwaggerOpenApi::save()` 写入 `output_dir/{serverName}.json|yaml`。 +3. `SwaggerOpenApi` 是**按 server 累积状态**的构建器:`init(serverName)` 重置 → 各 Generate 类向其 SplPriorityQueue(paths/tags 按 position 排序)投递 → `save()` 落盘 → `clean()` 释放。多 server 应用会为每个 server 各生成一份文件。 + +**注意**:`dtoConfig->isScanCacheable()` 为 true 时 `AfterDtoStartListener` 跳过生成(第 56-58 行提前 return)——扫描缓存模式下运行环境可能没有 output_dir 中的文件。 + +### 注解 → OpenAPI 的转换链 + +- `SwaggerPaths::addPath()` 读取类/方法注解(`#[Api]`、`#[ApiOperation]`、`#[ApiHeader]`、`#[ApiResponse]`、`#[ApiFormData]`、`#[ApiSecurity]`),委托给: + - `GenerateParameters` — 从方法签名 + DTO 类生成 parameters/requestBody + - `GenerateResponses` — 从方法**返回类型**(`MethodDefinitionCollector`)+ `#[ApiResponse]` + 全局 `GlobalResponse` 配置生成 responses;控制器方法返回具体类才能获得准确文档 + - `SwaggerComponents` — DTO 类的 `#[ApiModelProperty]`/验证注解 → `components.schemas`,继承自 tangwei/dto 的 `PropertyManager` +- `SwaggerConfig` 用 JsonMapper(`bIgnoreVisibility`)把 `config/autoload/api_docs.php` 直接映射到私有属性——**配置键名必须与属性名一致**(snake_case),新增配置项 = 新增同名私有属性。 + +### ApiVariable 代理类机制 + +`#[ApiVariable]` 标记的 DTO 属性(类型在运行时才能确定的"可变类型")由 `GenerateProxyClass` 在运行时通过 PHP-Parser 重写原类 AST(`Ast\ResponseVisitor` 替换属性类型和命名空间为 `ApiDocs\Proxy`),写入 `proxy_dir`(默认 `runtime/container/proxy/`)供 schema 生成使用。 + +### 文件服务端点 + +`SwaggerController`(json/yaml/md/静态文件)和 `SwaggerUiController`(各 UI 页面 + knife4j webjars)按请求实例化。静态资源路径硬编码指向 `vendor/tangwei/swagger-ui/dist` 和 `vendor/tangwei/knife4j-ui/dist`(knife4j-ui 是 suggest 依赖,未安装时相关路由会 500)。三类端点校验方式不同:`getFile` 用 scandir 白名单精确匹配,`knife4jFile` 用 sanitize + realpath 前缀校验(嵌套路径无法白名单)。`fileResponse` 在 Swoole 下用 `SwooleFileStream`(sendfile),Swow/phar 下退回 `file_get_contents`。 + +### 与 tangwei/dto 的关系 + +本组件重度依赖 `tangwei/dto`(`Hyperf\DTO\*`):注解扫描(`ApiAnnotation::classMetadata`)、DTO 验证、属性管理、Mapper 均来自该包。修改扫描/注解相关行为时,先确认逻辑在本包还是 dto 包。 + +## 测试约定 + +- 测试基类 `SwaggerUiControllerTestable` 重写了构造函数且**不调 `parent::__construct`**——父类构造函数的逻辑(目录检查、scandir)在测试中不会被覆盖到。 +- `tests/Request/` 下的 DTO 是多个测试共用的 fixture。 +- CI 在 hyperf/hyperf 容器镜像中运行,本地无 Swoole 也可跑 phpunit(测试不依赖 server 启动)。 + +## 示例与文档 + +- `example/` 目录是注解用法的活文档(各参数注解、分页、枚举、递归类型的完整示例),改注解行为时对照它验证。 +- README.md / README_EN.md 需保持同步;环境要求以 composer.json 为准(README 中的版本号容易滞后)。 diff --git a/README.md b/README.md index c2eb952..97fbfe2 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Latest Stable Version](https://img.shields.io/packagist/v/tangwei/apidocs)](https://packagist.org/packages/tangwei/apidocs) [![Total Downloads](https://img.shields.io/packagist/dt/tangwei/apidocs)](https://packagist.org/packages/tangwei/apidocs) [![License](https://img.shields.io/packagist/l/tangwei/apidocs)](https://github.com/tw2066/api-docs) -[![PHP Version](https://img.shields.io/badge/php-%3E%3D8.1-blue)](https://www.php.net) +[![PHP Version](https://img.shields.io/badge/php-%3E%3D8.2-blue)](https://www.php.net) [English](./README_EN.md) | 中文 @@ -22,8 +22,8 @@ ## 📋 环境要求 -- PHP >= 8.1 -- Hyperf >= 3.0 +- PHP >= 8.2 +- Hyperf ~3.2 - Swoole >= 5.0 或 Swow ## 💡 使用须知 @@ -139,7 +139,7 @@ return [ | 设置swagger资源路径,cdn资源 |-------------------------------------------------------------------------- */ - 'prefix_swagger_resources' => 'https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.5.0', + 'prefix_swagger_resources' => 'https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.27.1', /* |-------------------------------------------------------------------------- @@ -719,7 +719,7 @@ public function upload(#[RequestFormData] UploadRequest $request) 访问不同的 UI 界面: - **Swagger UI**: `http://your-host:9501/swagger` -- **Knife4j**: `http://your-host:9501/swagger/knife4j` +- **Knife4j**: `http://your-host:9501/swagger/doc`(需安装 `tangwei/knife4j-ui`) - **Redoc**: `http://your-host:9501/swagger/redoc` - **RapiDoc**: `http://your-host:9501/swagger/rapidoc` - **Scalar**: `http://your-host:9501/swagger/scalar` @@ -769,7 +769,7 @@ class DemoQuery ### RPC 支持 -[返回 PHP 对象](https://hyperf.wiki/3.1/#/zh-cn/json-rpc?id=%e8%bf%94%e5%9b%9e-php-%e5%af%b9%e8%b1%a1) +[返回 PHP 对象](https://hyperf.wiki/3.2/#/zh-cn/json-rpc?id=%e8%bf%94%e5%9b%9e-php-%e5%af%b9%e8%b1%a1) aspects.php 中配置: @@ -865,7 +865,7 @@ public function getUser(): UserResponse ### Q: 支持哪些验证规则? -A: 支持所有 Hyperf Validation 规则。详见 [Hyperf 验证器文档](https://hyperf.wiki/3.1/#/zh-cn/validation)。 +A: 支持所有 Hyperf Validation 规则。详见 [Hyperf 验证器文档](https://hyperf.wiki/3.2/#/zh-cn/validation)。 ### Q: `AutoController` 注解支持吗? diff --git a/README_EN.md b/README_EN.md index 8454f62..1210583 100644 --- a/README_EN.md +++ b/README_EN.md @@ -3,7 +3,7 @@ [![Latest Stable Version](https://img.shields.io/packagist/v/tangwei/apidocs)](https://packagist.org/packages/tangwei/apidocs) [![Total Downloads](https://img.shields.io/packagist/dt/tangwei/apidocs)](https://packagist.org/packages/tangwei/apidocs) [![License](https://img.shields.io/packagist/l/tangwei/apidocs)](https://github.com/tw2066/api-docs) -[![PHP Version](https://img.shields.io/badge/php-%3E%3D8.1-blue)](https://www.php.net) +[![PHP Version](https://img.shields.io/badge/php-%3E%3D8.2-blue)](https://www.php.net) English | [中文](./README.md) @@ -11,7 +11,7 @@ Automatic Swagger/OpenAPI documentation generator for the [Hyperf](https://githu ## ✨ Features -- 🚀 **Auto Generation** - Automatically generate OpenAPI 3.0 documentation based on PHP 8 Attributes +- 🚀 **Auto Generation** - Automatically generate OpenAPI 3.0/3.1 documentation based on PHP 8 Attributes - 🎯 **Type Safety** - Support DTO mode with automatic parameter mapping to PHP classes - 📝 **Multiple UIs** - Support Swagger UI, Knife4j, Redoc, RapiDoc, Scalar, and more - ✅ **Data Validation** - Integrate Hyperf validator with rich validation annotations @@ -22,8 +22,8 @@ Automatic Swagger/OpenAPI documentation generator for the [Hyperf](https://githu ## 📋 Requirements -- PHP >= 8.1 -- Hyperf >= 3.0 +- PHP >= 8.2 +- Hyperf ~3.2 - Swoole >= 5.0 or Swow ## 💡 Important Notes @@ -237,14 +237,16 @@ return [ php bin/hyperf.php start ``` -After successful startup, visit `http://your-host:9501/swagger` to view the API documentation. - ``` [INFO] Swagger docs url at http://0.0.0.0:9501/swagger [INFO] Worker#0 started. [INFO] HTTP Server listening at 0.0.0.0:9501 ``` +- After successful startup, visit `http://your-host:9501/swagger` to view the API documentation. +- Visit `http://your-host:9501/swagger/llms.txt` for links to a Markdown page per controller, which can be used by AI to quickly access the API documentation. +- Other servers can visit `http://your-host:9501/swagger/{service-name}.md` to access the Markdown documentation of the `{service-name}` server. + ## 📖 Usage Guide ### Basic Example @@ -848,7 +850,7 @@ public function upload(#[RequestFormData] UploadRequest $request) Access different UI interfaces: - **Swagger UI**: `http://your-host:9501/swagger` -- **Knife4j**: `http://your-host:9501/swagger/knife4j` +- **Knife4j**: `http://your-host:9501/swagger/doc` (requires `tangwei/knife4j-ui`) - **Redoc**: `http://your-host:9501/swagger/redoc` - **RapiDoc**: `http://your-host:9501/swagger/rapidoc` - **Scalar**: `http://your-host:9501/swagger/scalar` @@ -898,7 +900,7 @@ class DemoQuery ### RPC Support -[Return PHP Object](https://hyperf.wiki/3.1/#/en/json-rpc?id=returning-php-objects) +[Return PHP Object](https://hyperf.wiki/3.2/#/en/json-rpc?id=returning-php-objects) Configure in aspects.php: @@ -996,7 +998,7 @@ public function getUser(): UserResponse ### Q: What validation rules are supported? -A: All Hyperf Validation rules are supported. See [Hyperf Validation Documentation](https://hyperf.wiki/3.1/#/en/validation). +A: All Hyperf Validation rules are supported. See [Hyperf Validation Documentation](https://hyperf.wiki/3.2/#/en/validation). ### Q: Does `AutoController` annotation work? diff --git a/src/Swagger/GenerateParameters.php b/src/Swagger/GenerateParameters.php index a7014a5..8b036ff 100644 --- a/src/Swagger/GenerateParameters.php +++ b/src/Swagger/GenerateParameters.php @@ -32,6 +32,7 @@ public function __construct( protected string $action, protected array $apiHeaderArr, protected array $apiFormDataArr, + protected string $route, protected ContainerInterface $container, protected MethodDefinitionCollectorInterface $methodDefinitionCollector, protected SwaggerComponents $swaggerComponents, @@ -57,6 +58,9 @@ public function generate(): array // 判断是否为简单类型 $simpleSwaggerType = $this->common->getSimpleType2SwaggerType($parameterClassName); if ($simpleSwaggerType !== null) { + if (! $this->isPathParam($paramName)) { + continue; + } $parameter = new OA\Parameter(); $parameter->required = true; $parameter->name = $paramName; @@ -270,4 +274,12 @@ protected function getPropertiesByBaseParam(array $baseParam): array } return ['propertyArr' => $propertyArr, 'requiredArr' => $requiredArr]; } + + /** + * 判断参数是否为路由路径占位符(支持 {id} 和 {id:\d+} 形式). + */ + protected function isPathParam(string $paramName): bool + { + return preg_match('/\{' . preg_quote($paramName, '/') . '(:[^}]*)?\}/', $this->route) === 1; + } } diff --git a/src/Swagger/GenerateResponses.php b/src/Swagger/GenerateResponses.php index fa0fc1b..42c5eec 100644 --- a/src/Swagger/GenerateResponses.php +++ b/src/Swagger/GenerateResponses.php @@ -50,8 +50,9 @@ public function generate(): array $content && $response->content = $content; $arr[$code] = $response; - $annotationResp && $arr = Arr::merge($arr, $annotationResp); + // 优先级:方法级 ApiResponse 注解 > 全局 responses 配置 $globalResp && $arr = Arr::merge($arr, $globalResp); + $annotationResp && $arr = Arr::merge($arr, $annotationResp); return array_values($arr); } diff --git a/src/Swagger/SwaggerComponents.php b/src/Swagger/SwaggerComponents.php index 39772da..c6d2464 100644 --- a/src/Swagger/SwaggerComponents.php +++ b/src/Swagger/SwaggerComponents.php @@ -13,6 +13,7 @@ use Hyperf\DTO\Annotation\Validation\Required; use Hyperf\DTO\ApiAnnotation; use Hyperf\DTO\DtoConfig; +use Hyperf\DTO\Scan\Property; use Hyperf\DTO\Scan\PropertyManager; use OpenApi\Attributes as OA; use OpenApi\Generator; @@ -55,6 +56,10 @@ public function getProperties(string $className): array $property = new OA\Property(); $fieldName = $reflectionProperty->getName(); $propertyManager = $this->propertyManager->getProperty($className, $fieldName); + if ($propertyManager === null) { + // 属性未被 DTO 扫描器登记(如代理类),按反射类型兜底 + $propertyManager = $this->buildPropertyFromReflection($reflectionProperty); + } // 适配ApiVariable注解 $sourceClassName = $this->generateProxyClass?->getSourceClassname($className) ?? $className; @@ -151,6 +156,8 @@ public function generateSchemas(string $className) } $schema = new OA\Schema(); $schema->schema = $simpleClassName; + // 先登记再解析属性,防止循环引用类(A ↔ B)导致无限递归 + $this->schemas[$simpleClassName] = $schema; $data = $this->getProperties($className); $schema->properties = $data['propertyArr']; @@ -160,7 +167,19 @@ public function generateSchemas(string $className) $schema->description = $apiModel->value; } $data['requiredArr'] && $schema->required = $data['requiredArr']; - $this->schemas[$simpleClassName] = $schema; return $this->schemas[$simpleClassName]; } + + protected function buildPropertyFromReflection(\ReflectionProperty $reflectionProperty): Property + { + $property = new Property(); + $phpType = $this->common->getTypeName($reflectionProperty); + if ($this->common->isSimpleType($phpType)) { + $property->phpSimpleType = $phpType; + } else { + $property->isSimpleType = false; + $property->className = $phpType; + } + return $property; + } } diff --git a/src/Swagger/SwaggerPaths.php b/src/Swagger/SwaggerPaths.php index 6807ab5..09e5369 100644 --- a/src/Swagger/SwaggerPaths.php +++ b/src/Swagger/SwaggerPaths.php @@ -92,7 +92,7 @@ public function addPath(string $className, string $methodName, string $route, st $method = strtolower($methods); /** @var GenerateParameters $generateParameters */ - $generateParameters = make(GenerateParameters::class, [$className, $methodName, $apiHeaderArr, $apiFormDataArr]); + $generateParameters = make(GenerateParameters::class, [$className, $methodName, $apiHeaderArr, $apiFormDataArr, $route]); /** @var GenerateResponses $generateResponses */ $generateResponses = make(GenerateResponses::class, [$className, $methodName, $apiResponseArr]); $parameters = $generateParameters->generate(); diff --git a/tests/GenerateParametersTest.php b/tests/GenerateParametersTest.php new file mode 100644 index 0000000..f0f9280 --- /dev/null +++ b/tests/GenerateParametersTest.php @@ -0,0 +1,90 @@ +generate('/user/{id}', [ + $this->reflectionType('int', 'id'), + ]); + + $this->assertCount(1, $result['parameter']); + $this->assertSame('path', $result['parameter'][0]->in); + $this->assertTrue($result['parameter'][0]->required); + $this->assertSame('id', $result['parameter'][0]->name); + } + + /** + * 带正则约束的占位符 {id:\d+} 也应识别为 path 参数. + */ + public function testPathParamWithRegexConstraint(): void + { + $result = $this->generate('/user/{id:\d+}', [ + $this->reflectionType('int', 'id'), + ]); + + $this->assertSame('path', $result['parameter'][0]->in); + $this->assertTrue($result['parameter'][0]->required); + } + + private function reflectionType(string $type, string $name, bool $allowsNull = false, bool $defaultValueAvailable = false): ReflectionType + { + return new ReflectionType($type, $allowsNull, [ + 'defaultValueAvailable' => $defaultValueAvailable, + 'defaultValue' => null, + 'name' => $name, + 'attributes' => [], + ]); + } + + private function generate(string $route, array $definitions): array + { + $swaggerCommon = new SwaggerCommon(); + $container = m::mock(ContainerInterface::class); + $methodDefinitionCollector = m::mock(MethodDefinitionCollectorInterface::class); + $methodDefinitionCollector->shouldReceive('getParameters')->andReturn($definitions); + + $generateParameters = new GenerateParameters( + 'DemoController', + 'list', + [], + [], + $route, + $container, + $methodDefinitionCollector, + new SwaggerComponents($swaggerCommon, new PropertyManager($swaggerCommon, new PropertyEnum()), null), + $swaggerCommon, + new PropertyManager($swaggerCommon, new PropertyEnum()), + m::mock(MethodParametersManager::class), + ); + return $generateParameters->generate(); + } +} diff --git a/tests/GenerateResponsesTest.php b/tests/GenerateResponsesTest.php new file mode 100644 index 0000000..ae770d1 --- /dev/null +++ b/tests/GenerateResponsesTest.php @@ -0,0 +1,98 @@ +shouldReceive('getResponsesCode')->andReturn('200'); + $swaggerConfig->shouldReceive('getGlobalReturnResponsesClass')->andReturn(''); + $swaggerConfig->shouldReceive('getResponses')->andReturn([ + ['response' => 401, 'description' => 'Global Unauthorized'], + ]); + + $apiResponse = new ApiResponse(null, 401, 'Annotation Unauthorized'); + + $generateResponses = $this->makeGenerateResponses($swaggerConfig, [$apiResponse]); + $responses = $generateResponses->generate(); + + $resp401 = null; + foreach ($responses as $response) { + if ((int) $response->response === 401) { + $resp401 = $response; + } + } + $this->assertNotNull($resp401); + $this->assertSame('Annotation Unauthorized', $resp401->description); + } + + /** + * 注解未覆盖的状态码仍使用全局配置. + */ + public function testGlobalResponseKeptWhenNotOverridden(): void + { + $swaggerConfig = m::mock(SwaggerConfig::class); + $swaggerConfig->shouldReceive('getResponsesCode')->andReturn('200'); + $swaggerConfig->shouldReceive('getGlobalReturnResponsesClass')->andReturn(''); + $swaggerConfig->shouldReceive('getResponses')->andReturn([ + ['response' => 500, 'description' => 'Global System Error'], + ]); + + $generateResponses = $this->makeGenerateResponses($swaggerConfig, []); + $responses = $generateResponses->generate(); + + $descriptions = array_map(fn ($r) => $r->description, $responses); + $this->assertContains('Global System Error', $descriptions); + } + + private function makeGenerateResponses(SwaggerConfig $swaggerConfig, array $apiResponseArr): GenerateResponses + { + $container = m::mock(ContainerInterface::class); + $container->shouldReceive('has')->andReturn(false); + $container->shouldReceive('get')->with(MethodDefinitionCollectorInterface::class)->andReturn(new MethodDefinitionCollector()); + + $swaggerCommon = new SwaggerCommon(); + return new GenerateResponses( + DemoBodyRequest::class, + 'getBo', + $apiResponseArr, + $swaggerConfig, + new MethodDefinitionCollector(), + $container, + new SwaggerComponents($swaggerCommon, new PropertyManager($swaggerCommon, new PropertyEnum()), null), + $swaggerCommon, + m::mock(GenerateProxyClass::class), + ); + } +} diff --git a/tests/Request/TreeNode.php b/tests/Request/TreeNode.php new file mode 100644 index 0000000..5e8fc1f --- /dev/null +++ b/tests/Request/TreeNode.php @@ -0,0 +1,19 @@ +assertContains('name', $propertyNames); $this->assertContains('age', $propertyNames); } + + /** + * 自引用与互相引用的 DTO 不会导致 generateSchemas 无限递归. + */ + public function testCircularReferenceSchemas(): void + { + $swaggerCommon = new SwaggerCommon(); + $swaggerComponents = new SwaggerComponents($swaggerCommon, new PropertyManager($swaggerCommon, new PropertyEnum()), null); + + $schema = $swaggerComponents->generateSchemas(TreeNode::class); + $properties = $schema->properties; + + $propertyMap = []; + foreach ($properties as $property) { + $propertyMap[$property->property] = $property; + } + // 自引用 + $this->assertSame('#/components/schemas/TreeNode', $propertyMap['child']->ref); + // 互相引用 A → B,B 的 schema 也应生成且 B → A 正常回指 + $this->assertSame('#/components/schemas/TreeSibling', $propertyMap['sibling']->ref); + $schemas = $swaggerComponents->getSchemas(); + $this->assertArrayHasKey('TreeNode', $schemas); + $this->assertArrayHasKey('TreeSibling', $schemas); + $siblingProperties = $schemas['TreeSibling']->properties; + $this->assertSame('#/components/schemas/TreeNode', $siblingProperties[1]->ref); + + $swaggerCommon->simpleClassNameClear(); + } } From ee8e45734499e51bb2f4309ed059be9ca540ee45 Mon Sep 17 00:00:00 2001 From: tangwei Date: Thu, 23 Jul 2026 13:40:56 +0800 Subject: [PATCH 35/43] =?UTF-8?q?chore(ci):=20=E6=9B=B4=E6=96=B0=20compose?= =?UTF-8?q?r=20=E5=91=BD=E4=BB=A4=E5=8F=82=E6=95=B0=E4=BB=A5=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E4=BE=9D=E8=B5=96=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 composer update 命令从 -o 参数更改为 -oW 参数 - 提升依赖更新过程的严格性和一致性 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4163805..cc6ec9e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: php .github/workflows/add_composer_stability.php composer require hyperf/di:3.2.* composer require tangwei/dto:dev-master - composer update -o + composer update -oW composer info - name: Static Analysis From ce526e79b83ac4dbe563ec0387709949c84172b6 Mon Sep 17 00:00:00 2001 From: tangwei Date: Thu, 23 Jul 2026 13:44:49 +0800 Subject: [PATCH 36/43] =?UTF-8?q?chore(deps):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E5=AE=89=E8=A3=85=E5=91=BD=E4=BB=A4=E5=8F=82?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 composer require 的 -W 参数避免重复约束 - 移除 composer update 的 -W 参数优化执行效率 - 保持依赖安装的一致性配置 --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cc6ec9e..51ed723 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,8 +31,8 @@ jobs: run: | php .github/workflows/add_composer_stability.php composer require hyperf/di:3.2.* - composer require tangwei/dto:dev-master - composer update -oW + composer require tangwei/dto:dev-master -W + composer update -o composer info - name: Static Analysis From c7969c18df15d111d11069fe3bac0cc4cbd35ad6 Mon Sep 17 00:00:00 2001 From: tangwei Date: Thu, 23 Jul 2026 16:01:13 +0800 Subject: [PATCH 37/43] =?UTF-8?q?fix(swagger):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E8=B7=AF=E5=BE=84=E5=8D=A0=E4=BD=8D=E7=AC=A6?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E6=AD=A3=E5=88=99=E8=A1=A8=E8=BE=BE=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持带有空白字符的占位符格式,如 { id }、{id : \d+} - 更新正则表达式以与 FastRoute 规则对齐 - 保留对原有格式 {id} 和 {id:\d+} 的兼容性 --- src/Swagger/GenerateParameters.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Swagger/GenerateParameters.php b/src/Swagger/GenerateParameters.php index 8b036ff..1f0fed8 100644 --- a/src/Swagger/GenerateParameters.php +++ b/src/Swagger/GenerateParameters.php @@ -276,10 +276,10 @@ protected function getPropertiesByBaseParam(array $baseParam): array } /** - * 判断参数是否为路由路径占位符(支持 {id} 和 {id:\d+} 形式). + * 判断参数是否为路由路径占位符(支持 {id}、{id:\d+} 及 { id }、{id : \d+} 等空白变体,与 FastRoute 规则对齐). */ protected function isPathParam(string $paramName): bool { - return preg_match('/\{' . preg_quote($paramName, '/') . '(:[^}]*)?\}/', $this->route) === 1; + return preg_match('/\{\s*' . preg_quote($paramName, '/') . '\s*(?::[^{}]*(?:\{[^{}]*\}[^{}]*)*)?\}/', $this->route) === 1; } } From 774c720f25e23f76ecd34089c5192d191dc45fb6 Mon Sep 17 00:00:00 2001 From: tangwei Date: Fri, 24 Jul 2026 09:25:03 +0800 Subject: [PATCH 38/43] =?UTF-8?q?refactor(Swagger):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E8=B7=AF=E5=BE=84=E5=8F=82=E6=95=B0=E5=88=A4?= =?UTF-8?q?=E6=96=AD=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 FastRoute 解析器替代正则表达式匹配 - 使用标准路由解析器确保与框架路由规则一致性 - 重构 isPathParam 方法提高代码可读性和维护性 - 移除复杂的正则表达式模式匹配逻辑 --- src/Swagger/GenerateParameters.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Swagger/GenerateParameters.php b/src/Swagger/GenerateParameters.php index 1f0fed8..620cdd1 100644 --- a/src/Swagger/GenerateParameters.php +++ b/src/Swagger/GenerateParameters.php @@ -4,6 +4,7 @@ namespace Hyperf\ApiDocs\Swagger; +use FastRoute\RouteParser\Std; use Hyperf\ApiDocs\Annotation\ApiFormData; use Hyperf\ApiDocs\Annotation\ApiHeader; use Hyperf\ApiDocs\Annotation\ApiModelProperty; @@ -276,10 +277,18 @@ protected function getPropertiesByBaseParam(array $baseParam): array } /** - * 判断参数是否为路由路径占位符(支持 {id}、{id:\d+} 及 { id }、{id : \d+} 等空白变体,与 FastRoute 规则对齐). + * 判断参数是否为路由路径占位符(复用 FastRoute 解析器,与框架路由匹配规则保持一致). */ protected function isPathParam(string $paramName): bool { - return preg_match('/\{\s*' . preg_quote($paramName, '/') . '\s*(?::[^{}]*(?:\{[^{}]*\}[^{}]*)*)?\}/', $this->route) === 1; + $routeDataList = (new Std())->parse($this->route); + foreach ($routeDataList as $routeData) { + foreach ($routeData as $segment) { + if (is_array($segment) && $segment[0] === $paramName) { + return true; + } + } + } + return false; } } From ea785441aacea7ce654c4df9c6562ecb85c68402 Mon Sep 17 00:00:00 2001 From: tw Date: Wed, 12 Aug 2026 17:27:33 +0800 Subject: [PATCH 39/43] =?UTF-8?q?refactor(config):=20=E7=AE=80=E5=8C=96=20?= =?UTF-8?q?PHPUnit=20=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除不必要的配置选项如 backupGlobals、backupStaticAttributes、verbose 等 - 将 testsuite 标签包装在 testsuites 容器中以符合最新规范 - 保留核心配置如 bootstrap 文件路径、颜色输出和失败停止选项 --- phpunit.xml | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index d2c615a..196fc8a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,15 +1,10 @@ - - ./tests/ - + + + ./tests/ + + \ No newline at end of file From 64381062c6f009bd1c11cd0482da7c3330013f90 Mon Sep 17 00:00:00 2001 From: tw Date: Tue, 18 Aug 2026 10:17:56 +0800 Subject: [PATCH 40/43] =?UTF-8?q?refactor(Swagger):=20=E8=B0=83=E6=95=B4AP?= =?UTF-8?q?I=E6=96=87=E6=A1=A3=E8=B7=AF=E5=BE=84=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将类方法路径信息从description字段移至x属性中存储 - 移除description字段中的HTML标签拼接逻辑 - 直接使用apiOperation描述覆盖description字段 --- src/Swagger/SwaggerPaths.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Swagger/SwaggerPaths.php b/src/Swagger/SwaggerPaths.php index 09e5369..a45348c 100644 --- a/src/Swagger/SwaggerPaths.php +++ b/src/Swagger/SwaggerPaths.php @@ -104,9 +104,9 @@ public function addPath(string $className, string $methodName, string $route, st $operation->path = $route; $operation->tags = $tags; $operation->summary = $apiOperation->summary ?: Generator::UNDEFINED; - $operation->description = $this->getClassMethodPath($className, $methodName); + $operation->x = ['code-path' => $this->getClassMethodPath($className, $methodName)]; if ($apiOperation->description) { - $operation->description .= '
' . $apiOperation->description; + $operation->description = $apiOperation->description; } $operation->operationId = $this->getOperationId($route, $methods); From 1bf0fe882263261207fcedce20f4b59a2c63829c Mon Sep 17 00:00:00 2001 From: tw Date: Wed, 26 Aug 2026 17:23:57 +0800 Subject: [PATCH 41/43] =?UTF-8?q?fix(swagger):=20=E5=B0=86Swagger=E7=A6=81?= =?UTF-8?q?=E7=94=A8=E6=97=A5=E5=BF=97=E7=BA=A7=E5=88=AB=E4=BB=8Einfo?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E4=B8=BAdebug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改BootAppRouteListener.php中的日志记录级别 - 将'swagger not enable'消息从info级别改为debug级别 - 减少生产环境中的冗余日志输出 --- src/Listener/BootAppRouteListener.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Listener/BootAppRouteListener.php b/src/Listener/BootAppRouteListener.php index 2bf0bc6..b5fe7e1 100644 --- a/src/Listener/BootAppRouteListener.php +++ b/src/Listener/BootAppRouteListener.php @@ -48,7 +48,7 @@ public function process(object $event): void } if (! $this->swaggerConfig->isEnable()) { - $this->logger->info('api_docs swagger not enable'); + $this->logger->debug('api_docs swagger not enable'); return; } if (! $this->swaggerConfig->getOutputDir()) { From 88fd5063220d475ed61b5c05f24851d39cefe8eb Mon Sep 17 00:00:00 2001 From: tw Date: Tue, 1 Sep 2026 16:40:28 +0800 Subject: [PATCH 42/43] =?UTF-8?q?refactor(Swagger):=20=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E7=B1=BB=E6=96=B9=E6=B3=95=E8=B7=AF=E5=BE=84=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除不必要的类名缩写处理逻辑 - 直接使用完整类名和方法名构建路径字符串 - 更新方法注释描述 --- src/Swagger/SwaggerPaths.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Swagger/SwaggerPaths.php b/src/Swagger/SwaggerPaths.php index a45348c..810a61d 100644 --- a/src/Swagger/SwaggerPaths.php +++ b/src/Swagger/SwaggerPaths.php @@ -135,17 +135,11 @@ public function getRouteByOperationId(string $operationId): array } /** - * 获取类方法路径(快速定位后端代码). + * 获取类方法路径(定位后端代码). */ protected function getClassMethodPath(string $fullClassName, string $methodName): string { - $parts = explode('\\', $fullClassName); - $shortParts = []; - for ($i = 0; $i < count($parts) - 1; ++$i) { - $shortParts[] = $parts[$i][0] ?? ''; - } - $shortParts[] = end($parts); - return sprintf('%s', implode('.', $shortParts) . '::' . $methodName); + return sprintf('%s::%s',$fullClassName,$methodName); } /** From 1e13afb2316900b6955c807c942f16811a6fdd42 Mon Sep 17 00:00:00 2001 From: tw Date: Thu, 3 Sep 2026 16:14:07 +0800 Subject: [PATCH 43/43] =?UTF-8?q?test(api-docs):=20=E6=9B=B4=E6=96=B0Swagg?= =?UTF-8?q?er=E8=B7=AF=E5=BE=84=E6=B5=8B=E8=AF=95=E4=BB=A5=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=AE=8C=E6=95=B4=E6=96=B9=E6=B3=95=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改断言以检查完整的类方法路径格式 - 移除对简短命名空间格式的验证 - 确保测试覆盖Hyperf框架控制器方法的完整路径生成 --- tests/SwaggerPathsTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/SwaggerPathsTest.php b/tests/SwaggerPathsTest.php index aaa7b50..fd5648d 100644 --- a/tests/SwaggerPathsTest.php +++ b/tests/SwaggerPathsTest.php @@ -70,8 +70,7 @@ public function testGetClassMethodPath(): void $result = $method->invoke($swaggerPaths, 'Hyperf\ApiDocs\Controller\UserController', 'getUser'); - $this->assertStringContainsString('H.A.C.UserController', $result); - $this->assertStringContainsString('getUser', $result); + $this->assertStringContainsString('Hyperf\ApiDocs\Controller\UserController::getUser', $result); } public function testGetClassMethodPathWithShortNamespace(): void