Просмотр исходного кода

feat(openapi): 响应类型改为读 Resource 的 @return array{} 声明

此前响应字段类型只能按字段名猜(_at 结尾→string、status/type→integer),
会猜错:channels.type 与 source_id 都是 varchar 却被标成 integer。

- 生成器新增 array shape 解析:支持嵌套 array{}、array<T>、T[]、
  联合类型(含 null/false 标为 nullable)、key? 标注「仅在部分口径下返回」
- 解析不出形状时自动退回按字段名猜,不影响未声明的 Resource
- ChannelResource 补上声明,类型取自 channels 表的列定义

实测 /v2/channel-name/{name} 的 12 个字段与真实响应逐项吻合,此前猜错 4 个。
redocly lint 仍 0 error。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 3 недель назад
Родитель
Сommit
c18788412a

+ 19 - 2
api-v13/app/Http/Resources/ChannelResource.php

@@ -3,7 +3,6 @@
 namespace App\Http\Resources;
 namespace App\Http\Resources;
 
 
 use App\Http\Api\StudioApi;
 use App\Http\Api\StudioApi;
-use Illuminate\Contracts\Support\Arrayable;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;
 use Illuminate\Http\Resources\Json\JsonResource;
 
 
@@ -12,8 +11,26 @@ class ChannelResource extends JsonResource
     /**
     /**
      * Transform the resource into an array.
      * Transform the resource into an array.
      *
      *
+     * 类型取自 channels 表的列定义:type / source_id 都是 varchar 而不是数字,
+     * progress 是 double。带 ? 的键只在特定查询口径下出现。
+     *
      * @param  Request  $request
      * @param  Request  $request
-     * @return array|Arrayable|\JsonSerializable
+     * @return array{
+     *     uid: string,
+     *     name: string,
+     *     summary: string|null,
+     *     type: string,
+     *     studio: array{id: string, nickName: string, realName: string, studioName: string}|false,
+     *     lang: string,
+     *     is_system: bool,
+     *     status: int,
+     *     created_at: string,
+     *     updated_at: string,
+     *     source_type: string|null,
+     *     source_id: string|null,
+     *     progress?: float,
+     *     role?: string
+     * }
      */
      */
     public function toArray($request)
     public function toArray($request)
     {
     {

+ 31 - 1
openapi/README.md

@@ -21,7 +21,8 @@ npm run lint                  # redocly 校验,应为 0 error
    - `$request->input()/query()/has()/boolean()/integer()` → 查询参数与默认值
    - `$request->input()/query()/has()/boolean()/integer()` → 查询参数与默认值
    - `switch ($request->input('view'))` 的 case → 参数枚举
    - `switch ($request->input('view'))` 的 case → 参数枚举
    - `validate([...])` / `Validator::make()` → 请求体字段、必填与类型
    - `validate([...])` / `Validator::make()` → 请求体字段、必填与类型
-   - 返回的 `App\Http\Resources\*Resource::toArray()` 的键 → 响应 schema
+   - 返回的 `App\Http\Resources\*Resource::toArray()` → 响应 schema。
+     该方法上写了 `@return array{...}` 就按声明取类型;没写则退回按字段名猜
 3. 写出 `main.yaml` + 每个 path 一个 `resources/auto/<slug>.yaml`。
 3. 写出 `main.yaml` + 每个 path 一个 `resources/auto/<slug>.yaml`。
 
 
 ## 在控制器里写参数说明(首选做法)
 ## 在控制器里写参数说明(首选做法)
@@ -51,6 +52,35 @@ npm run lint                  # redocly 校验,应为 0 error
 - 标注过的参数覆盖源码推断的结果;没标注的参数仍然按源码推断输出,不会丢。
 - 标注过的参数覆盖源码推断的结果;没标注的参数仍然按源码推断输出,不会丢。
 - `@deprecated` 会把该 operation 标成废弃。
 - `@deprecated` 会把该 operation 标成废弃。
 
 
+## 让响应类型来自声明,而不是猜测
+
+没有声明时,生成器只能按字段名猜类型(`_at` 结尾→string、`is_` 开头→boolean、
+`_id` / `status` / `type` 等→integer),会猜错——比如 `channels.type` 其实是
+varchar,却被猜成 integer。
+
+给 Resource 的 `toArray()` 补一个 `@return array{...}` 就能让类型变成真声明:
+
+```php
+/**
+ * @return array{
+ *     uid: string,
+ *     type: string,
+ *     summary: string|null,
+ *     studio: array{id: string, nickName: string}|false,
+ *     status: int,
+ *     progress?: float
+ * }
+ */
+public function toArray($request)
+```
+
+- `string|null`、`|false` → 标成 `nullable: true`,联合类型取第一个具体类型
+- `key?:` → 该键只在部分口径下返回,会在描述里注明
+- 支持嵌套 `array{...}`、`array<T>`、`T[]`
+- 解析不出形状时自动退回按字段名猜,不会报错
+
+范例见 `api-v13/app/Http/Resources/ChannelResource.php`。
+
 ## 目录
 ## 目录
 
 
 | 路径                                               | 说明                                             |
 | 路径                                               | 说明                                             |

+ 15 - 4
openapi/public/assets/protocol/resources/auto/v2-channel-name-name.yaml

@@ -34,10 +34,17 @@ get:
                     type: string
                     type: string
                   summary:
                   summary:
                     type: string
                     type: string
+                    nullable: true
                   type:
                   type:
-                    type: integer
-                  studio:
                     type: string
                     type: string
+                  studio:
+                    type: object
+                    properties:
+                      id: { type: string }
+                      nickName: { type: string }
+                      realName: { type: string }
+                      studioName: { type: string }
+                    nullable: true
                   lang:
                   lang:
                     type: string
                     type: string
                   is_system:
                   is_system:
@@ -50,12 +57,16 @@ get:
                     type: string
                     type: string
                   source_type:
                   source_type:
                     type: string
                     type: string
+                    nullable: true
                   source_id:
                   source_id:
-                    type: integer
+                    type: string
+                    nullable: true
                   progress:
                   progress:
-                    type: integer
+                    type: number
+                    description: 仅在部分查询口径下返回。
                   role:
                   role:
                     type: string
                     type: string
+                    description: 仅在部分查询口径下返回。
     401:
     401:
       $ref: '../../main.yaml#/components/responses/Unauthorized'
       $ref: '../../main.yaml#/components/responses/Unauthorized'
     422:
     422:

+ 180 - 0
openapi/scripts/generate.php

@@ -356,6 +356,12 @@ function resourceSchema(?string $class): ?array
         if ($m->name->toString() !== 'toArray') {
         if ($m->name->toString() !== 'toArray') {
             continue;
             continue;
         }
         }
+        // 声明优先:toArray 上写了 @return array{...} 就用它,类型是真声明而非按字段名猜
+        if ($doc = $m->getDocComment()) {
+            if ($shape = parseArrayShape($doc->getText())) {
+                return $shape;
+            }
+        }
         foreach ($finder->findInstanceOf($m, Node\Expr\Array_::class) as $array) {
         foreach ($finder->findInstanceOf($m, Node\Expr\Array_::class) as $array) {
             foreach ($array->items as $item) {
             foreach ($array->items as $item) {
                 if ($item && $item->key instanceof Node\Scalar\String_) {
                 if ($item && $item->key instanceof Node\Scalar\String_) {
@@ -375,6 +381,180 @@ function resourceSchema(?string $class): ?array
     return $props ?: null;
     return $props ?: null;
 }
 }
 
 
+/**
+ * 解析 PHPDoc 里的 `@return array{uid: string, studio: array{...}|false, progress?: float}`。
+ *
+ * 支持嵌套 array{}、联合类型(取第一个具体类型,含 null/false 则标 nullable)、
+ * `key?:` 表示该键只在部分口径下出现。解析不出形状时返回 null,调用方回落到按字段名猜。
+ *
+ * @return array<string, array>|null
+ */
+function parseArrayShape(string $docText): ?array
+{
+    if (! preg_match('/@return\s+array\s*\{/s', $docText, $m, PREG_OFFSET_CAPTURE)) {
+        return null;
+    }
+    // 去掉每行开头的 * 再找花括号,避免注释前缀干扰
+    $body = preg_replace('#^\s*\*\s?#m', '', $docText);
+    if (! preg_match('/@return\s+array\s*\{(.*)/s', $body, $mm)) {
+        return null;
+    }
+    $inner = extractBraced($mm[1]);
+
+    return $inner === null ? null : shapeToProperties($inner);
+}
+
+/** 从 `{` 之后的文本里取出配平的内容 */
+function extractBraced(string $text): ?string
+{
+    $depth = 1;
+    $out = '';
+    $len = strlen($text);
+    for ($i = 0; $i < $len; $i++) {
+        $ch = $text[$i];
+        if ($ch === '{') {
+            $depth++;
+        } elseif ($ch === '}') {
+            $depth--;
+            if ($depth === 0) {
+                return $out;
+            }
+        }
+        $out .= $ch;
+    }
+
+    return null;
+}
+
+/** 按顶层逗号切分,忽略嵌套花括号与尖括号里的逗号 */
+function splitTopLevel(string $text): array
+{
+    $parts = [];
+    $buf = '';
+    $depth = 0;
+    $len = strlen($text);
+    for ($i = 0; $i < $len; $i++) {
+        $ch = $text[$i];
+        if ($ch === '{' || $ch === '<') {
+            $depth++;
+        } elseif ($ch === '}' || $ch === '>') {
+            $depth--;
+        }
+        if ($ch === ',' && $depth === 0) {
+            $parts[] = $buf;
+            $buf = '';
+
+            continue;
+        }
+        $buf .= $ch;
+    }
+    if (trim($buf) !== '') {
+        $parts[] = $buf;
+    }
+
+    return $parts;
+}
+
+/** array shape 的内容 → OpenAPI properties */
+function shapeToProperties(string $inner): array
+{
+    $props = [];
+    foreach (splitTopLevel($inner) as $part) {
+        $part = trim($part);
+        if ($part === '' || ! str_contains($part, ':')) {
+            continue;
+        }
+        [$key, $type] = explode(':', $part, 2);
+        $key = trim($key);
+        $optional = str_ends_with($key, '?');
+        $key = rtrim($key, '?');
+        if ($key === '') {
+            continue;
+        }
+        $schema = phpTypeToSchema(trim($type));
+        if ($optional) {
+            $schema['description'] = trim(($schema['description'] ?? '').' 仅在部分查询口径下返回。');
+        }
+        $props[$key] = $schema;
+    }
+
+    return $props;
+}
+
+/** PHPDoc 类型 → OpenAPI schema */
+function phpTypeToSchema(string $type): array
+{
+    $type = trim($type);
+    $nullable = false;
+    if (str_starts_with($type, '?')) {
+        $nullable = true;
+        $type = substr($type, 1);
+    }
+    // 联合类型:null / false 视为可空,取第一个具体类型
+    $members = splitTopLevel(str_replace('|', ',', preg_replace('/\|/', '|', $type)));
+    if (count($members) > 1) {
+        $concrete = [];
+        foreach ($members as $mem) {
+            $mem = trim($mem);
+            if (in_array(strtolower($mem), ['null', 'false', 'true'], true)) {
+                $nullable = true;
+
+                continue;
+            }
+            $concrete[] = $mem;
+        }
+        $type = $concrete ? $concrete[0] : 'mixed';
+    }
+    $type = trim($type);
+
+    // 嵌套 array{...}
+    if (preg_match('/^array\s*\{(.*)$/s', $type, $m)) {
+        $innerText = extractBraced($m[1]);
+        $schema = ['type' => 'object'];
+        if ($innerText !== null && ($p = shapeToProperties($innerText))) {
+            $schema['properties'] = $p;
+        }
+        if ($nullable) {
+            $schema['nullable'] = true;
+        }
+
+        return $schema;
+    }
+    // 列表:array<T> / T[] / list<T>
+    if (preg_match('/^(?:array|list)\s*<(.+)>$/s', $type, $m)) {
+        $args = splitTopLevel($m[1]);
+        $itemType = trim(end($args));
+        $schema = ['type' => 'array', 'items' => phpTypeToSchema($itemType)];
+        if ($nullable) {
+            $schema['nullable'] = true;
+        }
+
+        return $schema;
+    }
+    if (str_ends_with($type, '[]')) {
+        $schema = ['type' => 'array', 'items' => phpTypeToSchema(substr($type, 0, -2))];
+        if ($nullable) {
+            $schema['nullable'] = true;
+        }
+
+        return $schema;
+    }
+
+    $schema = match (strtolower($type)) {
+        'int', 'integer' => ['type' => 'integer'],
+        'float', 'double' => ['type' => 'number'],
+        'bool', 'boolean' => ['type' => 'boolean'],
+        'array' => ['type' => 'array', 'items' => ['type' => 'string']],
+        'object', 'mixed', '' => ['type' => 'object'],
+        default => ['type' => 'string'],
+    };
+    if ($nullable) {
+        $schema['nullable'] = true;
+    }
+
+    return $schema;
+}
+
 function guessType(string $field): string
 function guessType(string $field): string
 {
 {
     if (str_ends_with($field, '_at') || $field === 'created' || $field === 'updated') {
     if (str_ends_with($field, '_at') || $field === 'created' || $field === 'updated') {