Malla文档

编写插件

从空文件到经过测试的连接器:操作、密钥、错误、测试,以及在沙箱中运行。

开发者指南
中级45 分钟

你将构建: 一个经过测试的 Shop API 连接器,带 API 密钥、一个产品查询和一个下单变更操作,在本地沙箱中运行

请先阅读: 本地开发查询与集成

在本教程中,你将为一个小型 JSON API 编写连接器,从一个空文件开始,直到测试套件全部通过,并在编辑器中运行一个查询。示例是一个使用 API 密钥的 Shop API,包含一个查询(列出产品)和一个变更操作(创建订单)。你需要已检出并安装好依赖的仓库,并且应该了解编辑器中查询和集成的工作方式。

编写插件视频即将推出。下面的文字步骤已涵盖全部内容。
编写插件· 12 分钟

规划连接器

插件由两部分组成。连接(connection)描述一种集成:它的设置(一个 Zod schema)、密钥字段、允许访问的主机,以及一个测试。操作(operations)是查询和工作流调用的内容:每个操作都有一个类型(query 或 mutation)、一个 Zod 输入 schema 和一个 run 函数。完整参考请见 Plugin SDK 指南;本教程会实际构建一个。

本例中的 Shop API 对 GET /products?search= 返回 { "items": [...] },对 POST /orders 返回 { "id", "status" }。它要求在 X-Api-Key 请求头中携带密钥。

编写连接

  1. 创建文件和配置 schema

    创建 packages/plugins/src/shop.ts。插件 id 为 shop,所以文件名与该文件夹中的其他插件保持一致。

    ts
    import { 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,并且只在允许本地集成时才放行本地主机。

  2. 描述输入

    在配置下方添加操作的输入 schema。它们会在调用 run 之前校验解析后的输入,并成为查询的输入表单。

    ts
    const 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'),
    })
    
  3. 读取密钥并调用 API

    添加两个辅助函数。ctx.secrets 保存已解密且有值的密钥,所以缺少密钥时会抛出 invalid_config。ctx.fetch 是执行出站策略的 fetch。./http 中的 readBody 和 upstreamError 负责解析响应,并把非 2xx 状态转换为 operation_failed 错误。./rest 中的 joinUrl 确保路径位于基础 URL 之下。

    ts
    function 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
    }
    
  4. 定义插件及其连接

    现在调用 definePlugin。暂时让 operations 保持为空。

    ts
    export 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 让上下文获得下文所述的官方插件能力。

添加操作

  1. 添加查询

    把 operations: {} 替换为操作映射。操作 id 必须是驼峰式的 <plugin id>.<name>;definePlugin 会在模块加载时检查。

    ts
      operations: {
        '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。output schema 不会被强制校验;编辑器用它来提供自动补全。

  2. 添加变更操作

    在同一个映射中添加第二项。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_inputSDK,在输入与操作 schema 不匹配时抛出。你的代码,用于 schema 无法表达的检查。
operation_failed你的代码(upstreamError),用于上游调用失败。ctx.fetch 也会在网络错误和重定向过多时抛出。
fetch_blockedctx.fetch:主机不在 hosts 中、明文 HTTP、私有地址,或重定向到其他主机。
dns_failedctx.fetch:无法解析主机。
timeoutctx.fetch:在时间限制内没有响应。
response_too_largectx.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 可以为集成保存新的配置。

注册插件

  1. 把它加入注册表

    打开 packages/plugins/src/index.ts。导入插件,连同其配置类型一起导出,并把它加入 PLUGINS。PLUGINS 的顺序就是编辑器菜单中的顺序。

    ts
    import { shopPlugin } from './shop'
    
    export { shopPlugin, type ShopConfig } from './shop'
    
    export const PLUGINS: readonly Plugin[] = [
      restPlugin,
      // …the other plugins
      openaiPlugin,
      shopPlugin,
    ] as readonly Plugin[]
    
  2. 保留 id

    在 packages/schema/src/data.ts 中 BUILTIN_PLUGIN_IDS 的末尾添加 'shop'。这个列表防止目录插件占用内置 id,plugins.test.ts 会检查两个列表是否一致。

  3. 更新列出所有插件的测试

    有两个测试按顺序列出了插件 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 测试。
  4. 更新编辑器和文档

    如果你的插件有 secretBoundFields,把它加入 apps/web/app/editor/logic/integrationSecrets.ts 中的 SECRET_BOUND_FIELDS,写明保存地址的字段及其他绑定字段。编辑器用这个映射在保存会清除密钥前发出警告;无论如何 api 都会清除密钥。

    然后在 docs/architecture/05-bindings-data.md 的 “Other built-in connectors” 表格中为该插件添加一行。

编写测试

  1. 创建测试文件

    创建 packages/plugins/src/shop.test.ts。test-utils.ts 中的辅助函数会像 api 一样构建上下文:context() 解析配置,在模拟上游前加上真实的 fetch 策略,并记录每个请求;failure() 返回 PluginError 的错误码和详情;json() 构建一个 JSON 响应。

    ts
    import { 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']!
    
  2. 测试正常路径

    像 api 一样通过 runOperation 运行操作,这样会先应用输入 schema。除了结果,也要检查插件发出的请求。

    ts
    describe('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' })
      })
    
  3. 测试失败情况和连接测试

    覆盖你的代码抛出的每种错误、出站策略(hosts 之外的主机会被拦截),以及连接测试的两种结果。最后关闭 describe 块。

    ts
      it('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' })
      })
    })
    
  4. 运行测试

    在仓库根目录运行插件测试:

    sh
    pnpm --filter @malla/plugins test
    
检查点

@malla/plugins 中的所有测试都通过,包括 shop.test.ts 中的五个测试和 plugins.test.ts 中的注册表测试。

在沙箱中试用

  1. 启动本地 Shop API

    e2e 的模拟 API 会在 X-Api-Key 请求头保护下响应 GET /products?search=,足以支持查询和连接测试。在单独的终端中,用一个端口和一个密钥启动它:

    sh
    node apps/web/e2e/support/fake-api.mjs 4199 shop-demo-key
    

    它没有 POST /orders,所以请在单元测试中或针对真实 API 试用变更操作。

  2. 启动沙箱

    在另一个终端中运行 pnpm sandbox,然后使用 scripts/sandbox/README.md 中的演示账户在 http://localhost:3200/login 登录。沙箱会设置 ALLOW_LOCAL_INTEGRATIONS=1,所以 api 可以调用 http://localhost。如果沙箱已经在运行,插件文件变化时 wrangler 会重新加载 api;如果下一步的菜单中没有 Shop API,请按 ⌃C 停止沙箱再重新启动。

  3. 创建集成

    打开 Demo site,在左侧边栏中选择 Data。在 Integrations 旁点击 +(Add integration),选择 Shop API。为它命名,把 Base url 设为 http://localhost:4199,保留 Currency,在 Secrets 中把 API key 填为 shop-demo-key,然后点击 Create integration。

  4. 测试连接

    在 Test connection 卡片中点击 Run test。绿色横幅显示 Connected。把密钥改成错误的值,点击 Save changes 后再次运行测试:横幅显示 Upstream answered 401 (operation_failed)。然后把正确的密钥改回来。

  5. 运行查询

    在 Queries 旁点击 +(Add query)。在 Source 中,把 Integration 设为你的 Shop API 集成,Operation 设为 List products。在 Input 下的 Search 字段中输入 lamp,然后在 Result 卡片中点击 Run。

检查点

Result 卡片显示来自你的插件的两个产品:Alpine Lamp 和 Dune Lamp。

运行质量门禁

  1. 对整个 monorepo 做类型检查和测试

    在仓库根目录运行本地开发指南中的质量门禁:

    sh
    pnpm check-types
    pnpm test
    

    pnpm test 包含 api 中的 GET /plugins 测试和 @malla/plugins 中的注册表测试。若只想重新运行 api 测试,请使用 pnpm --filter api exec vitest run src/routes/integrations.test.ts。

  2. 小步提交

    先提交插件及其测试,再提交注册相关内容(注册表、保留 id、列表测试),最后提交文档。请遵循 docs/frontend-conventions.md 中的微提交规则。

完成

Shop API 连接器已加入插件注册表,它的测试全部通过,使用它的查询也能在沙箱编辑器中运行。

你学到了什么

  • 连接器就是一次 definePlugin 调用:一个连接(Zod 配置、密钥字段、主机、测试)以及用 defineOperation 构建的操作。
  • schema 上的 .describe() 会成为集成表单和查询表单中的提示文字。
  • ctx.fetch 只能访问连接返回的主机,并会对被拦截、过慢或过大的调用抛出相应的 PluginError 错误码。
  • 新插件还需要加入 PLUGINS、BUILTIN_PLUGIN_IDS,以及列出所有插件的两个测试。
  • test-utils.ts 让你的操作在真实的 fetch 策略和模拟上游下运行。

阅读本教程背后的指南: