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
+
[](https://packagist.org/packages/tangwei/apidocs)
[](https://packagist.org/packages/tangwei/apidocs)
[](https://github.com/tw2066/api-docs)
+[](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)
+
+## 🔗 相关链接
-
+- [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
+
+[](https://packagist.org/packages/tangwei/apidocs)
+[](https://packagist.org/packages/tangwei/apidocs)
+[](https://github.com/tw2066/api-docs)
+[](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 @@
-
-