Skip to content

@snail-js/cli ​

从 OpenAPI 3.0 / 3.1 文档生成 @snail-js/api 的装饰器请求代码。

生成物不是「能跑就行」的胶水:方法签名带上真实的请求体与响应类型,调用 .send() 时 data 直接就是响应 DTO 的类型 —— 这是靠 SnailPayloadOf 从声明的返回类型推导出来的,所以生成器 必须写出 Promise<ResponseDto> 而不是 ResponseDto。

ts
// 生成一次,之后这样用:
const { data } = await petApi.getPetById(1).send(); // data: GetPetByIdResponse

安装 ​

bash
pnpm add -D @snail-js/cli
pnpm add @snail-js/api

@snail-js/cli 的运行时依赖只有 yaml(用于解析 YAML 文档),@snail-js/api 是可选 peer, 仅在生成的代码里被引用。

用 npx / pnpm dlx 跑一次也可以:

bash
pnpm dlx @snail-js/cli generate --input ./openapi.json --out src

snail init ​

在当前项目里创建一个最小可用的服务骨架(默认写到 src/service):

bash
snail init
snail init --out src/service --base-url /api --name backend --force
文件说明
service.ts@Server({ baseURL }) 修饰的 SnailServer 子类,导出唯一的 service 实例
index.ts桶文件,再次导出 service
README.md下一步怎么做(不会被 --clean 删掉,因为没有生成标记)

init 只做一件事:让 service.ts 先存在。因为 generate 产出的 api 文件会 import { service } from "../service",没有它生成物编译不过。

已存在的文件会阻止命令执行,并打印出具体是哪个文件;确认要覆盖时加 --force。

snail generate ​

bash
snail generate --input ./openapi.json --out src
snail g -i ./openapi.yaml -o src --tags pet,store --clean

snail g 是 snail generate 的别名。

选项缩写说明
--input <file>-i必填。OpenAPI 3.0/3.1 文档,.json / .yaml / .yml 都可
--out <dir>-o源码根目录,默认 src。生成器在其下建 apis/、types/
--base-url <url>覆盖 @Server 的 baseURL;默认取文档 servers[0].url,都没有时用 /api
--name <name>写入 @Server 的 name(用于日志、缓存命名空间)
--tags a,b,c只生成这些 tag;没有 tag 的操作算作 default
--exclude-tags a,b排除这些 tag(与 --tags 同时给时,先排除再筛选)
--dry-run只打印会写入的文件,不落盘
--force覆盖不是本工具生成的文件(默认会拒绝并报出文件路径)
--clean先删除输出目录里带生成标记的文件,再写入
--verbose输出调试信息;失败时附带调用栈(默认只打印一行错误)
通用选项说明
-h, --help打印用法
-V, --version打印版本

退出码 ​

码含义
0成功
1生成失败(文档读不了、解析不了、写入被拒等),错误信息在 stderr
2用法错误(缺少 --input、文件不存在、未知选项),什么都没做

默认不打印调用栈;加 --verbose 才打印。

一个 before / after ​

手写(每个接口都要重复一遍):

ts
@Api("/pet")
export class PetApi {
  @Get("/:petId")
  getPetById(@Params("petId") petId: number): Promise<Pet> {
    return null!;
  }
}

snail generate -i openapi.json -o src 之后得到的 src/apis/pet.api.ts:

ts
// Generated by @snail-js/cli. Do not edit by hand.

import { Api, Data, Delete, Get, Params, Post, Query } from "@snail-js/api";
import type { ApiResponse, GetPetByIdResponse, Pet, PetCreate, PetStatus } from "../types/pet";
import { service } from "../service";

/** Everything about your pets. */
@Api("/pet")
export class PetApi {
  /**
   * Add a new pet to the store.
   * POST /pet
   */
  @Post("/")
  addPet(@Data() payload: PetCreate): Promise<Pet> {
    return null!;
  }

  /**
   * Find pets by status.
   * GET /pet/findByStatus
   */
  @Get("/findByStatus")
  findPetsByStatus(@Query("status") status: PetStatus, @Query("limit") limit?: number): Promise<Pet[]> {
    return null!;
  }

  /**
   * Find a pet by id.
   * GET /pet/{petId}
   * Cookie 参数 `session`:@snail-js/api 没有 cookie 装饰器,需要调用方自行写入请求头。
   */
  @Get("/:petId")
  getPetById(@Params("petId") petId: number, session?: string): Promise<GetPetByIdResponse> {
    return null!;
  }
}

/** PetApi 的即用实例,直接调用其方法即可发起请求。 */
export const petApi = service.createApi(PetApi);

方法体永远是 return null!;:这些装饰器不是可执行代码,service.createApi() 会把每个被装饰的 方法替换成一个「请求工厂」,方法体只用来声明参数类型和返回类型。返回类型写成 Promise<Pet>,SnailPayloadOf 才会把 Pet 取出来当作 data 的类型。

生成的文件结构 ​

--out src 时:

text
src/
  apis/
    pet.api.ts        # 每个 tag 一个 @Api 类 + 一个即用实例
    store.api.ts
    default.api.ts    # 没有 tag 的操作,类名 DefaultApi
  types/
    pet.ts            # 该 tag 用到的接口/类型别名
    store.ts
    index.ts          # 类型的桶文件(export type *)
  service.ts          # @Server({ baseURL }) 的 SnailServer 子类,导出 service
  index.ts            # 桶文件:re-export 每个 api 实例
  • 一个 tag 一个类:类名 <PascalTag>Api,实例名 <camelTag>Api(default → defaultApi)。
  • 类上的 @Api("...") 是该 tag 所有操作的最长公共路径前缀;只有一个操作的 tag 前缀为空字符串, 路径完整写在方法装饰器上。例如 /pet、/pet/findByStatus、/pet/{petId} → @Api("/pet") + @Get("/")、@Get("/findByStatus")、@Get("/:petId")。
  • OpenAPI 的 {petId} 会变成 :petId,对应参数用 @Params("petId");占位符名字会被清洗成合法标识符。
  • 装饰器参数里永远是文档里的原始名字(@Query("page-size")),而变量名会被清洗成合法标识符(pageSize)。
  • 类型按「第一次用到它的 tag」归属到 types/<tag>.ts;跨文件引用会生成 import type { Order } from "./store";。types/index.ts 用 export type * 汇出全部类型。

参数与请求体映射 ​

OpenAPI生成
in: path@Params("name") name: T
in: query@Query("name") name: T
in: header@HeaderValue("name") name: T
in: cookie没有装饰器:参数保留在签名里,并在方法 JSDoc 中说明需要自行写入 header
application/json body@Data() payload: T
application/x-www-form-urlencoded body每个字段一个参数:@Data("petId") petId: T
multipart/form-data body@Data() formData: FormData,文件字段写进 JSDoc
text/* body@Data() payload: string
application/octet-stream body@Data() payload: Blob

必填参数排在可选参数前面(TypeScript 不允许必填参数跟在可选参数后面)。位置对运行时没有影响: 每个参数都有自己的装饰器来指名 wire key。

响应的映射 ​

只取 2xx:

响应生成的返回类型
有 JSON schemaPromise<T>,内联对象会被命名成 <OperationId>Response
text/*Promise<string>
application/octet-streamPromise<Blob>
多个 2xxPromise<A | B>
没有 2xx 内容(如 204)Promise<void>

schema → 类型 ​

JSON SchemaTypeScript
type: string/number/integer/booleanstring / number / boolean
type: arrayT[](联合类型自动加括号:("a" | "b")[])
enum字符串字面量联合:"available" | "pending" | "sold"
const单个字面量类型
allOf合并成一个接口;含非对象分支时退化为交叉类型
oneOf / anyOf联合类型
nullable: true(3.0)T | null
type: ["string","null"](3.1)string | null
additionalPropertiesRecord<string, T>
format: date-time / date仍然是 string,附一条 JSDoc 说明
format: binarystring/Blob + JSDoc 说明
没写类型也没写结构unknown,并记一条 warning

关于枚举:统一生成字面量联合类型,永远不生成 TypeScript enum。理由是 enum 是运行期值 —— 它需要被 import、在 isolatedModules 下无法安全地 export type 重导出、而且表达不了 JSON Schema 允许的混合字面量。联合类型是纯类型,verbatimModuleSyntax 下用 import type 就够。

关于 unknown:任何情况下都不会生成 any。any 会关掉它接触到的一切检查,看起来有类型 实际上没有;schema 真的什么都没说时用 unknown,并在 GenerateResult.warnings 里记下是哪一处, 让你知道文档哪里没写清楚。

非标准响应体(envelope) ​

@snail-js/api 默认把响应当作 { code, message, data },并把 data 解包出来。 如果你的后端用的是别的字段名,改 src/service.ts 即可 —— 生成器不会去猜你的 envelope, 它只写 @Server({ baseURL }):

ts
import { Server, SnailServer } from "@snail-js/api";

@Server({
  baseURL: "/api",
  codeKey: "status",
  messageKey: "msg",
  dataKey: "result",
  // 业务码校验也在这里配;默认接受 0 与 200
  validateCode: (code) => code === "SUCCESS"
})
class Service extends SnailServer {}

export const service = new Service();

service.ts 是手写入口,不是「生成后不许动」的文件:每次 generate 都会重写它(因为它带生成 标记),所以建议的做法是先用 snail init 生成一次、按需改好,然后不要让它再被覆盖 —— 把 envelope 相关配置放进一个手写文件,在 service.ts 里 import 进来,或者干脆把 service.ts 移出生成目录手工维护, 只让生成器产出 apis/ 与 types/。

确定性保证 ​

同样的输入永远产出逐字节相同的输出。为此:

  • tag、操作、属性、字段、文件、import、枚举字面量、联合分支、方法名去重顺序全部排序后再输出;
  • 类型名冲突时按固定顺序加数字后缀,并记一条 warning;
  • 输入文档里重新排列 paths 或 properties 不会改变输出(测试里有一条专门验证这一点的用例)。

这意味着生成物可以放心提交进版本库,diff 里只会出现真正的内容变化。

--clean 只删自己生成的文件 ​

每个生成文件的第一行都是:

text
// Generated by @snail-js/cli. Do not edit by hand.

--clean 只删除带这一行的文件,并且只在目录空了以后才删除目录。放在同一个目录里的手写文件不会 被碰。snail init 写出的 README.md 故意没有这一行,所以也删不掉。

程序化 API ​

ts
import {
  generateFromOpenAPI,
  writeGenerated,
  renderDocument,
  buildOperations,
  renderSchemaType,
  type GenerateOptions,
  type GenerateResult,
  type OpenAPIDocument
} from "@snail-js/cli";

// 不落盘,只拿到文件名与内容
const result: GenerateResult = await generateFromOpenAPI("./openapi.json", {
  baseUrl: "/api",
  tags: ["pet"]
});
// result.files: Array<{ path: string; contents: string }>   path 相对输出目录
// result.warnings: string[]

const written: string[] = await writeGenerated(result, "src", { clean: true });

input 既可以是路径,也可以是已经解析好的文档对象(比如从网络拉下来、或者内嵌在构建脚本里)。

管道各步骤都是单独导出的,可以脱离文件系统测试或复用:

函数作用
loadOpenAPIDocument / parseOpenAPIDocument / readOpenAPIDocument读取并解析 JSON / YAML 文档
resolveLocalReference / dereferenceSchema / mergeObjectShape本地 $ref 解析、allOf 合并
buildOperations / groupOperations展平成规范化操作列表、按 tag 分组
renderSchemaType / renderPayloadType / TypeRegistryJSON Schema → TypeScript 类型表达式
renderApiFile / renderTypeFile / renderServiceFile / renderBarrelFile各文件的渲染器
renderDocument文档 → GenerateResult(不落盘)

CLI 参数解析也是纯函数,便于测试:

ts
import { parseArgv, runCli } from "@snail-js/cli/bin";

已知限制 ​

这些是明确不支持的,不会「差不多能用」,而是会报错或忽略:

  • 不支持远程 / 相对路径的 $ref(https://…、./other.json#/X)。遇到会直接报错并说明只支持 本地引用。生成必须是离线、可复现的,一个 502 不该悄悄改变你的类型。请先把文档 bundle 成一个文件。
  • 不生成任何认证脚手架:不写 token 注入、不写 @Header、不管 refresh。鉴权靠拦截器 / 插件挂到 service 上,生成物只描述接口形状。
  • 不把 format: date-time 推断成 Date:axios 拿到的是 JSON 字符串,生成 Date 只会让类型和 运行时对不上。date / date-time 一律是 string + JSDoc 说明。
  • 不生成 TypeScript enum(见上文,统一用字面量联合类型)。
  • 不支持 Swagger 2.0:文档的 swagger: "2.0" 会被识别并报错。
  • 不读取 x- 扩展、不生成注释里的 example 之外的运行时校验:没有 zod / schema 校验代码。
  • 不处理 callbacks、links、webhooks:这些不影响请求代码的形状。
  • 不推断 required:文档没写 required 就生成可选属性 ?。
  • oneOf / anyOf 不生成判别(discriminator)逻辑:只生成联合类型。
  • 同名类型冲突会加数字后缀并记 warning,而不是智能合并 —— components.schemas 里本来就不该有 清洗后同名的名字。
  • multipart/form-data 一律是 FormData,字段类型不参与类型检查(文件字段名会写进 JSDoc)。
  • cookie 参数没有装饰器(@snail-js/api 未提供),参数会保留在签名里并在 JSDoc 中提示, 需要调用方自己写进 header。

基于 MIT 许可发布