Skip to content
Merged

K3 #44

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
run: |
php .github/workflows/add_composer_stability.php
composer require hyperf/di:3.2.*
composer require tangwei/dto:dev-master
composer require tangwei/dto:dev-master -W
composer update -o
composer info

Expand Down
61 changes: 61 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 中的版本号容易滞后)。
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![Latest Stable Version](https://img.shields.io/packagist/v/tangwei/apidocs)](https://packagist.org/packages/tangwei/apidocs)
[![Total Downloads](https://img.shields.io/packagist/dt/tangwei/apidocs)](https://packagist.org/packages/tangwei/apidocs)
[![License](https://img.shields.io/packagist/l/tangwei/apidocs)](https://github.com/tw2066/api-docs)
[![PHP Version](https://img.shields.io/badge/php-%3E%3D8.1-blue)](https://www.php.net)
[![PHP Version](https://img.shields.io/badge/php-%3E%3D8.2-blue)](https://www.php.net)

[English](./README_EN.md) | 中文

Expand All @@ -22,8 +22,8 @@

## 📋 环境要求

- PHP >= 8.1
- Hyperf >= 3.0
- PHP >= 8.2
- Hyperf ~3.2
- Swoole >= 5.0 或 Swow

## 💡 使用须知
Expand Down Expand Up @@ -139,7 +139,7 @@ return [
| 设置swagger资源路径,cdn资源
|--------------------------------------------------------------------------
*/
'prefix_swagger_resources' => 'https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.5.0',
'prefix_swagger_resources' => 'https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.27.1',

/*
|--------------------------------------------------------------------------
Expand Down Expand Up @@ -719,7 +719,7 @@ public function upload(#[RequestFormData] UploadRequest $request)
访问不同的 UI 界面:

- **Swagger UI**: `http://your-host:9501/swagger`
- **Knife4j**: `http://your-host:9501/swagger/knife4j`
- **Knife4j**: `http://your-host:9501/swagger/doc`(需安装 `tangwei/knife4j-ui`)
- **Redoc**: `http://your-host:9501/swagger/redoc`
- **RapiDoc**: `http://your-host:9501/swagger/rapidoc`
- **Scalar**: `http://your-host:9501/swagger/scalar`
Expand Down Expand Up @@ -769,7 +769,7 @@ class DemoQuery

### RPC 支持

[返回 PHP 对象](https://hyperf.wiki/3.1/#/zh-cn/json-rpc?id=%e8%bf%94%e5%9b%9e-php-%e5%af%b9%e8%b1%a1)
[返回 PHP 对象](https://hyperf.wiki/3.2/#/zh-cn/json-rpc?id=%e8%bf%94%e5%9b%9e-php-%e5%af%b9%e8%b1%a1)

aspects.php 中配置:

Expand Down Expand Up @@ -865,7 +865,7 @@ public function getUser(): UserResponse

### Q: 支持哪些验证规则?

A: 支持所有 Hyperf Validation 规则。详见 [Hyperf 验证器文档](https://hyperf.wiki/3.1/#/zh-cn/validation)。
A: 支持所有 Hyperf Validation 规则。详见 [Hyperf 验证器文档](https://hyperf.wiki/3.2/#/zh-cn/validation)。

### Q: `AutoController` 注解支持吗?

Expand Down
20 changes: 11 additions & 9 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
[![Latest Stable Version](https://img.shields.io/packagist/v/tangwei/apidocs)](https://packagist.org/packages/tangwei/apidocs)
[![Total Downloads](https://img.shields.io/packagist/dt/tangwei/apidocs)](https://packagist.org/packages/tangwei/apidocs)
[![License](https://img.shields.io/packagist/l/tangwei/apidocs)](https://github.com/tw2066/api-docs)
[![PHP Version](https://img.shields.io/badge/php-%3E%3D8.1-blue)](https://www.php.net)
[![PHP Version](https://img.shields.io/badge/php-%3E%3D8.2-blue)](https://www.php.net)

English | [中文](./README.md)

Automatic Swagger/OpenAPI documentation generator for the [Hyperf](https://github.com/hyperf/hyperf) framework, supporting Swoole/Swow engines, providing an elegant and powerful API documentation solution.

## ✨ Features

- 🚀 **Auto Generation** - Automatically generate OpenAPI 3.0 documentation based on PHP 8 Attributes
- 🚀 **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
Expand All @@ -22,8 +22,8 @@ Automatic Swagger/OpenAPI documentation generator for the [Hyperf](https://githu

## 📋 Requirements

- PHP >= 8.1
- Hyperf >= 3.0
- PHP >= 8.2
- Hyperf ~3.2
- Swoole >= 5.0 or Swow

## 💡 Important Notes
Expand Down Expand Up @@ -237,14 +237,16 @@ return [
php bin/hyperf.php start
```

After successful startup, visit `http://your-host:9501/swagger` to view the API documentation.

```
[INFO] Swagger docs url at http://0.0.0.0:9501/swagger
[INFO] Worker#0 started.
[INFO] HTTP Server listening at 0.0.0.0:9501
```

- After successful startup, visit `http://your-host:9501/swagger` to view the API documentation.
- Visit `http://your-host:9501/swagger/llms.txt` for links to a Markdown page per controller, which can be used by AI to quickly access the API documentation.
- Other servers can visit `http://your-host:9501/swagger/{service-name}.md` to access the Markdown documentation of the `{service-name}` server.

## 📖 Usage Guide

### Basic Example
Expand Down Expand Up @@ -848,7 +850,7 @@ public function upload(#[RequestFormData] UploadRequest $request)
Access different UI interfaces:

- **Swagger UI**: `http://your-host:9501/swagger`
- **Knife4j**: `http://your-host:9501/swagger/knife4j`
- **Knife4j**: `http://your-host:9501/swagger/doc` (requires `tangwei/knife4j-ui`)
- **Redoc**: `http://your-host:9501/swagger/redoc`
- **RapiDoc**: `http://your-host:9501/swagger/rapidoc`
- **Scalar**: `http://your-host:9501/swagger/scalar`
Expand Down Expand Up @@ -898,7 +900,7 @@ class DemoQuery

### RPC Support

[Return PHP Object](https://hyperf.wiki/3.1/#/en/json-rpc?id=returning-php-objects)
[Return PHP Object](https://hyperf.wiki/3.2/#/en/json-rpc?id=returning-php-objects)

Configure in aspects.php:

Expand Down Expand Up @@ -996,7 +998,7 @@ public function getUser(): UserResponse

### Q: What validation rules are supported?

A: All Hyperf Validation rules are supported. See [Hyperf Validation Documentation](https://hyperf.wiki/3.1/#/en/validation).
A: All Hyperf Validation rules are supported. See [Hyperf Validation Documentation](https://hyperf.wiki/3.2/#/en/validation).

### Q: Does `AutoController` annotation work?

Expand Down
12 changes: 12 additions & 0 deletions src/Swagger/GenerateParameters.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public function __construct(
protected string $action,
protected array $apiHeaderArr,
protected array $apiFormDataArr,
protected string $route,
protected ContainerInterface $container,
protected MethodDefinitionCollectorInterface $methodDefinitionCollector,
protected SwaggerComponents $swaggerComponents,
Expand All @@ -57,6 +58,9 @@ public function generate(): array
// 判断是否为简单类型
$simpleSwaggerType = $this->common->getSimpleType2SwaggerType($parameterClassName);
if ($simpleSwaggerType !== null) {
if (! $this->isPathParam($paramName)) {
continue;
}
$parameter = new OA\Parameter();
$parameter->required = true;
$parameter->name = $paramName;
Expand Down Expand Up @@ -270,4 +274,12 @@ protected function getPropertiesByBaseParam(array $baseParam): array
}
return ['propertyArr' => $propertyArr, 'requiredArr' => $requiredArr];
}

/**
* 判断参数是否为路由路径占位符(支持 {id}、{id:\d+} 及 { id }、{id : \d+} 等空白变体,与 FastRoute 规则对齐).
*/
protected function isPathParam(string $paramName): bool
{
return preg_match('/\{\s*' . preg_quote($paramName, '/') . '\s*(?::[^{}]*(?:\{[^{}]*\}[^{}]*)*)?\}/', $this->route) === 1;
}
}
3 changes: 2 additions & 1 deletion src/Swagger/GenerateResponses.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ public function generate(): array
$content && $response->content = $content;
$arr[$code] = $response;

$annotationResp && $arr = Arr::merge($arr, $annotationResp);
// 优先级:方法级 ApiResponse 注解 > 全局 responses 配置
$globalResp && $arr = Arr::merge($arr, $globalResp);
$annotationResp && $arr = Arr::merge($arr, $annotationResp);

return array_values($arr);
}
Expand Down
21 changes: 20 additions & 1 deletion src/Swagger/SwaggerComponents.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -151,6 +156,8 @@ public function generateSchemas(string $className)
}
$schema = new OA\Schema();
$schema->schema = $simpleClassName;
// 先登记再解析属性,防止循环引用类(A ↔ B)导致无限递归
$this->schemas[$simpleClassName] = $schema;

$data = $this->getProperties($className);
$schema->properties = $data['propertyArr'];
Expand All @@ -160,7 +167,19 @@ public function generateSchemas(string $className)
$schema->description = $apiModel->value;
}
$data['requiredArr'] && $schema->required = $data['requiredArr'];
$this->schemas[$simpleClassName] = $schema;
return $this->schemas[$simpleClassName];
}

protected function buildPropertyFromReflection(\ReflectionProperty $reflectionProperty): Property
{
$property = new Property();
$phpType = $this->common->getTypeName($reflectionProperty);
if ($this->common->isSimpleType($phpType)) {
$property->phpSimpleType = $phpType;
} else {
$property->isSimpleType = false;
$property->className = $phpType;
}
return $property;
}
}
2 changes: 1 addition & 1 deletion src/Swagger/SwaggerPaths.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
90 changes: 90 additions & 0 deletions tests/GenerateParametersTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace HyperfTest\ApiDocs;

use Hyperf\ApiDocs\Swagger\GenerateParameters;
use Hyperf\ApiDocs\Swagger\SwaggerCommon;
use Hyperf\ApiDocs\Swagger\SwaggerComponents;
use Hyperf\Di\MethodDefinitionCollectorInterface;
use Hyperf\Di\ReflectionType;
use Hyperf\DTO\Scan\MethodParametersManager;
use Hyperf\DTO\Scan\PropertyEnum;
use Hyperf\DTO\Scan\PropertyManager;
use Mockery as m;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;

/**
* @internal
* @coversNothing
*/
class GenerateParametersTest extends TestCase
{
protected function tearDown(): void
{
m::close();
}

/**
* 路由占位符中的简单类型参数应生成为 path 必填.
*/
public function testPathParamInRoute(): void
{
$result = $this->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();
}
}
Loading
Loading