Ghost Admin API 端点开发实战:api-framework 请求管线、Controller 权限校验与 e2e 测试全流程
【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost
本文以 Ghost 仓库中官方的 Add Admin API Endpoint 技能文档(SKILL.md)及其配套的参考、权限、校验三份指南为核心,系统讲解在 Ghost 中新增 Admin API 端点的完整流程:从@tryghost/api-framework的五阶段请求管线、Frame对象、Controller 各配置属性,到permissions的四种模式、声明式输入校验、路由注册与e2e-api测试验证,帮助你在ghost/api/admin/**上安全、规范地交付新端点。
一、新增 Admin API 端点的标准工作流
Ghost 仓库在 .agents/skills/add-admin-api-endpoint/SKILL.md 中给出了官方推荐的操作步骤,适用于在ghost/api/admin/**下新增端点:
创建或定位 endpoint 文件:如果是全新资源,在 ghost/core/core/server/api/endpoints/ 下新建 endpoint 文件;否则在
endpoints/目录下找到既有资源的 endpoint 文件并追加方法。编写 Controller 对象:使用
@tryghost/api-framework提供的ControllerJSDoc 类型,至少包含docName和一个端点定义(例如browse)。注册路由:将每个端点的 HTTP 路由添加到 ghost/core/core/server/web/api/endpoints/admin/routes.js。
编写 e2e 测试:在 ghost/core/test/e2e-api/admin/ 下为新端点添加基础
e2e-api测试,确保新端点行为符合预期。运行测试并迭代:在仓库根目录下执行
cd ghost/core && pnpm test:single test/e2e-api/admin/{test-file-name}直至测试通过。
test:single脚本定义在 ghost/core/package.json 中,会根据路径自动选择vitest或带数据库配置的vitest.config.db.ts来运行单个测试文件。
下面结合仓库源码,逐步展开每一环节背后的框架机制。
二、api-framework 请求管线:五阶段处理模型
reference.md 将 API 框架描述为一个基于管线(pipeline)的系统:每个 HTTP 请求在执行业务逻辑前,会依次通过五个阶段,从而让所有端点获得一致的校验、序列化与权限处理:
- Input Validation(输入校验)—— 校验 query 参数、URL 参数与请求体;
- Input Serialization(输入序列化)—— 转换入站数据(例如把
include映射为withRelated); - Permissions(权限)—— 检查当前用户/API Key 是否有权访问该资源;
- Query(查询/业务逻辑)—— 执行你编写的 controller 代码;
- Output Serialization(输出序列化)—— 把结果格式化为客户端期望的响应结构。
端点文件并不是直接生效的:在 ghost/core/core/server/api/endpoints/index.js 中,每个资源都通过管线包装后才对外暴露,例如:
const apiFramework = require('@tryghost/api-framework'); const localUtils = require('./utils'); module.exports = { get posts() { return apiFramework.pipeline(require('./posts'), localUtils); }, get tags() { return apiFramework.pipeline(require('./tags'), localUtils); }, // ... };也就是说,你在 endpoint 文件里写的是纯声明式配置,apiFramework.pipeline(controller, localUtils)负责把校验器、权限处理器、序列化器(localUtils指向endpoints/utils/)装配成可调用函数;需要 Content API 变体时则传入第三个参数'content'(如pagesPublic、tiersPublic)。
Frame 对象:贯穿全部阶段的上下文载体
Frame类承载请求的全部信息,并在各阶段间以引用方式传递、被就地修改。其结构如下(摘自 reference.md):
{ original: Object, // 原始输入(用于调试) options: Object, // query 参数、URL 参数、context、自定义选项 data: Object, // 请求体;若配置了 `data`,也可来自 query/URL 参数 user: Object, // 已登录用户对象 file: Object, // 单个上传文件 files: Array, // 多个上传文件 apiType: String, // 'content' 或 'admin' docName: String, // 端点名(如 'posts') method: String, // 方法名(如 'browse', 'read', 'add', 'edit') response: Object // 由输出序列化阶段写入 }一个具体示例:
{ original: { include: 'tags,authors' }, options: { withRelated: ['tags', 'authors'], context: { user: '123' } }, data: { posts: [{ title: 'My Post' }] } }注意original保留原始输入,而options.include在输入序列化阶段已被转换为withRelated数组——这正是管线“先转换、后消费”的典型体现。
三、Controller 结构与各配置属性详解
基本形态
Controller 是一个带docName属性(端点名,必需)与方法配置的对象。仓库中的真实示例见 ghost/core/core/server/api/endpoints/tags.js:
/** @type {import('@tryghost/api-framework').Controller} */ const controller = { docName: 'tags', browse: { headers: { cacheInvalidate: false, }, options: ['include', 'filter', 'fields', 'limit', 'order', 'page', 'debug'], validation: { options: { include: { values: ALLOWED_INCLUDES, // ['count.posts'] }, }, }, permissions: true, query(frame) { return models.Tag.findPage(frame.options); }, }, // read / add / edit / destroy ... };各属性说明
以下属性说明完整继承自 reference.md,并结合仓库实现补充了细节。
headers(Object):配置 HTTP 响应头。cacheInvalidate: true(或{ value: '/posts/*' })——写操作后失效缓存;disposition: { type: 'csv' | 'json' | 'yaml' | 'file', value: 'export.csv' }—— 用于下载类接口的文件处置头,value可以是函数;location: false—— 关闭add方法默认自动生成的Location响应头。
options(Array | Function):允许进入frame.options的 query/URL 参数白名单,如options: ['include', 'filter', 'page', 'limit', 'order']。也支持函数形式按frame.apiType动态返回不同白名单。data(Array):应放入frame.data而非frame.options的参数,典型用于 READ 请求中模型期望findOne(data, options)的签名,如data: ['id', 'slug', 'email']。validation(Object | Function):输入校验配置,详见第五节。permissions(Boolean | Object | Function):权限配置,必须显式声明,详见第四节。query(Function,必需):主业务逻辑,返回 API 响应:query(frame) { const { include, filter, page, limit } = frame.options; // 已校验的选项 const postData = frame.data.posts[0]; // 请求体 const userId = frame.options.context.user; // 上下文 return models.Post.findPage(frame.options); }statusCode(Number | Function):HTTP 状态码,默认 200。可固定(statusCode: 201)或按结果动态返回:(result) => result.posts.length ? 200 : 204。response(Object):响应格式配置,如response: { format: 'plain' }以纯文本发送而非 JSON。cache(Object):端点级缓存,提供async get(cacheKey, fallback)与async set(cacheKey, response)两个钩子。generateCacheKeyData(Function):自定义缓存键生成,默认使用frame.options,可在此基础上追加字段。
框架内置的通用模式
reference.md 还总结了若干实战模式,可直接照搬:
- User Context 判断:在
query中通过frame.options.context.user / integration / member区分调用方身份,为不同身份注入不同过滤条件(如非管理员强制status:published)。 - 流式/特殊响应:
query可以返回一个函数handler(req, res, next)直接接管 Express 响应,用于流式输出等场景。 - 在 query 内设置响应头:
frame.setHeader('X-Custom-Header', 'value')。tags.js 的edit方法中就使用了该技巧——仅当模型确实发生变化时才frame.setHeader('X-Cache-Invalidate', '/*')(见 tags.js)。
四、permissions 配置:四种模式与底层实现
permissions.md 强调:api-framework 采用基于管线的权限系统,权限是五个阶段中的第三阶段;每个 controller 方法必须显式定义permissions属性——省略该属性会抛出IncorrectUsageError。这是防止“无意识安全漏洞”、让权限处理显式化的硬性要求。
模式 1:permissions: true(默认权限检查)
最常见的模式,委托给默认权限处理器。默认处理器实现位于 ghost/core/core/server/api/endpoints/utils/permissions.js,其执行逻辑:
单数形式推导:把
docName转为单数——posts→post;ies结尾走ies→y规则(源码中对应apiConfig.docName.match(/ies$/)分支),如categories→category。调用权限服务:
permissions.canThis(frame.options.context)[method]singular例如
docName: 'posts'+edit方法即调用permissions.canThis(context).edit.post(postId, unsafeAttrs)。源码中还支持apiConfig.identifier(frame)由 controller 覆盖默认的资源标识符(默认取frame.options.id),以适配“改某条 setting 的 key 即标识符”这类场景。数据库核对:权限服务在
permissions与permissions_roles表中查找action_type匹配方法名、object_type匹配单数docName的记录,并确认用户角色被授予了该权限。
此外源码中还体现了两个细节:权限检查可以返回excludedAttrs列表,处理器会从frame.data[docName][0]中剔除这些属性后继续放行(而非直接抛错,见 permissions.js 注释,当前主要服务于 contributor 角色编辑 posts 的场景);NoPermissionError会被统一改写为You do not have permission to {method} {docName}的友好消息。
数据库前提:默认处理器生效需要在permissions表中存在对应记录(如('Browse posts', 'browse', 'post')),并在permissions_roles表中与 Administrator、Editor 等角色建立映射。这些记录通常通过两种途径维护:
- 初始 fixtures:ghost/core/core/server/data/schema/fixtures/fixtures.json;
- 数据库迁移:使用
addPermissionWithRoles()工具函数,定义于 ghost/core/core/server/data/migrations/utils/permissions.js。
模式 2:permissions: false(跳过权限)
完全绕过权限阶段,适用于公共端点、健康检查等。permissions.md 明确警告:仅在确定端点应当公开可访问时才使用——“显式地声明为公开”本身就是一种安全决策。
模式 3:函数式(自定义权限逻辑)
delete: { options: ['id'], permissions: async function(frame) { // 未登录 → UnauthorizedError // 非资源所有者且非 admin → NoPermissionError return Promise.resolve(); }, query(frame) { return models.Resource.destroy(frame.options); } }适用于按资源变化的复杂逻辑、owner 权限、需要查库做决策的角色控制。permissions.md 给出的完整示例覆盖 owner 权限(user_settings只能读写自己的记录)、角色访问控制(admin_settings仅 Owner/Administrator 可访问、仅 Owner 可编辑)、以及带数据准备的permissions: { before }组合(例如在检查前加载用户订阅状态,供后续query使用)。
模式 4:配置对象(默认处理 + 钩子)
permissions: { unsafeAttrs: ['author', 'status'], before: async function(frame) { frame.user.permissions = await loadUserPermissions(frame.user.id); } }unsafeAttrs:指定需要提升权限才能修改的属性(如只有管理员能改文章作者、发布状态、可见性、featured等)。默认处理器会_.pick(frame.data[docName][0], unsafeAttrs)把这部分数据一并传给权限检查(见 permissions.js)。before:在默认权限检查前运行的钩子,用于预载权限判断所需的数据。
场景与模式选择速查
permissions.md 提供的对应关系如下:
| 场景 | 推荐模式 |
|---|---|
| 公共端点 | permissions: false |
| 标准认证 CRUD | permissions: true |
| 需要 unsafe attrs 追踪 | permissions: { unsafeAttrs: [...] } |
| 复杂自定义逻辑 | permissions: async function(frame) {...} |
| 需要前置数据准备 | permissions: { before: async function(frame) {...} } |
最佳实践包括:权限函数只做权限检查、不掺入业务逻辑;使用有意义的错误消息;资源属于特定用户时必须校验所有权;敏感字段一律走unsafeAttrs。错误类型统一取自@tryghost/errors:UnauthorizedError(未认证)、NoPermissionError(已认证但无权限)、NotFoundError(资源不存在,慎用以防信息泄露)、ValidationError(校验失败)。
为新资源注册权限的迁移写法
当你的端点使用permissions: true时,必须通过迁移把权限写入数据库。permissions.md 给出完整模板(文件放置于ghost/core/core/server/data/migrations/versions/X.X/下):
const {combineTransactionalMigrations, addPermissionWithRoles} = require('../../utils'); module.exports = combineTransactionalMigrations( addPermissionWithRoles({ name: 'Browse my resources', action: 'browse', object: 'my_resource' // docName 的单数形式 }, ['Administrator', 'Admin Integration']), addPermissionWithRoles({ name: 'Read my resources', action: 'read', object: 'my_resource' }, ['Administrator', 'Admin Integration']), // ... edit / add / destroy );命名约定:name为人类可读描述;action为 API 方法(browse/read/edit/add/destroy);object为docName的单数形式(automated_email而非automated_emails)。可用角色包括 Owner、Administrator、Editor、Author、Contributor、Admin Integration 等;若端点仅允许管理员访问,就只授予Administrator与Admin Integration两个角色。
五、validation 配置:声明式校验与函数式校验
validation.md 将校验定位为管线的第一阶段:确保必填字段存在、取值在允许列表内、数据类型正确(ID、邮箱、slug 等),在请求进入权限与业务逻辑前就拒绝非法结构。
两种模式
模式 1:对象式(最常用)——通过配置对象声明规则:
browse: { options: ['include', 'page', 'limit'], validation: { options: { include: { values: ['tags', 'authors'], required: true }, page: { required: false } } }, permissions: true, query(frame) { return models.Post.findPage(frame.options); } }模式 2:函数式——完全接管校验逻辑,适合跨字段校验、条件规则、自定义错误消息:
add: { validation(frame) { const {ValidationError} = require('@tryghost/errors'); const post = frame.data.posts?.[0]; if (!post.title || post.title.length < 3) { return Promise.reject(new ValidationError({ message: 'Title must be at least 3 characters' })); } return Promise.resolve(); }, permissions: true, query(frame) { return models.Post.add(frame.data.posts[0], frame.options); } }校验 Options(query 参数)
- 必填:
filter: { required: true }; - 允许值有等价的两种写法——对象记法
include: { values: ['tags', 'authors'] }与数组简写include: ['tags', 'authors']; include参数的特殊行为:非法取值会被静默过滤而非报错。例如请求?include=tags,invalid_field,authors,最终frame.options.include变为'tags,authors'——这是对客户端请求了不支持的 include 时的优雅降级设计。
校验 Data(请求体)
READ 操作的 data 来自 query/URL 参数,如
data: ['id', 'slug']+validation.data.slug.values: ['featured', 'latest'];ADD/EDIT 操作的 data 来自请求体,且必须有根键(root key),结构如:
{ "posts": [{ "title": "My Post", "status": "draft" }] }框架自动校验:根键存在、根键下是至少含一项的数组、必填字段存在且不为 null。
内置全局校验器
框架借助@tryghost/validator对常见字段自动校验,无需手写规则:
| 字段名 | 校验规则 | 合法示例 |
|---|---|---|
id | MongoDB ObjectId、1或me | 507f1f77bcf86cd799439011、me |
uuid | UUID 格式 | 550e8400-e29b-41d4-a716-446655440000 |
slug | URL 安全 slug | my-post-title |
email | 邮箱格式 | user@example.com |
page | 数字 | 1、25 |
limit | 数字或all | 10、all |
from/to | 日期格式 | 2024-01-15 |
order | 排序格式 | created_at desc |
columns | 列名列表 | id,title,created_at |
而filter、context、forUpdate、transacting、include、formats、name默认不做全局校验。
按方法区分的校验行为
- BROWSE / READ:校验
frame.data对apiConfig.data,允许空 data,使用全局校验器; - ADD:依次校验根键存在 → 必填字段存在 → 必填字段非 null。典型错误:
"No root key ('posts') provided."、"Validation (FieldIsRequired) failed for title"、"Validation (FieldIsInvalid) failed for title"(为 null 时); - EDIT:执行全部 ADD 校验,并额外校验 URL 与请求体中的 ID 一致性——
/posts/123配 body{"posts":[{"id":"456"}]}会报"Invalid id provided."; - 特殊方法:
changePassword()、resetPassword()、setup()走 ADD 规则,publish()走 BROWSE 规则。
错误类型上,校验阶段使用@tryghost/errors的ValidationError(字段校验失败)与BadRequestError(请求结构错误)。函数式校验可附加context与help字段,让错误对 API 消费者可操作,例如:
return Promise.reject(new ValidationError({ message: 'Email address is required', context: 'Please provide a valid email address to continue', help: 'Check that the email field is included in your request' }));validation.md 的最佳实践清单值得直接采纳:显式列出全部允许的 options 防参数注入;把常用类型交给内置校验器;显式标注必填字段;简单场景用数组简写;复杂逻辑(如日期区间不超过 30 天)才上函数式;错误消息具体且可执行。
六、路由注册:把 Controller 接入 Admin API
SKILL.md 第 3 步指向的路由文件是 ghost/core/core/server/web/api/endpoints/admin/routes.js。其核心机制是@tryghost/api-framework导出的http包装器:
const express = require('../../../../../shared/express'); const api = require('../../../../api').endpoints; const { http } = require('@tryghost/api-framework'); const apiMw = require('../../middleware'); const mw = require('./middleware'); module.exports = function apiRoutes() { const router = express.Router('admin api'); router.use(apiMw.cors); // ## Public router.get('/site', mw.publicAdminApi, http(api.site.read)); // ## Posts router.get('/posts', mw.authAdminApi, http(api.posts.browse)); router.post('/posts', mw.authAdminApi, http(api.posts.add)); router.put('/posts/:id', mw.authAdminApi, http(api.posts.edit)); router.delete('/posts/:id', mw.authAdminApi, http(api.posts.destroy)); // ... };结合源码可以观察到几个注册细节:
- 认证中间件分层:
mw.publicAdminApi用于少数公开端点(如GET /site),绝大多数端点挂mw.authAdminApi;带 URL 参数的场景另有mw.authAdminApiWithUrl(如PUT /schedules/:resource/:id)。 - 上传类端点:先挂
apiMw.upload.single('postsfile')与apiMw.upload.validation({ type: 'posts' }),再交给http(api.posts.importCSV),文件随后经frame.file流入 query。 - Labs 开关:新特性可用
labs.enabledMiddleware('csvContentImporter')包裹路由,按功能开关灰度开放。 - 路由顺序敏感:文件中显式注释 “browseAll must come before :id routes”(如
/comments必须在/comments/:id之前),注册新端点时需避免与既有通配路径冲突。
http(api.posts.browse)形式的包装器把管线产物适配为 Express handler,因此 controller 中无需关心req/res生命周期。框架同时也支持程序化内部调用,跳过 HTTP 层直接复用同一套校验/权限逻辑:
// 带 data 与 options const result = await api.posts.add( { posts: [{ title: 'New Post' }] }, // data { context: { user: userId } } // options ); // 仅 options const posts = await api.posts.browse({ filter: 'status:published', include: 'tags', context: { user: userId } });此外,endpoint 专属的校验器与序列化器可分别放在endpoints/utils/下:输入校验器按{ add(apiConfig, frame) {...} }导出,输出序列化器按{ posts: { browse(response, apiConfig, frame) {...} } }组织并写入frame.response。错误处理统一使用@tryghost/errors的ValidationError/NotFoundError/NoPermissionError,保证客户端拿到一致的错误结构。
七、e2e 测试:让新端点可被验证
SKILL.md 第 4、5 步要求在 ghost/core/test/e2e-api/admin/ 下为新端点编写基础 e2e 测试,然后以单文件模式运行直至通过:
cd ghost/core && pnpm test:single test/e2e-api/admin/{test-file-name}该目录下已按资源组织了大量同类测试可供参照,例如posts-bulk.test.js、pages.test.js、members.test.js、comments.test.js、config.test.js等,它们示范了如何以 Admin API 客户端发起请求、断言状态码与响应结构、以及覆盖权限与校验错误路径。测试命名沿用e2e-api/admin/{资源}.test.js的约定,与 SKILL.md 给出的命令参数保持一致。
八、端到端速览与最佳实践
把以上环节串起来,一个新 Admin API 端点的完整交付物清单为:
ghost/core/core/server/api/endpoints/{资源}.js—— 声明式 controller(docName+ 方法配置);ghost/core/core/server/api/endpoints/index.js—— 通过apiFramework.pipeline(...)注册 getter;ghost/core/core/server/web/api/endpoints/admin/routes.js——router.{get,post,put,delete}+mw.authAdminApi+http(...);- (如用
permissions: true)迁移文件addPermissionWithRoles(...)与/或 fixtures 中的权限记录; ghost/core/test/e2e-api/admin/{资源}.test.js—— e2e 测试;- 运行
cd ghost/core && pnpm test:single test/e2e-api/admin/{资源}.test.js迭代至通过。
reference.md 与 permissions.md 汇总的最佳实践,可浓缩为七条纪律:
- 始终显式声明
permissions—— 省略即报错,这是安全要求而非风格建议; - 用
options白名单收窄参数—— 未列入的参数不会进入frame.options; - 优先声明式校验,复杂逻辑再上函数式;
- 写操作设
cacheInvalidate: true,读操作设为false; - 敏感字段用
unsafeAttrs要求提升权限; query直接返回模型响应,把结构转换交给输出序列化器;- READ 端点用
data承接findOne(data, options)所需的参数。
遵循这套管线、权限与测试约定,新端点即能融入 Ghost Admin API 现有的安全与工程体系:校验在权限之前拦截非法输入,权限在业务之前拦截越权访问,序列化器保证输出一致性,而 e2e 测试把整条链路固化为可持续回归的验证。
【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考