你将构建: 一个经过测试的 Shop API 连接器,带 API 密钥、一个产品查询和一个下单变更操作,在本地沙箱中运行
在本教程中,你将为一个小型 JSON API 编写连接器,从一个空文件开始,直到测试套件全部通过,并在编辑器中运行一个查询。示例是一个使用 API 密钥的 Shop API,包含一个查询(列出产品)和一个变更操作(创建订单)。你需要已检出并安装好依赖的仓库,并且应该了解编辑器中查询和集成的工作方式。
规划连接器
插件由两部分组成。连接(connection)描述一种集成:它的设置(一个 Zod schema)、密钥字段、允许访问的主机,以及一个测试。操作(operations)是查询和工作流调用的内容:每个操作都有一个类型(query 或 mutation)、一个 Zod 输入 schema 和一个 run 函数。完整参考请见 Plugin SDK 指南;本教程会实际构建一个。
本例中的 Shop API 对 GET /products?search= 返回 { "items": [...] },对 POST /orders 返回 { "id", "status" }。它要求在 X-Api-Key 请求头中携带密钥。
编写连接
创建文件和配置 schema
创建
packages/plugins/src/shop.ts。插件 id 为shop,所以文件名与该文件夹中的其他插件保持一致。tsimport { defineOperation, definePlugin, PluginError, readPath, type PluginContext } from '@malla/plugin-sdk' import { z } from 'zod' import { readBody, upstreamError } from './http' import { joinUrl } from './rest' // Shop API connector: search the product catalog and place orders. export const shopConfigSchema = z.object({ baseUrl: z.url({ protocol: /^https?$/ }).describe('API base URL, e.g. https://api.shop.example/v1'), currency: z.enum(['USD', 'EUR', 'MXN']).default('USD').describe('Currency for new orders'), }) export type ShopConfig = z.output<typeof shopConfigSchema>配置 schema 会成为编辑器中的表单。每个键是一个字段,标签由键拆分成单词得到(
baseUrl显示为 “Base url”),.describe()的文本是字段下方的提示。带.default()的字段在表单中是可选的。该 schema 允许http,以便沙箱能访问本地 API;出站策略仍会拒绝除本地主机以外的明文 HTTP,并且只在允许本地集成时才放行本地主机。描述输入
在配置下方添加操作的输入 schema。它们会在调用
run之前校验解析后的输入,并成为查询的输入表单。tsconst product = z.object({ id: z.string(), name: z.string(), price: z.number() }) export const listProductsInput = z.object({ search: z.string().trim().max(200).optional().describe('Only products whose name contains this text'), }) export const createOrderInput = z.object({ productId: z.string().min(1).max(64).describe('Id of the product to order'), quantity: z.number().int().min(1).max(100).default(1), email: z.email().describe('Where the shop sends the receipt'), })读取密钥并调用 API
添加两个辅助函数。
ctx.secrets保存已解密且有值的密钥,所以缺少密钥时会抛出invalid_config。ctx.fetch是执行出站策略的 fetch。./http中的readBody和upstreamError负责解析响应,并把非 2xx 状态转换为operation_failed错误。./rest中的joinUrl确保路径位于基础 URL 之下。tsfunction apiKey(ctx: PluginContext<ShopConfig>): string { const value = ctx.secrets.apiKey if (!value) throw new PluginError('invalid_config', 'Secret apiKey is not set', { issues: [{ path: 'secrets.apiKey', message: 'Required' }] }) return value } /** GET or POST to the Shop API with the key header; JSON body in, parsed body out. */ async function call(ctx: PluginContext<ShopConfig>, path: string, body?: unknown): Promise<unknown> { const url = joinUrl(ctx.config.baseUrl, path) const headers = new Headers({ accept: 'application/json', 'x-api-key': apiKey(ctx) }) if (body !== undefined) headers.set('content-type', 'application/json') const response = await ctx.fetch(url, { method: body === undefined ? 'GET' : 'POST', headers, body: body === undefined ? null : JSON.stringify(body), }) const result = await readBody(response) if (!response.ok) throw upstreamError(response, result) return result }定义插件及其连接
现在调用
definePlugin。暂时让operations保持为空。tsexport const shopPlugin = definePlugin({ id: 'shop', name: 'Shop API', version: '1.0.0', description: 'Search the product catalog and place orders in your Shop API.', firstParty: true, icon: 'lucide:package', connection: { config: shopConfigSchema, secrets: { apiKey: { label: 'API key', description: 'Sent as the X-Api-Key header' } }, secretBoundFields: ['baseUrl'], hosts: (config) => [new URL(config.baseUrl).host], async test(ctx) { await call(ctx, '/products?search=') }, }, operations: {}, })secrets列出密钥字段。值在静态存储时加密,永远不会从 api 返回。字段名只能包含字母和数字(apiKey,而不是api-key);否则definePlugin会抛出错误。hosts是出站白名单:ctx.fetch只能访问它根据已保存配置返回的主机。官方插件没有单独的网络权限;permissions.network属于第三方插件目录的清单。secretBoundFields指定决定密钥发往何处的设置。修改其中任一字段或主机集合时,已保存的密钥会被清除,除非同一次更新提供了新值。test在有人点击 Run test 时运行。发起一个需要密钥的低成本调用,失败时抛出PluginError。普通的Error不会被映射,测试会返回服务器错误,而不是可读的信息。icon是一个 Lucide 图标名。编辑器只打包在其自身源码中出现过的图标名,所以请选择它已经在用的图标(lucide:package就是其中之一),否则图标会显示为空白。firstParty: true让上下文获得下文所述的官方插件能力。
添加操作
添加查询
把
operations: {}替换为操作映射。操作 id 必须是驼峰式的<plugin id>.<name>;definePlugin会在模块加载时检查。tsoperations: { 'shop.listProducts': defineOperation({ kind: 'query', name: 'List products', description: 'Products from the catalog, optionally filtered by name.', input: listProductsInput, output: z.array(product), async run(ctx: PluginContext<ShopConfig>, input) { const query = new URLSearchParams(input.search ? { search: input.search } : {}) const body = await call(ctx, `/products?${query}`) const items = readPath(body, 'items') if (!Array.isArray(items)) throw new PluginError('operation_failed', 'The Shop API answered without an items list') return items }, }), },defineOperation根据 schema 推断input的类型,所以input.search是string | undefined。给ctx标注你的配置类型,就能带类型地读取ctx.config.baseUrl。readPath只通过自有属性读取点路径(items、data.items.0.name),某一级不存在时返回undefined。outputschema 不会被强制校验;编辑器用它来提供自动补全。添加变更操作
在同一个映射中添加第二项。
mutation会修改上游的数据,所以编辑器会在它的名称后标注 “(mutation)”。ts'shop.createOrder': defineOperation({ kind: 'mutation', name: 'Create order', description: 'Places an order and returns its id and status.', input: createOrderInput, output: z.object({ id: z.string(), status: z.string() }), async run(ctx: PluginContext<ShopConfig>, input) { const order = await call(ctx, '/orders', { product_id: input.productId, quantity: input.quantity, email: input.email, currency: ctx.config.currency, }) return { id: readPath(order, 'id') ?? null, status: readPath(order, 'status') ?? 'unknown' } }, }),这里的
input.quantity是number,而不是可选值:schema 的默认值会在run之前填入。
加入新文件后,pnpm --filter @malla/plugins check-types 通过。
错误与其他能力
从 @malla/plugin-sdk 抛出带有某个错误码的 PluginError。api 会把错误码映射为 HTTP 状态和错误代码,Test connection 横幅会显示信息,并在括号中附上错误码。
| 错误码 | 由谁抛出 |
|---|---|
invalid_config | 你的代码,用于缺少密钥或设置错误。SDK 也会在已保存的配置与 schema 不匹配时抛出。 |
invalid_input | SDK,在输入与操作 schema 不匹配时抛出。你的代码,用于 schema 无法表达的检查。 |
operation_failed | 你的代码(upstreamError),用于上游调用失败。ctx.fetch 也会在网络错误和重定向过多时抛出。 |
fetch_blocked | ctx.fetch:主机不在 hosts 中、明文 HTTP、私有地址,或重定向到其他主机。 |
dns_failed | ctx.fetch:无法解析主机。 |
timeout | ctx.fetch:在时间限制内没有响应。 |
response_too_large | ctx.fetch:响应正文大于 2 MB。 |
除了 config、secrets 和 fetch,上下文还有 ctx.signal(调用方取消时中止)和 ctx.published(在线上站点为 true,在编辑器中为 false)。官方插件还能使用 ctx.cms(列出和获取 CMS 条目)和 ctx.postgres(通过 TCP 访问 Postgres,地址检查与 ctx.fetch 相同)(见 cms.ts 和 postgres.ts)。仅在编辑器中,ctx.saveConfig 可以为集成保存新的配置。
注册插件
把它加入注册表
打开
packages/plugins/src/index.ts。导入插件,连同其配置类型一起导出,并把它加入PLUGINS。PLUGINS的顺序就是编辑器菜单中的顺序。tsimport { shopPlugin } from './shop' export { shopPlugin, type ShopConfig } from './shop' export const PLUGINS: readonly Plugin[] = [ restPlugin, // …the other plugins openaiPlugin, shopPlugin, ] as readonly Plugin[]保留 id
在
packages/schema/src/data.ts中BUILTIN_PLUGIN_IDS的末尾添加'shop'。这个列表防止目录插件占用内置 id,plugins.test.ts会检查两个列表是否一致。更新列出所有插件的测试
有两个测试按顺序列出了插件 id。在两者末尾都添加
'shop':packages/plugins/src/plugins.test.ts中的第一个测试(“bundles every first-party plugin, each reserved as a built-in id”),apps/api/src/routes/integrations.test.ts中的GET /plugins测试。
更新编辑器和文档
如果你的插件有
secretBoundFields,把它加入apps/web/app/editor/logic/integrationSecrets.ts中的SECRET_BOUND_FIELDS,写明保存地址的字段及其他绑定字段。编辑器用这个映射在保存会清除密钥前发出警告;无论如何 api 都会清除密钥。然后在
docs/architecture/05-bindings-data.md的 “Other built-in connectors” 表格中为该插件添加一行。
编写测试
创建测试文件
创建
packages/plugins/src/shop.test.ts。test-utils.ts中的辅助函数会像 api 一样构建上下文:context()解析配置,在模拟上游前加上真实的 fetch 策略,并记录每个请求;failure()返回PluginError的错误码和详情;json()构建一个 JSON 响应。tsimport { runOperation, type PluginContext } from '@malla/plugin-sdk' import { describe, expect, it } from 'vitest' import { shopPlugin, type ShopConfig } from './shop' import { context, failure, json } from './test-utils' const config = { baseUrl: 'https://api.shop.test/v1' } const secrets = { apiKey: 'sk_test' } const list = shopPlugin.operations['shop.listProducts']! const order = shopPlugin.operations['shop.createOrder']!测试正常路径
像 api 一样通过
runOperation运行操作,这样会先应用输入 schema。除了结果,也要检查插件发出的请求。tsdescribe('shop plugin', () => { it('lists products with the key header and unwraps items', async () => { const items = [{ id: 'p1', name: 'Alpine Lamp', price: 49.5 }] const { ctx, requests } = context(shopPlugin, config, () => json({ items }), { secrets }) expect(await runOperation(list, ctx, { search: 'lamp' })).toEqual(items) expect(requests[0]!.url).toBe('https://api.shop.test/v1/products?search=lamp') expect(requests[0]!.headers.get('x-api-key')).toBe('sk_test') }) it('creates an order in the configured currency', async () => { const { ctx, requests } = context(shopPlugin, { ...config, currency: 'EUR' }, () => json({ id: 'o_1', status: 'pending' }, 201), { secrets }) const result = await runOperation(order, ctx, { productId: 'p1', email: 'ada@example.com' }) expect(result).toEqual({ id: 'o_1', status: 'pending' }) expect(requests[0]!.method).toBe('POST') expect(await requests[0]!.json()).toEqual({ product_id: 'p1', quantity: 1, email: 'ada@example.com', currency: 'EUR' }) })测试失败情况和连接测试
覆盖你的代码抛出的每种错误、出站策略(
hosts之外的主机会被拦截),以及连接测试的两种结果。最后关闭describe块。tsit('checks input, config and secrets', async () => { const { ctx } = context(shopPlugin, config, () => json({}), { secrets }) expect((await failure(runOperation(order, ctx, { productId: 'p1', email: 'nope' }))).code).toBe('invalid_input') const noKey = context(shopPlugin, config, () => json({ items: [] })) expect((await failure(runOperation(list, noKey.ctx, {}))).code).toBe('invalid_config') expect(() => context(shopPlugin, { baseUrl: 'ftp://shop.test' }, () => json({}))).toThrow() }) it('reports upstream errors and blocks other hosts', async () => { const { ctx } = context(shopPlugin, config, () => json({ message: 'Out of stock' }, 409), { secrets }) expect(await failure(runOperation(order, ctx, { productId: 'p1', email: 'ada@example.com' }))).toMatchObject({ code: 'operation_failed', details: { status: 409 } }) expect((await failure(ctx.fetch('https://evil.test/steal'))).code).toBe('fetch_blocked') }) it('tests the connection with an authenticated call', async () => { const ok = context(shopPlugin, config, () => json({ items: [] }), { secrets }) await expect(shopPlugin.connection!.test!(ok.ctx as PluginContext<ShopConfig>)).resolves.toBeUndefined() const denied = context(shopPlugin, config, () => json({ message: 'Invalid key' }, 401), { secrets }) await expect(shopPlugin.connection!.test!(denied.ctx as PluginContext<ShopConfig>)).rejects.toMatchObject({ code: 'operation_failed' }) }) })运行测试
在仓库根目录运行插件测试:
shpnpm --filter @malla/plugins test
@malla/plugins 中的所有测试都通过,包括 shop.test.ts 中的五个测试和 plugins.test.ts 中的注册表测试。
在沙箱中试用
启动本地 Shop API
e2e 的模拟 API 会在
X-Api-Key请求头保护下响应GET /products?search=,足以支持查询和连接测试。在单独的终端中,用一个端口和一个密钥启动它:shnode apps/web/e2e/support/fake-api.mjs 4199 shop-demo-key它没有
POST /orders,所以请在单元测试中或针对真实 API 试用变更操作。启动沙箱
在另一个终端中运行
pnpm sandbox,然后使用scripts/sandbox/README.md中的演示账户在http://localhost:3200/login登录。沙箱会设置ALLOW_LOCAL_INTEGRATIONS=1,所以 api 可以调用http://localhost。如果沙箱已经在运行,插件文件变化时 wrangler 会重新加载 api;如果下一步的菜单中没有 Shop API,请按 ⌃C 停止沙箱再重新启动。创建集成
打开 Demo site,在左侧边栏中选择 Data。在 Integrations 旁点击 +(Add integration),选择 Shop API。为它命名,把 Base url 设为
http://localhost:4199,保留 Currency,在 Secrets 中把 API key 填为shop-demo-key,然后点击 Create integration。测试连接
在 Test connection 卡片中点击 Run test。绿色横幅显示 Connected。把密钥改成错误的值,点击 Save changes 后再次运行测试:横幅显示
Upstream answered 401 (operation_failed)。然后把正确的密钥改回来。运行查询
在 Queries 旁点击 +(Add query)。在 Source 中,把 Integration 设为你的 Shop API 集成,Operation 设为 List products。在 Input 下的 Search 字段中输入
lamp,然后在 Result 卡片中点击 Run。
Result 卡片显示来自你的插件的两个产品:Alpine Lamp 和 Dune Lamp。
运行质量门禁
对整个 monorepo 做类型检查和测试
在仓库根目录运行本地开发指南中的质量门禁:
shpnpm check-types pnpm testpnpm test包含api中的GET /plugins测试和@malla/plugins中的注册表测试。若只想重新运行 api 测试,请使用pnpm --filter api exec vitest run src/routes/integrations.test.ts。小步提交
先提交插件及其测试,再提交注册相关内容(注册表、保留 id、列表测试),最后提交文档。请遵循
docs/frontend-conventions.md中的微提交规则。
Shop API 连接器已加入插件注册表,它的测试全部通过,使用它的查询也能在沙箱编辑器中运行。
你学到了什么
- 连接器就是一次
definePlugin调用:一个连接(Zod 配置、密钥字段、主机、测试)以及用defineOperation构建的操作。 - schema 上的
.describe()会成为集成表单和查询表单中的提示文字。 ctx.fetch只能访问连接返回的主机,并会对被拦截、过慢或过大的调用抛出相应的PluginError错误码。- 新插件还需要加入
PLUGINS、BUILTIN_PLUGIN_IDS,以及列出所有插件的两个测试。 test-utils.ts让你的操作在真实的 fetch 策略和模拟上游下运行。
阅读本教程背后的指南: