diff --git a/.github/workflows/add_composer_stability.php b/.github/workflows/add_composer_stability.php deleted file mode 100644 index 9fec7b3..0000000 --- a/.github/workflows/add_composer_stability.php +++ /dev/null @@ -1,43 +0,0 @@ -=8.2", - "tangwei/dto": "~3.2.0", - "zircote/swagger-php": "^6.0" + "php": ">=8.1", + "tangwei/dto": "~3.1.0", + "zircote/swagger-php": "^4.8||^5.1" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", - "hyperf/laminas-mime": "^3.0", "mockery/mockery": "^1.0", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "^1.0", "phpunit/phpunit": ">=7.0", "symfony/var-dumper": "^5.1" }, @@ -48,7 +47,7 @@ "config": "Hyperf\\ApiDocs\\ConfigProvider" }, "branch-alias": { - "dev-master": "3.2.x-dev" + "dev-master": "3.1.x-dev" } }, "config": { diff --git a/example/Controller/DemoController.php b/example/Controller/DemoController.php index a349c33..e8537b5 100644 --- a/example/Controller/DemoController.php +++ b/example/Controller/DemoController.php @@ -65,17 +65,6 @@ public function api(#[RequestQuery] #[Valid] DemoQuery $request): DataType return new DataType(); } - /** - * @param DemoQuery[] $request - */ - #[ApiOperation(summary: '查询测试POST Arr')] - #[PostMapping(path: 'apiArr')] - public function apiArr(#[RequestBody] #[Valid] array $request, Address $address): array - { - dump($request); - return $request; - } - #[ApiOperation(summary: '查询测试POST')] #[PostMapping(path: 'api')] #[ApiHeader(name: 'test', required: true, type: 'string')] diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index b7c8a02..4341d53 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -7,6 +7,7 @@ use Hyperf\ApiDocs\Listener\AfterDtoStartListener; use Hyperf\ApiDocs\Listener\AfterWorkerStartListener; use Hyperf\ApiDocs\Listener\BootAppRouteListener; +use Hyperf\ApiDocs\Listener\DiMapGenerateListener; class ConfigProvider { @@ -19,6 +20,7 @@ public function __invoke(): array AfterDtoStartListener::class, BootAppRouteListener::class, AfterWorkerStartListener::class, + DiMapGenerateListener::class, ], 'annotations' => [ 'scan' => [ diff --git a/src/Listener/DiMapGenerateListener.php b/src/Listener/DiMapGenerateListener.php new file mode 100644 index 0000000..ca8aaf3 --- /dev/null +++ b/src/Listener/DiMapGenerateListener.php @@ -0,0 +1,314 @@ +workerId !== 0) { + return; + } + try { + $this->generate(); + } catch (\Throwable $e) { + $this->logger->error('Generate Di Map file failed: ' . $e->getMessage()); + } + } + + /** + * 生成 di-map.json 到 api_docs 配置的 output_dir。 + */ + public function generate(): void + { + if (! $this->swaggerConfig->isEnable() || ! $outputDir = $this->swaggerConfig->getOutputDir()) { + return; + } + + $path = rtrim($outputDir, '/\\') . '/di-map.json'; + // 绑定来源只有 dependencies.php 与 lazy_loader.php:两者未更新且已有产物时跳过重写 + if (! $this->shouldRegenerate($path)) { + return; + } + $payload = [ + 'base_path' => BASE_PATH, + 'generated_at' => date('c'), + ] + $this->collect(); + // 原子写:避免 IDE 插件读到写了一半的文件 + $tmpPath = $path . '.tmp'; + file_put_contents($tmpPath, json_encode( + $payload, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + )); + rename($tmpPath, $path); + $this->logger->debug('Generate Di Map file: ' . $path); + } + + /** + * 是否需要重新生成:产物不存在时必须生成;否则 dependencies.php(项目绑定表) + * 或 lazy_loader.php(懒加载代理配置,可选文件)比产物新才重新生成。 + */ + private function shouldRegenerate(string $diMapPath): bool + { + if (! is_file($diMapPath)) { + return true; + } + $generatedAt = filemtime($diMapPath); + foreach ([BASE_PATH . '/config/autoload/dependencies.php', BASE_PATH . '/config/lazy_loader.php'] as $source) { + if (is_file($source) && filemtime($source) > $generatedAt) { + return true; + } + } + return false; + } + + /** + * 收集 DI 容器中的所有映射关系(只读,不执行工厂闭包)。 + * resolved 为调用时刻的快照,之后运行时 set() 的条目不包含在内。 + * + * @return array{definitions: array, resolved: array} + */ + public function collect(): array + { + if (! $this->container instanceof Container) { + return ['definitions' => [], 'resolved' => []]; + } + + $definitions = []; + foreach ($this->readProperty($this->container, 'definitionSource')->getDefinitions() as $name => $definition) { + // getDefinition() 的 autowire 缓存会把不存在的类写入 null,跳过 + if (! $definition instanceof DefinitionInterface) { + continue; + } + $definitions[$name] = $this->formatDefinition($definition); + } + + $resolved = []; + foreach ($this->readProperty($this->container, 'resolvedEntries') as $name => $value) { + $class = is_object($value) ? $value::class : null; + $resolved[$name] = [ + 'type' => $class ? 'object' : gettype($value), + 'class' => $class, + 'path' => $class ? $this->getClassPath($class) : null, + ]; + } + + return [ + 'definitions' => $this->filterSelfMapped($definitions), + 'resolved' => $this->filterSelfMapped($resolved), + ]; + } + + /** + * 过滤掉 name 与目标类相同的自映射条目,并按 name 排序。 + * 工厂定义除外:即使产出类与 name 相同,其 path 指向工厂文件,有定位价值。 + * + * @param array $map + * @return array + */ + private function filterSelfMapped(array $map): array + { + $map = array_filter($map, fn (array $d, string $name): bool => $d['class'] !== $name || $d['type'] === 'factory', ARRAY_FILTER_USE_BOTH); + ksort($map); + return $map; + } + + /** + * @return array{type: string, class: ?string, path: ?string} + */ + private function formatDefinition(DefinitionInterface $definition): array + { + if ($definition instanceof ObjectDefinition) { + $class = $definition->getClassName(); + return [ + 'type' => 'object', + 'class' => $class, + 'path' => $this->getClassPath($class), + ]; + } + if ($definition instanceof FactoryDefinition) { + $factory = $definition->getFactory(); + return [ + 'type' => 'factory', + 'class' => $this->getFactoryClass($factory), + 'path' => $this->getFactoryPath($factory), + ]; + } + return [ + 'type' => class_basename($definition), + 'class' => null, + 'path' => null, + ]; + } + + /** + * 工厂定义位置:闭包记录定义文件,类形式(类名、[类, 方法]、类::方法、__invoke 对象)记录类文件路径。 + */ + private function getFactoryPath(callable|string $factory): ?string + { + if ($factory instanceof \Closure) { + $file = (new \ReflectionFunction($factory))->getFileName(); + return $this->localizePath($file ?: null); + } + if (is_array($factory)) { + // [类名或对象, 方法名] + return $this->getClassPath(is_object($factory[0]) ? $factory[0]::class : $factory[0]); + } + if (is_string($factory)) { + // 类名 或 类名::方法 + return $this->getClassPath(explode('::', $factory)[0]); + } + // 实现了 __invoke 的工厂对象 + return $this->getClassPath($factory::class); + } + + /** + * 推断工厂产出的类:反射工厂 callable 声明的返回类型,是类(非标量)则记录,否则为 null。 + * 覆盖闭包、[类, 方法]、类::方法、带 __invoke 的类名或对象;未声明返回类型时为 null。 + */ + private function getFactoryClass(callable|string $factory): ?string + { + try { + $callable = match (true) { + $factory instanceof \Closure => new \ReflectionFunction($factory), + is_array($factory) => new \ReflectionMethod($factory[0], $factory[1]), + is_string($factory) && str_contains($factory, '::') => new \ReflectionMethod(...explode('::', $factory, 2)), + is_string($factory) && method_exists($factory, '__invoke') => new \ReflectionMethod($factory, '__invoke'), + is_object($factory) => new \ReflectionMethod($factory, '__invoke'), + default => null, + }; + } catch (\ReflectionException) { + return null; + } + $type = $callable?->getReturnType(); + if (! $type instanceof \ReflectionNamedType || $type->isBuiltin()) { + return null; + } + $name = $type->getName(); + $lower = strtolower($name); + if (! in_array($lower, ['self', 'static', 'parent'], true)) { + return $name; + } + // self/static/parent 需结合声明类解析 + if (! $callable instanceof \ReflectionMethod) { + return null; + } + if ($lower === 'parent') { + $parent = $callable->getDeclaringClass()->getParentClass(); + return $parent ? $parent->getName() : null; + } + return $callable->getDeclaringClass()->getName(); + } + + private function getClassPath(string $class): ?string + { + if (! class_exists($class) && ! interface_exists($class) && ! enum_exists($class)) { + return null; + } + $file = (new \ReflectionClass($class))->getFileName(); + if (! $file) { + return null; + } + // AOP 代理类的反射路径指向 runtime/container/proxy/*.proxy.php(代理保留原类名); + // 运行时 composer classmap 已被代理路径覆盖(ClassLoader::init 的 addClassMap), + // 只能从磁盘上的 autoload 文件反查原始文件 + if (str_contains(str_replace('\\', '/', $file), '/runtime/container/proxy/')) { + $file = $this->findOriginalClassFile($class) ?? $file; + } + return $this->localizePath($file); + } + + /** + * 路径转相对 BASE_PATH(IDE 插件按应用根解析,规避 WSL/Windows 路径差); + * 不在 BASE_PATH 下(如宿主编译路径、外部挂载)保留绝对路径。 + */ + private function localizePath(?string $file): ?string + { + if ($file === null) { + return null; + } + $prefix = BASE_PATH . '/'; + return str_starts_with($file, $prefix) ? substr($file, strlen($prefix)) : $file; + } + + /** + * 从磁盘上的 composer autoload 文件反查类的原始文件(绕开运行时被代理覆盖的 classmap)。 + */ + private function findOriginalClassFile(string $class): ?string + { + $classMapFile = BASE_PATH . '/vendor/composer/autoload_classmap.php'; + if (is_file($classMapFile)) { + $classMap = include $classMapFile; + if (isset($classMap[$class])) { + return $classMap[$class]; + } + } + $psr4File = BASE_PATH . '/vendor/composer/autoload_psr4.php'; + if (is_file($psr4File)) { + foreach ((array) include $psr4File as $prefix => $dirs) { + if (! str_starts_with($class, (string) $prefix)) { + continue; + } + $relative = str_replace('\\', '/', substr($class, strlen((string) $prefix))) . '.php'; + foreach ((array) $dirs as $dir) { + if (is_file($file = rtrim((string) $dir, '/') . '/' . $relative)) { + return $file; + } + } + } + } + return null; + } + + private function readProperty(object $object, string $property): mixed + { + return (new \ReflectionProperty($object, $property))->getValue($object); + } +} diff --git a/src/Swagger/GenerateParameters.php b/src/Swagger/GenerateParameters.php index 620cdd1..488044d 100644 --- a/src/Swagger/GenerateParameters.php +++ b/src/Swagger/GenerateParameters.php @@ -17,7 +17,6 @@ use Hyperf\DTO\ApiAnnotation; use Hyperf\DTO\DtoConfig; use Hyperf\DTO\Scan\MethodParametersManager; -use Hyperf\DTO\Scan\Property; use Hyperf\DTO\Scan\PropertyManager; use OpenApi\Attributes as OA; use Psr\Container\ContainerInterface; @@ -74,14 +73,6 @@ public function generate(): array } $methodParameter = $this->methodParametersManager->getMethodParameter($this->controller, $this->action, $paramName); - if ($parameterClassName === 'array' && $methodParameter?->isRequestBody()) { - $requestBody = new OA\RequestBody(); - $requestBody->required = true; - $property = $this->methodParametersManager->getProperty($this->controller, $this->action, $paramName); - $requestBody->content = $this->getContent($property->arrClassName ?? '', property: $property); - $result['requestBody'] = $requestBody; - } - if ($this->container->has($parameterClassName)) { if ($methodParameter == null) { continue; @@ -190,32 +181,21 @@ public function getParameterArrByClass(string $parameterClassName, string $in): return $parameters; } - protected function getContent(string $className, string $mediaTypeStr = 'application/json', ?Property $property = null): array + protected function getContent(string $className, string $mediaTypeStr = 'application/json'): array { $arr = []; $mediaType = new OA\MediaType(); $mediaType->mediaType = $mediaTypeStr; - $mediaType->schema = $this->getJsonContent($className, $property); + $mediaType->schema = $this->getJsonContent($className); $arr[] = $mediaType; return $arr; } - protected function getJsonContent(string $className, ?Property $property = null): OA\JsonContent + protected function getJsonContent(string $className): OA\JsonContent { $jsonContent = new OA\JsonContent(); $this->swaggerComponents->generateSchemas($className); - if ($property?->phpSimpleType == 'array') { - $jsonContent->type = 'array'; - $items = new OA\Items(); - if ($property->arrClassName) { - $items->ref = $this->common->getComponentsName($property->arrClassName); - } else { - $items->type = $this->common->getSwaggerType($property->arrSimpleType); - } - $jsonContent->items = $items; - } else { - $jsonContent->ref = $this->common->getComponentsName($className); - } + $jsonContent->ref = $this->common->getComponentsName($className); return $jsonContent; } diff --git a/src/Swagger/GenerateProxyClass.php b/src/Swagger/GenerateProxyClass.php index 763d8a0..39591cb 100644 --- a/src/Swagger/GenerateProxyClass.php +++ b/src/Swagger/GenerateProxyClass.php @@ -32,7 +32,7 @@ public function __construct( $proxyDir = $this->swaggerConfig->getProxyDir(); if (file_exists($proxyDir) === false) { if (mkdir($proxyDir, 0755, true) === false) { - throw ApiDocsException::directoryCreationFailed($proxyDir); + throw new ApiDocsException("Failed to create a directory : {$proxyDir}"); } } } @@ -129,7 +129,7 @@ protected function putContents($generateNamespaceClassName, $content): void protected function phpParser(object $generateClass, $filePath, $propertyArr): array { $code = file_get_contents($filePath); - $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); $ast = $parser->parse($code); $simpleClassName = $this->swaggerCommon->getSimpleClassName($generateClass::class);