@snail-js/cli
从 OpenAPI 3.0 / 3.1 文档生成 @snail-js/api 的装饰器请求代码。
生成物不是「能跑就行」的胶水:方法签名带上真实的请求体与响应类型,调用 .send() 时 data 直接就是响应 DTO 的类型 —— 这是靠 SnailPayloadOf 从声明的返回类型推导出来的,所以生成器 必须写出 Promise<ResponseDto> 而不是 ResponseDto。
// 生成一次,之后这样用:
const { data } = await petApi.getPetById(1).send(); // data: GetPetByIdResponse安装
pnpm add -D @snail-js/cli
pnpm add @snail-js/api@snail-js/cli 的运行时依赖只有 yaml(用于解析 YAML 文档),@snail-js/api 是可选 peer, 仅在生成的代码里被引用。
用 npx / pnpm dlx 跑一次也可以:
pnpm dlx @snail-js/cli generate --input ./openapi.json --out srcsnail init
在当前项目里创建一个最小可用的服务骨架(默认写到 src/service):
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
snail generate --input ./openapi.json --out src
snail g -i ./openapi.yaml -o src --tags pet,store --cleansnail 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
手写(每个接口都要重复一遍):
@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:
// 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 时:
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 schema | Promise<T>,内联对象会被命名成 <OperationId>Response |
text/* | Promise<string> |
application/octet-stream | Promise<Blob> |
| 多个 2xx | Promise<A | B> |
| 没有 2xx 内容(如 204) | Promise<void> |
schema → 类型
| JSON Schema | TypeScript |
|---|---|
type: string/number/integer/boolean | string / number / boolean |
type: array | T[](联合类型自动加括号:("a" | "b")[]) |
enum | 字符串字面量联合:"available" | "pending" | "sold" |
const | 单个字面量类型 |
allOf | 合并成一个接口;含非对象分支时退化为交叉类型 |
oneOf / anyOf | 联合类型 |
nullable: true(3.0) | T | null |
type: ["string","null"](3.1) | string | null |
additionalProperties | Record<string, T> |
format: date-time / date | 仍然是 string,附一条 JSDoc 说明 |
format: binary | string/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 }):
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 只删自己生成的文件
每个生成文件的第一行都是:
// Generated by @snail-js/cli. Do not edit by hand.--clean 只删除带这一行的文件,并且只在目录空了以后才删除目录。放在同一个目录里的手写文件不会 被碰。snail init 写出的 README.md 故意没有这一行,所以也删不掉。
程序化 API
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 / TypeRegistry | JSON Schema → TypeScript 类型表达式 |
renderApiFile / renderTypeFile / renderServiceFile / renderBarrelFile | 各文件的渲染器 |
renderDocument | 文档 → GenerateResult(不落盘) |
CLI 参数解析也是纯函数,便于测试:
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。