AKR

Article

openapi-metadata 软广

· 3 min read ·

为什么我要写这篇文章呢?因为事实上,我已经关注 TypeScript 生态里的 OpenAPI generation 非常长的时间了。之所以比较关心这个,是因为我实在是懒得手写 Client Request 相关的胶水代码。你知道的,上过班的人都觉得这简直烦爆了。如果以 OpenAPI 为 SSOT(单一真理来源)的话,至少你在 judge 后端的时候,还能获得一丝道义上的优势。

好的,闲话少说,现在开始。

什么是 openapi-metadata?

首先,openapi-metadata 是又一个(yet another)OpenAPI generation 的库。但是在 TypeScript 生态里,这其实是头一个——也不能说头一个,因为之前类似功能的库仅限于 NestJS(NestJS 有一个官方的 Swagger package)。

这些 OpenAPI metadata 的用途,就是在一个 Controller Class 上面挂满各种各样的装饰器(Decorator),用来描述:

  1. Request 的方式
  2. Request 所用的 DTO 和 VO
  3. Response 会报什么错、状态码是什么
  4. 返回的 Body 里会有什么 Message 等信息

程序会根据这些 Decorator 来生成一个 OpenAPI 文档。

这么说可能有点抽象,来给你看几段代码就明白了:

import { Body, Controller, Get, Logger, Post } from '@nestjs/common';
import {
  ApiOperation,
  ApiResponse,
  ApiTags,
} from 'openapi-metadata/decorators';
import { AuthService } from './auth.service';
import { Auth } from './decorators/auth.decorators';
import { Session } from './decorators/session.decorator';
import { UserSession } from './types/auth.types';
import { GetSessionVO } from './vo/get-session.vo';

@ApiTags('session')
@Controller('session')
export class AuthController {
  private readonly logger = new Logger(AuthController.name);

  constructor(private readonly authService: AuthService) {}

  @Auth()
  @Get()
  @ApiResponse({
    status: 200,
    description: 'Get current user session',
    type: GetSessionVO,
  })
  @ApiOperation({
    methods: ['get'],
    path: '/session',
    summary: 'Get current user session',
  })
  getSession(@Session() session: UserSession) {
    this.logger.log(`Fetching session for user ID: ${session.user.id}`);
    return GetSessionVO.fromSession(session);
  }
}

可能写其他语言的人会觉得有点诧异:“啊?这种东西居然今天才有吗?“我只能说,那确实是的。我们写了那么久 Java,写了那么久 Go,也见过用 kdoc、go comment 里生成的,但在我们 JS/TS 圈子里,拥有这样一个比较通用、正常的库,这还是头一次。

这发生了什么

只能说如果你有 NestJS 的使用经验,那你对这套逻辑会非常熟悉;但如果你不了解 NestJS,我还是需要讲解一下底层到底发生了什么。

首先,TypeScript 的 Decorator(装饰器)可以在 Class 上 emit(发射)一些元数据。以 NestJS 自带的 @Controller 为例:

  1. 这个装饰器会标记该 Class 下的所有路由都位于某个特定路径下。
  2. 方法上的 @Get 标记则说明该方法会处理 GET 请求。
  3. 本质上,它是通过另一种方式对 Class 及其方法进行了包装。

像 NestJS 和 OpenAPI Metadata 这种库,它们会获取这些 Class 的实例,并在 Runtime(运行时)通过附加在实例上的 Metadata 来提取信息:

  • 在这个场景下,NestJS 会读取 Controller 所代表的路由信息。
  • OpenAPI Metadata 则会读取 Controller 上带有 OpenAPI 相关的标记。

值得一提的是,TypeScript 的 Decorator 在编译后,还会额外 emit 一个当前字段的类型信息(Metadata),例如它是 String、Number 还是某个特定的 Class。

这就给了 TypeScript Decorator 相关的库在 Runtime 获取 TypeScript 类型信息的方式。

但是这种方式实际上是远远偏离 JS 标准的,所以也不用指望它在什么时候能被 JS 标准化。

所以这意味着什么呢?

这意味着所有使用 Class 为组织方式的 JS HTTP 框架,最终都获得了获取 OpenAPI Spec 的方式。

哪怕是 NestJS 本身自带一个 Swagger Package 的方案,但由于每次获取 OpenAPI 时都要启动 NestJS 实例,这个生成过程其实是很不方便的。有时候你需要在开发阶段去 Generate、Inject 一些环境信息,而我的 App 通常只运行在 Docker 下,维护这两个环境的互操作其实非常麻烦。

尤其是 AdonisJS,我在此之前不肯采用它,也一直是由于它不支持 OpenAPI Generation,现在我可以放心大胆地用了。

其他方案?

在我看来,其他方案就没那么优雅,或者说代价过大了。

我就是在说那些所谓很先进的 HTTP 框架,比如 Hono。你可以看一下 Hono 有一个用 Zod 生成 OpenAPI Spec 的方案,实在是丑得离谱:

  1. 它不仅要破坏原先 Hono Zod Validator 的优雅范式
  2. 还要写一大堆模板代码
  3. 定义一个路由需要写三个 code block
  4. 完全破坏了 Hono 链式调用的美感,实在令人胆寒

此外还有一个叫 oRPC 的方案,这个其实也不错。但 oRPC 最大的问题在于它本质是一个 RPC,其 OpenAPI Server 相关的功能并不是主体。这意味着我要去忍受它作为 RPC 的一大堆特性,而我只想好好地写一个 HTTP 后端。

当然这么说可能有点偏颇,确实是。我也知道 Express 上那些 OpenAPI 方案,如果你觉得那玩意儿那么恶心也能用的话,那你就用吧,我没意见,真的:

// ./api-v1/paths/worlds.js
export default function(worldsService) {
  let operations = {
    GET
  };

  function GET(req, res, next) {
    res.status(200).json(worldsService.getWorlds(req.query.worldName));
  }

  // NOTE: We could also use a YAML string here.
  GET.apiDoc = {
    summary: 'Returns worlds by name.',
    operationId: 'getWorlds',
    parameters: [
      {
        in: 'query',
        name: 'worldName',
        required: true,
        type: 'string'
      }
    ],
    responses: {
      200: {
        description: 'A list of worlds that match the requested name.',
        schema: {
          type: 'array',
          items: {
            $ref: '#/definitions/World'
          }
        }
      },
      default: {
        description: 'An error occurred',
        schema: {
          additionalProperties: true
        }
      }
    }
  };

  return operations;
}

结尾

你说的对,但是我已经转 Kotlin 了。

所以吹完之后,你问我用吗?我不用。老项目已经迁移过来了,但是新项目不想考虑了。