本页译自英文原文。如有出入,以英文版本为准。
DaloyJS OpenAPI 规范
DaloyJS 直接从你的路由定义生成干净的 OpenAPI 3.1 文档,无需插件,也无需额外的装饰器。校验、类型和规范共享同一个事实来源。
一份契约,四种产物唯一事实来源路由定义app.get(path, { request, responses }, handler)
运行时请求与响应校验Zod · Valibot · ArkType
规范OpenAPI 3.1 文档GET /openapi.json · /openapi.yaml
给人看文档界面Scalar · Swagger UI · Redoc
调用方类型化客户端 SDKHey API 代码生成
路由只需编写一次。校验、OpenAPI 规范、交互式文档和类型化客户端都从它派生,因此它们永远不会彼此脱节。
一行代码:自动挂载 /docs、/openapi.json、/openapi.yaml
FastAPI 风格。向 App 构造函数传入 docs: "auto"(或 docs: true),DaloyJS 就会为你注册 GET /openapi.json + GET /openapi.yaml(两种格式的实时规范)以及 GET /docs(Scalar API 参考界面)。
import { App } from "@daloyjs/core";
const app = new App({
openapi: {
info: { title: "My API", version: "1.0.0" },
servers: [{ url: "https://api.example.com" }],
securitySchemes: { bearer: { type: "http", scheme: "bearer" } },
},
docs: "auto", // 在非生产环境挂载 GET /docs、GET /openapi.json、GET /openapi.yaml
});
docs: "auto" 只在应用不处于生产环境时挂载(create-daloy 模板默认使用它,因此部署后的 API 不会公开自己的 schema);你也可以不设置它(默认),然后用下面的辅助函数手动挂载。通过对象形式可以自定义路径、界面和标签。自 1.5.5 起,除非你设置 enabled,否则对象形式的行为与 "auto" 相同,因此自定义界面永远不会意外地在生产环境中公开你的路由地图;对于应当在生产环境中提供文档的公开 API,请设置 enabled: true(或 docs: true):
new App({
openapi: { info: { title: "My API", version: "1.0.0" } },
docs: {
path: "/reference", // 默认:"/docs"
openapiPath: "/spec.json", // 默认:"/openapi.json"
openapiYamlPath: "/spec.yaml", // 默认:"/openapi.yaml";设为 false 可禁用
ui: "scalar", // "scalar"(默认)| "swagger" | "redoc"
scalar: {
theme: "kepler",
customCss: ":root { --scalar-color-accent: #2563eb; }",
hideTestRequestButton: true,
},
tags: ["Docs"], // 默认:["Docs"],传入 [] 可省略
enabled: "auto", // 默认 "auto"(生产环境中关闭);true | false
},
});
安全:公开的规范就是一份完整的攻击面地图:每个路径、参数、认证方案和错误结构都在其中。在生产环境中使用 docs: true 或 enabled: true 时,DaloyJS 会记录 docs.public_in_production,并把规范提供给任何能访问到它的人。这对公开 API 是正确的。其他情况请保留 "auto",或者把文档放在认证之后(应用级的 bearerAuth(),只要没有为 /docs 和 /openapi.json 设置 except(),就能覆盖它们)。把管理、定时任务和调试路由标记为 internal: true。这样它们会被排除在生成的文档之外,并对网络流量返回 404,而 app.inject() 仍然可以调用它们。参见保护管理面板(英文)。
选择界面:Scalar、Swagger UI 或 Redoc
把 ui 设为 "scalar"(默认)、"swagger" 或 "redoc"。三者渲染同一份实时规范,挂载在相同的路径上,并带有同样严格的 CSP 和托管在 CDN 上的资源,因此切换只需改一个词。Scalar 和 Swagger UI 为受保护的路由提供开发者请求控制台。Redoc 是只读的参考界面:它会显示安全要求,但没有内置的 Try it 控制台。
new App({
openapi: { info: { title: "My API", version: "1.0.0" } },
docs: {
ui: "swagger",
swagger: {
docExpansion: "none",
displayRequestDuration: true,
},
},
});
Swagger UI 默认会在页面重新加载后保留 Authorize 对话框中的授权信息。它的做法是把粘贴的令牌存进浏览器的 localStorage,Scalar 在 persistAuth: true 时也是如此。文档源上运行的任何脚本都能读取它,而且关闭标签页后它依然存在。请只粘贴短期有效、权限最小的测试令牌,绝不要粘贴生产或管理员凭据,并且让文档与任何自有 Web 应用使用不同的源。传入 swagger: { persistAuthorization: false } 可以只在内存中保存令牌。Scalar 会自动选择第一个配置的 OpenAPI 安全方案,因此认证表单已经准备好,开发者可以直接粘贴 bearer 令牌或 API key。如果你希望 Scalar 使用不同的默认值,请显式设置:
new App({
openapi: {
info: { title: "My API", version: "1.0.0" },
securitySchemes: {
bearer: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
apiKey: { type: "apiKey", in: "header", name: "x-api-key" },
},
},
docs: {
scalar: {
persistAuth: true,
authentication: { preferredSecurityScheme: "bearer" },
},
},
});
如果文档需要一个显眼的登录入口,请添加 docs.auth。Scalar、Swagger UI 和 Redoc 都会渲染同样的按钮。把它指向本地登录表单、Better Auth 路由、Clerk 登录 URL,或者来自 Auth0、Okta、Microsoft Entra ID、Cognito、Keycloak 等提供方的 OIDC 授权 URL:
new App({
openapi: {
info: { title: "My API", version: "1.0.0" },
securitySchemes: {
oidc: {
type: "oauth2",
flows: {
authorizationCode: {
authorizationUrl: "https://login.example.com/oauth2/v2.0/authorize",
tokenUrl: "https://login.example.com/oauth2/v2.0/token",
scopes: { "api.read": "Read API data" },
},
},
},
},
},
docs: {
ui: "redoc", // 同样适用于 "scalar" 和 "swagger"
auth: {
loginUrl: "/login", // 或者你的提供方的授权/登录 URL
label: "Sign in",
target: "popup",
},
},
});
redoc 对象会原样转发给 Redoc.init(specUrl, configuration, element)。由于 Redoc 在 blob: Web Worker 中构建搜索索引,自动挂载的 /docs 路由只会在 ui: "redoc" 时自动为该页面的 CSP 放宽 worker-src 'self' blob:。Scalar 和 Swagger UI 保持更严格的策略。除非 ui 是 "scalar",否则 scalar 选项会被忽略,swagger 和 redoc 同理。
scalar 对象会作为 JSON 配置转发给 Scalar 的 HTML API,而 Daloy 仍把实时的 openapiPath 作为数据源。用它来设置主题、自定义 CSS、布局、认证默认值和客户端可见性,无需复制文档 HTML。
进阶:手动生成规范
需要原始的规范对象(用于代码生成、契约测试或自定义路由)?直接调用 generateOpenAPI(app, options):
import { generateOpenAPI } from "@daloyjs/core/openapi";
const doc = generateOpenAPI(app, {
info: { title: "My API", version: "1.0.0" },
servers: [{ url: "https://api.example.com" }],
securitySchemes: { bearer: { type: "http", scheme: "bearer" } },
});
console.log(JSON.stringify(doc, null, 2));
进阶:用你自己的路由提供文档
手写的文档路由就是一条普通路由,因此 docs: "auto" 的开关对它不起作用。它是否应该在生产环境中存在、是否需要认证,都要你自己决定。用 generateOpenAPI() 手动提供的任何 /openapi.json 路由也是如此:
import { swaggerUiHtml, scalarHtml, redocHtml, htmlResponse } from "@daloyjs/core/docs";
// 不受 `docs: "auto"` 控制,所以要自己加开关。
if (process.env.NODE_ENV !== "production") {
app.get(
"/docs",
{
operationId: "docs",
responses: { 200: { description: "API reference" } },
},
async () => {
const html = scalarHtml({
specUrl: "/openapi.json",
title: "My API",
configuration: { theme: "kepler" },
});
const res = htmlResponse(html);
return { status: 200, body: await res.text(), headers: Object.fromEntries(res.headers) };
},
);
}
swaggerUiHtml、scalarHtml 和 redocHtml 都返回自包含的 HTML 页面,它们从 jsDelivr 加载资源,并使用只允许该源的严格 CSP。用 redocHtml 手写路由时,请向 htmlResponse(或 docsContentSecurityPolicy)传入 allowBlobWorkers: true,让 CSP 允许 Redoc 的 blob: 搜索 worker:
import { redocHtml, htmlResponse } from "@daloyjs/core/docs";
const res = htmlResponse(
redocHtml({ specUrl: "/openapi.json", title: "My API", configuration: { hideDownloadButtons: true } }),
{ allowBlobWorkers: true },
);
如果你想用一个大得多的契约测试文档的使用体验,请参见大型模拟 REST 演示(英文)。当你需要验证搜索、分组和渲染性能时,它比玩具级的 CRUD 示例更适合做基准。
导出到磁盘用于代码生成
// scripts/dump-openapi.ts
import { writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { generateOpenAPI } from "@daloyjs/core/openapi";
import { buildApp } from "../src/build-app.js";
const app = buildApp();
const out = "./generated/openapi.json";
await mkdir(dirname(out), { recursive: true });
await writeFile(out, JSON.stringify(generateOpenAPI(app, {
info: { title: "My API", version: "1.0.0" },
}), null, 2));
console.log(`wrote ${out}`);
// package.json
"scripts": {
"gen:openapi": "node scripts/dump-openapi.ts"
}
生成哪些内容
- 每条路由一个
operationId,重复的会在注册时抛出错误。
- 路径参数
:id 规范化为 {id}。
- 在支持时通过
schema.toJSONSchema?.() 转换 schema 请求体,否则使用结构化的回退方案。
- 为 RFC 9457 错误提供可复用的
components.schemas.Problem。
tags、summary、description 以及按状态码的 description。
- 标记为
internal: true 的路由会被省略,除非你传入 includeInternal: true(例如为你自己的工具生成仅供内部使用的契约)。绝不要公开那份文档。
Webhooks
OpenAPI 3.1 允许生产方发布顶层 webhooks,即期望调用方实现的操作。向 generateOpenAPI 传入 webhooks,DaloyJS 就会把它们输出到文档顶层的 webhooks 映射中。
import { generateOpenAPI } from "@daloyjs/core/openapi";
const doc = generateOpenAPI(app, {
info: { title: "Books", version: "1.0.0" },
webhooks: {
bookCreated: {
method: "POST",
operationId: "onBookCreated",
summary: "Fires when a book is created",
tags: ["Webhooks"],
request: { body: z.object({ id: z.string(), title: z.string() }) },
responses: { 200: { description: "Acknowledged" } },
auth: { scheme: "bearer", scopes: ["webhook:receive"] },
},
},
});
webhooks 映射只是描述操作,并不负责投递或保护任何东西。真正投递时,请对每个负载签名,让接收方能够同时验证来源和完整性,并附带时间戳,让接收方能拒绝重放。单靠一个静态 bearer 令牌两者都证明不了。createWebhookSender()(英文)会签名、加时间戳、重试,并让每个出站请求都经过 fetchGuard()。接收方用 verifyWebhookSignature() 验证。请在 webhook 的 description 中记录签名相关的请求头。
回调
回调描述某个操作可能在调用方一侧触发的带外请求(例如一个订阅端点,之后会向调用者提供的 URL 发送 POST)。直接在路由或 webhook 上附加 callbacks 映射即可。
import { fetchGuard } from "@daloyjs/core";
// 每个发往调用者提供的 URL 的请求都要经过 SSRF 防护。
const safeFetch = fetchGuard();
app.post(
"/subscribe",
{
operationId: "subscribe",
request: {
body: z
.object({
// 仅限 https:拒绝 file:、gopher:、javascript: 和普通 http:
callbackUrl: z.url({ protocol: /^https$/ }),
})
.strict(),
},
responses: { 201: { description: "Subscribed" } },
callbacks: {
onEvent: {
"{$request.body#/callbackUrl}": {
method: "POST",
operationId: "onEventCallback",
request: { body: z.object({ id: z.string() }) },
responses: {
200: { description: "ack" },
410: { description: "gone" },
},
},
},
},
},
async () => ({ status: 201, body: undefined }),
);
安全:调用者提供的回调 URL 是服务端请求伪造(SSRF)的汇聚点。仅靠 schema 校验是不够的。单独的 z.url() 会接受 http://169.254.169.254/(云元数据)、http://localhost:6379/ 以及 file:/gopher: URL,即使是 z.url({ protocol: /^https$/ }) 也仍会接受 https://169.254.169.254/ 或解析到私有地址的主机名。请让每个回调都通过 fetchGuard()(英文)提供的 safeFetch 发送,它会检查解析后的地址和每一次重定向。更好的做法是用 createWebhookSender()(英文)投递,它还会对负载签名。在保存 URL 之前,可以考虑验证对方对该 URL 的所有权,例如通过一次挑战请求。
每个回调名称映射到一个或多个运行时表达式键(例如 "{$request.body#/callbackUrl}"),每个键再映射到一个或多个以 HTTP 方法为键的操作。空映射和空数组会被跳过,传入空回调永远不会产生格式错误的规范。
可辨识联合
OpenAPI 3.1 的 discriminator 是描述带标签联合类型的标准方式。DaloyJS 在 @daloyjs/core/openapi(以及根包)中提供了两个辅助函数:
discriminator(propertyName, mapping?):最基础的规范构建器。当你已有手写的 JSON Schema,并想干净地附加这个字段时使用它。
discriminatedUnion(propertyName, variants, opts?):一个兼容 Standard Schema 的包装器,既在运行时校验(根据辨识值分派),又提供 .toJSONSchema(),让 OpenAPI 生成器自动输出 { oneOf, discriminator }。
import { z } from "zod";
import { discriminatedUnion } from "@daloyjs/core";
// .strict() 会以 422 拒绝未知键(例如被注入的 `isAdmin`),
// 而不是悄悄把它们剥离掉。
const Cat = z.object({ kind: z.literal("cat"), meow: z.boolean() }).strict();
const Dog = z.object({ kind: z.literal("dog"), bark: z.boolean() }).strict();
const Animal = discriminatedUnion(
"kind",
{ cat: Cat, dog: Dog },
{ mapping: { cat: "#/components/schemas/Cat", dog: "#/components/schemas/Dog" } },
);
app.post(
"/animals",
{
operationId: "createAnimal",
request: { body: Animal },
responses: { 201: { description: "ok", body: Animal } },
},
async ({ body }) => ({ status: 201, body }),
);
在运行时,这个包装器会以清晰的 Standard Schema 问题拒绝非对象、缺失或非字符串的辨识字段,以及未知的辨识值(包括 __proto__、constructor 和 toString 等继承来的名称),其余的一切都交给匹配变体的校验器处理。
最后更新: