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 1b6b8b9..97fbfe2 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.2-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.2 +- Hyperf ~3.2 +- 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` + +### 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 +> 完整配置文件示例:config/autoload/api_docs.php
- 配置详情 + 完整配置说明(点击展开)

```php @@ -103,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', /* |-------------------------------------------------------------------------- @@ -192,295 +228,511 @@ return [

+### 3. 启动服务 -### 2. 直接启动框架(需要有http服务) - -```shell script +```bash php bin/hyperf.php start - +``` +``` [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)页面 +- 启动成功后,访问 `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 文档。 + +## 📖 使用指南 + +### 基础示例 + +#### 1. 定义 DTO 类 + +```php + 命名空间:`Hyperf\DTO\Annotation\Contracts` +class UserRequest +{ + #[ApiModelProperty('用户名')] + #[Required] + public string $username; -#### #[RequestBody] 注解 + #[ApiModelProperty('年龄')] + #[Required] + #[Integer] + #[Between(1, 120)] + public int $age; + + #[ApiModelProperty('邮箱')] + public ?string $email = null; +} +``` -- 获取Body参数 +#### 2. 编写控制器 ```php -public function add(#[RequestBody] DemoBodyRequest $request){} + 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, + ]; + } +} ``` -#### #[RequestQuery] 注解 +## 🎨 注解参考 -- 获取GET参数 +### 控制器注解 + +#### `#[Api]` - 控制器标签 ```php -public function add(#[RequestQuery] DemoQuery $request){} +#[Api( + tags: '用户管理', // 标签名称(支持数组) + description: '用户相关操作', // 描述 + position: 1, // 排序位置 + hidden: false // 是否隐藏 +)] ``` -#### #[RequestFormData] 注解 +#### `#[ApiOperation]` - API 操作 + +```php +#[ApiOperation( + summary: '创建用户', // 摘要 + description: '详细描述', // 详细描述 + deprecated: false, // 是否废弃 + security: true, // 是否需要认证 + hidden: false // 是否隐藏 +)] +``` -- 获取表单请求 +#### `#[ApiResponse]` - 响应定义 ```php -public function fromData(#[RequestFormData] DemoFormData $formData){} +// 简单类型响应 +#[ApiResponse(PhpType::INT, 200, '成功')] + +// 对象响应 +#[ApiResponse(UserResponse::class, 200, '用户信息')] + +// 数组响应 +#[ApiResponse([UserResponse::class], 200, '用户列表')] + +// 分页响应 +#[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; + } +} +``` + +控制器使用: + +```php +#[ApiOperation('分页查询')] +#[GetMapping(path: 'page')] +#[ApiResponse(new Page([UserResponse::class]))] +public function page(#[RequestQuery] PageQuery $query): Page +{ + // 返回分页数据 +} +``` + +### 参数注解 + +#### `#[RequestBody]` - Body 参数 + +获取 POST/PUT/PATCH 请求的 JSON body 参数: + +```php +public function create(#[RequestBody] #[Valid] UserRequest $request) +{ + // $request 自动填充 body 数据 +} ``` -- 获取文件(和表单一起使用) +#### `#[RequestQuery]` - Query 参数 + +获取 URL 查询参数(GET 参数): + +```php +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暂不能定义数组类型,返回的数据类型不能完全满足 +## ✅ 数据验证 - 当不能满足时,可以通过ApiResponse注解来解决 +### 内置验证注解 - ```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(){} - ``` +组件提供丰富的验证注解支持: -* php暂不支持泛型,当返回存在相同结构时候,需要写很多类来返回 +```php +use Hyperf\DTO\Annotation\Validation\*; - 例: 分页只有`content`结构是可变,可以通过`#[ApiVariable]`配合使用 +class UserRequest +{ + #[Required] // 必填 + #[Max(50)] // 最大长度 + public string $username; - ```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; - } - } - ``` + #[Required] + #[Integer] // 整数 + #[Between(1, 120)] // 范围 + public int $age; - 控制器 + #[Email] // 邮箱格式 + public ?string $email; - ```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()); - } - ``` + #[Url] // URL 格式 + public ?string $website; - 通过`#[ApiResponse(new Page([ActivityResponse::class]))]`会生成相应的文档 + #[Regex('/^1[3-9]\d{9}$/')] // 正则验证 + public ?string $mobile; + #[In(['male', 'female'])] // 枚举值 + public ?string $gender; + #[Date] // 日期格式 + public ?string $birthday; +} +``` + +> 💡 **提示**:只需在控制器方法参数中添加 `#[Valid]` 注解即可启用验证 + +```php +public function create(#[RequestBody] #[Valid] UserRequest $request) +{ + // 验证自动执行 +} +``` -## 示例 +### 自定义验证 -### 控制器 +#### 使用 Validation 注解 ```php -#[Controller(prefix: '/demo')] -#[Api(tags: 'demo管理', position: 1)] -class DemoController extends AbstractController +// 支持 Laravel 风格的验证规则 +#[Validation('required|string|min:3|max:50')] +public string $username; + +// 数组元素验证 +#[Validation('integer', customKey: 'ids.*')] +public array $ids; +``` + +#### 自定义验证注解 + +```php +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/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` -## 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 +743,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 +760,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.2/#/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 +790,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.2/#/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..1210583 --- /dev/null +++ b/README_EN.md @@ -0,0 +1,1045 @@ +# 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.2-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/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 +- 🔒 **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.2 +- Hyperf ~3.2 +- 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 +``` + +``` +[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 + +#### 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/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` + +## ⚙️ 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.2/#/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.2/#/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! 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. - } + #[In(['a', 'b'])] + public string $type; + + + } 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 diff --git a/publish/api_docs.php b/publish/api_docs.php index 7439ff7..2928ef4 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', /* |-------------------------------------------------------------------------- @@ -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' => [ [ 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/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/Listener/BootAppRouteListener.php b/src/Listener/BootAppRouteListener.php index 24f0ebe..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()) { @@ -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']); @@ -89,6 +93,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://' . $httpServer['host'] . ':' . $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; } } diff --git a/src/Swagger/GenerateParameters.php b/src/Swagger/GenerateParameters.php index abce312..488044d 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; @@ -31,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, @@ -56,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; @@ -67,12 +72,11 @@ public function generate(): array continue; } + $methodParameter = $this->methodParametersManager->getMethodParameter($this->controller, $this->action, $paramName); 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; @@ -251,4 +255,20 @@ protected function getPropertiesByBaseParam(array $baseParam): array } return ['propertyArr' => $propertyArr, 'requiredArr' => $requiredArr]; } + + /** + * 判断参数是否为路由路径占位符(复用 FastRoute 解析器,与框架路由匹配规则保持一致). + */ + protected function isPathParam(string $paramName): bool + { + $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; + } } diff --git a/src/Swagger/GenerateResponses.php b/src/Swagger/GenerateResponses.php index f882cd2..42c5eec 100644 --- a/src/Swagger/GenerateResponses.php +++ b/src/Swagger/GenerateResponses.php @@ -50,22 +50,23 @@ 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); } -// 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 6873808..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 = []; } /** @@ -32,12 +32,12 @@ public function simpleClassNameClear(): void */ public function getSimpleClassName(?string $className): string { - if ($className === null) { + if (empty($className)) { $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..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; @@ -21,7 +22,7 @@ class SwaggerComponents { - protected static array $schemas = []; + protected array $schemas = []; public function __construct( protected SwaggerCommon $common, @@ -32,12 +33,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 @@ -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; @@ -135,7 +140,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,11 +151,13 @@ 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; + // 先登记再解析属性,防止循环引用类(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']; - self::$schemas[$simpleClassName] = $schema; - return self::$schemas[$simpleClassName]; + 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/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 3c27819..695cf32 100644 --- a/src/Swagger/SwaggerController.php +++ b/src/Swagger/SwaggerController.php @@ -27,17 +27,24 @@ 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); + if (! is_dir($this->outputDir) || ($swaggerFileList = scandir($this->outputDir)) === false) { + throw ApiDocsException::directoryCreationFailed($this->outputDir); + } + $this->swaggerFileList = $swaggerFileList; } 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,26 +53,53 @@ 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); + return $this->fileResponse($filePath)->withHeader('content-type', 'application/json;charset=utf-8'); } 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)->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; - return $this->fileResponse($filePath); + $content = $this->swaggerLlms->detail($operationId, $filePath); + return $this->response->raw($content); } 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/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/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 6e2f583..810a61d 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(); @@ -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] ?? []; + } + /** * 获取类方法路径(定位后端代码). */ @@ -140,17 +145,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 { - $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] = true; + self::$operationIds[$operationId] = [$route, $methods]; return $operationId; } - return $this->getOperationId($operationId . ucfirst(strtolower($methods)), $methods); + return $this->getOperationId($operationId, $methods, ++$num); } /** diff --git a/src/Swagger/SwaggerUiController.php b/src/Swagger/SwaggerUiController.php index f28985d..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; @@ -51,9 +52,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)); } @@ -91,10 +99,14 @@ 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; + $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); } public function favicon(): PsrResponseInterface @@ -102,4 +114,13 @@ public function favicon(): PsrResponseInterface $file = $this->docsWebPath . '/favicon.png'; return $this->fileResponse($file); } + + protected function sanitizeFilePath(string $file): string + { + do { + $file = str_replace(['..', '\\', "\0"], '', $file, $count); + } while ($count > 0); + + return ltrim($file, '/'); + } } 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 @@ 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; + }; + + 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/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/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/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 @@ +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..07c0d7e --- /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..fd5648d --- /dev/null +++ b/tests/SwaggerPathsTest.php @@ -0,0 +1,99 @@ +getProperty('operationIds'); + + $this->assertIsArray($property->getValue($swaggerPaths)); + } + + 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'); + + $result = $method->invoke($swaggerPaths, 'Hyperf\ApiDocs\Controller\UserController', 'getUser'); + + $this->assertStringContainsString('Hyperf\ApiDocs\Controller\UserController::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'); + + $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..1041584 100644 --- a/tests/SwaggerSchemasTest.php +++ b/tests/SwaggerSchemasTest.php @@ -13,6 +13,8 @@ use Hyperf\DTO\Scan\PropertyManager; use HyperfTest\ApiDocs\Request\Address; use HyperfTest\ApiDocs\Request\DemoBodyRequest; +use HyperfTest\ApiDocs\Request\TreeNode; +use HyperfTest\ApiDocs\Request\User; use Mockery as m; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -29,63 +31,149 @@ 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('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); + } + + /** + * 自引用与互相引用的 DTO 不会导致 generateSchemas 无限递归. + */ + public function testCircularReferenceSchemas(): void + { + $swaggerCommon = new SwaggerCommon(); + $swaggerComponents = new SwaggerComponents($swaggerCommon, new PropertyManager($swaggerCommon, new PropertyEnum()), null); - $this->assertEquals($addressProperties[1]->property, 'user'); - $this->assertEquals($addressProperties[1]->ref, '#/components/schemas/User'); + $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(); } diff --git a/tests/SwaggerUiControllerTest.php b/tests/SwaggerUiControllerTest.php new file mode 100644 index 0000000..5d6ab7d --- /dev/null +++ b/tests/SwaggerUiControllerTest.php @@ -0,0 +1,215 @@ +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 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); + $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; + } +}