MallaDocs

Escribe un plugin

De un archivo vacío a un conector probado: operaciones, secretos, errores, pruebas y una ejecución en el sandbox.

Guía para desarrolladores
Intermedio45 min

Vas a construir: Un conector Shop API probado, con una clave de API, una consulta de productos y una mutación de pedidos, funcionando en el sandbox local

Lee primero: Desarrollo localConsultas e integraciones

En este tutorial escribes un conector para una pequeña API JSON, desde un archivo vacío hasta una suite de pruebas que pasa y una consulta que se ejecuta en el editor. El ejemplo es una Shop API con una clave de API, una consulta (listar productos) y una mutación (crear un pedido). Necesitas el repositorio clonado e instalado, y conviene que sepas cómo funcionan las consultas y las integraciones en el editor.

Escribe un pluginEl video llegará pronto. Los pasos escritos a continuación cubren todo.
Escribe un plugin· 12 min

Planifica el conector

Un plugin tiene dos partes. La conexión describe una integración: sus ajustes (un esquema Zod), sus campos secretos, los hosts a los que puede llegar y una prueba. Las operaciones son lo que llaman las consultas y los flujos de trabajo: cada una tiene un tipo (query o mutation), un esquema Zod de entrada y una función run. La guía del SDK de plugins tiene la referencia completa; este tutorial construye un plugin.

La Shop API de este ejemplo responde a GET /products?search= con { "items": [...] } y a POST /orders con { "id", "status" }. Espera la clave en un encabezado X-Api-Key.

Escribe la conexión

  1. Crea el archivo y el esquema de configuración

    Crea packages/plugins/src/shop.ts. El id del plugin es shop, así que el nombre del archivo sigue el de los demás plugins de la carpeta.

    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>
    

    El esquema de configuración se convierte en el formulario del editor. Cada clave es un campo, su etiqueta es la clave separada en palabras (baseUrl se muestra como "Base url") y el texto de .describe() es la pista que aparece debajo. Los campos con .default() son opcionales en el formulario. El esquema admite http para que el sandbox pueda llegar a una API local; la política saliente sigue rechazando HTTP sin cifrar hacia cualquier cosa que no sea un host local, y solo cuando las integraciones locales están permitidas.

  2. Describe las entradas

    Agrega los esquemas de entrada de las operaciones debajo de la configuración. Validan la entrada resuelta antes de llamar a run, y se convierten en el formulario de entrada de la consulta.

    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. Lee el secreto y llama a la API

    Agrega dos funciones auxiliares. ctx.secrets contiene los secretos descifrados que tienen valor, así que una clave que falta lanza invalid_config. ctx.fetch es el fetch que aplica la política. readBody y upstreamError de ./http analizan la respuesta y convierten un estado que no es 2xx en un error operation_failed. joinUrl de ./rest mantiene las rutas bajo la URL base.

    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. Define el plugin y su conexión

    Ahora llama a definePlugin. Deja operations vacío por el momento.

    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 enumera los campos secretos. Los valores se guardan cifrados y nunca vuelven desde la api. Los nombres de campo deben ser letras y dígitos (apiKey, no api-key); si no, definePlugin lanza un error.
    • hosts es la lista de hosts salientes permitidos: ctx.fetch solo puede llegar a los hosts que devuelve para la configuración guardada. No hay un permiso de red aparte para los plugins propios; permissions.network pertenece a los manifiestos del catálogo de terceros.
    • secretBoundFields nombra los ajustes que deciden a dónde va la clave. Cambiar uno de ellos, o el conjunto de hosts, borra los secretos guardados, salvo que la misma actualización envíe valores nuevos.
    • test se ejecuta cuando alguien presiona Run test. Haz una llamada barata que necesite la clave y lanza un PluginError cuando falle. Un Error simple no se mapea, y la prueba responde con un error de servidor en lugar de un mensaje legible.
    • icon es un nombre de ícono de Lucide. El editor solo incluye los nombres de íconos que aparecen en su propio código, así que elige uno que ya use (lucide:package es uno) o el ícono se mostrará vacío.
    • firstParty: true le da al contexto las capacidades de plugin propio descritas más abajo.

Agrega las operaciones

  1. Agrega la consulta

    Reemplaza operations: {} por el mapa de operaciones. Los ids de operación deben ser <plugin id>.<name> en camelCase; definePlugin lo comprueba cuando se carga el módulo.

    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 tipa input a partir del esquema, así que input.search es un string | undefined. Anota ctx con tu tipo de configuración para leer ctx.config.baseUrl con su tipo. readPath lee una ruta con puntos (items, data.items.0.name) solo a través de propiedades propias y devuelve undefined si falta un tramo. El esquema output no se aplica; el editor lo usa para el autocompletado.

  2. Agrega la mutación

    Agrega la segunda entrada al mismo mapa. Una mutation cambia datos en el origen, así que el editor la muestra con "(mutation)" después de su nombre.

    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' }
          },
        }),
    

    Aquí input.quantity es un number, no opcional: el valor por defecto del esquema lo completa antes de run.

Punto de control

pnpm --filter @malla/plugins check-types pasa con el nuevo archivo.

Errores y otras capacidades

Lanza PluginError de @malla/plugin-sdk con uno de sus códigos. La api mapea el código a un estado HTTP y a un código de error, y el aviso de Test connection muestra el mensaje con el código entre paréntesis.

CódigoQuién lo lanza
invalid_configTu código, por un secreto que falta o un ajuste incorrecto. También el SDK, cuando la configuración guardada no coincide con el esquema.
invalid_inputEl SDK, cuando la entrada no coincide con el esquema de la operación. Tu código, para comprobaciones que el esquema no puede expresar.
operation_failedTu código (upstreamError), por una llamada fallida al origen. También ctx.fetch, por errores de red y demasiadas redirecciones.
fetch_blockedctx.fetch: un host que no está en hosts, HTTP sin cifrar, una dirección privada o una redirección a otro host.
dns_failedctx.fetch: no se pudo resolver el host.
timeoutctx.fetch: no hubo respuesta dentro del tiempo límite.
response_too_largectx.fetch: el cuerpo pesa más de 2 MB.

Además de config, secrets y fetch, el contexto tiene ctx.signal (se aborta cuando quien llama cancela) y ctx.published (true en el sitio en línea, false en el editor). Los plugins propios también reciben ctx.cms, para listar y obtener ítems del CMS, y ctx.postgres, para Postgres sobre TCP con las mismas comprobaciones de dirección que ctx.fetch (mira cms.ts y postgres.ts). Solo en el editor, ctx.saveConfig guarda una nueva configuración para la integración.

Registra el plugin

  1. Agrégalo al registro

    Abre packages/plugins/src/index.ts. Importa el plugin, expórtalo con su tipo de configuración y agrégalo a PLUGINS. El orden de PLUGINS es el orden del menú del editor.

    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. Reserva el id

    Agrega 'shop' al final de BUILTIN_PLUGIN_IDS en packages/schema/src/data.ts. La lista impide que los plugins del catálogo tomen un id integrado, y plugins.test.ts comprueba que ambas listas coincidan.

  3. Actualiza las pruebas que enumeran todos los plugins

    Dos pruebas enumeran los ids de los plugins en orden. Agrega 'shop' al final de ambas:

    • la primera prueba de packages/plugins/src/plugins.test.ts ("bundles every first-party plugin, each reserved as a built-in id"),
    • la prueba de GET /plugins en apps/api/src/routes/integrations.test.ts.
  4. Actualiza el editor y la documentación

    Si tu plugin tiene secretBoundFields, agrégalo a SECRET_BOUND_FIELDS en apps/web/app/editor/logic/integrationSecrets.ts, con el campo que guarda la dirección y los demás campos vinculados. El editor usa ese mapa para avisar antes de que un guardado borre los secretos; la api los borra de todos modos.

    Luego agrega una fila para el plugin en la tabla "Other built-in connectors" de docs/architecture/05-bindings-data.md.

Escribe las pruebas

  1. Crea el archivo de pruebas

    Crea packages/plugins/src/shop.test.ts. Las funciones auxiliares de test-utils.ts construyen un contexto como lo hace la api: context() analiza la configuración, pone la política de fetch real delante de un origen falso y registra cada solicitud; failure() devuelve el código y los detalles de un PluginError; json() construye una respuesta 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. Prueba los casos exitosos

    Ejecuta las operaciones con runOperation, como hace la api, para que primero se aplique el esquema de entrada. Comprueba tanto la solicitud que envió el plugin como el resultado.

    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. Prueba los fallos y la prueba de conexión

    Cubre cada error que lanza tu código, la política (un host fuera de hosts se bloquea) y los dos resultados de la prueba de conexión. Cierra el bloque describe al final.

    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. Ejecútalas

    Ejecuta las pruebas del plugin desde la raíz del repositorio:

    sh
    pnpm --filter @malla/plugins test
    
Punto de control

Todas las pruebas de @malla/plugins pasan, incluidas las cinco de shop.test.ts y la prueba del registro en plugins.test.ts.

Pruébalo en el sandbox

  1. Inicia una Shop API local

    La API falsa de e2e responde a GET /products?search= detrás de un encabezado X-Api-Key, lo que basta para la consulta y la prueba de conexión. Iníciala en su propia terminal con un puerto y una clave:

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

    No tiene POST /orders, así que prueba la mutación en tus pruebas unitarias o contra una API real.

  2. Inicia el sandbox

    En otra terminal, ejecuta pnpm sandbox e inicia sesión en http://localhost:3200/login con la cuenta de demostración de scripts/sandbox/README.md. El sandbox define ALLOW_LOCAL_INTEGRATIONS=1, así que la api puede llamar a http://localhost. Si el sandbox ya estaba en marcha, wrangler recarga la api cuando cambian los archivos del plugin; si Shop API no aparece en el menú en el siguiente paso, detén el sandbox con ⌃C y vuelve a iniciarlo.

  3. Crea la integración

    Abre Demo site y elige Data en la barra lateral izquierda. Junto a Integrations, presiona + (Add integration) y elige Shop API. Ponle un nombre, pon Base url en http://localhost:4199, deja Currency, escribe shop-demo-key como API key en Secrets y presiona Create integration.

  4. Prueba la conexión

    En la tarjeta Test connection, presiona Run test. Un aviso verde dice Connected. Cambia la clave a un valor incorrecto, presiona Save changes y vuelve a ejecutar la prueba: el aviso muestra Upstream answered 401 (operation_failed). Vuelve a poner la clave correcta.

  5. Ejecuta una consulta

    Junto a Queries, presiona + (Add query). En Source, pon Integration en tu integración Shop API y Operation en List products. Escribe lamp en el campo Search de Input y presiona Run en la tarjeta Result.

Punto de control

La tarjeta Result muestra dos productos, Alpine Lamp y Dune Lamp, que vienen de tu plugin.

Ejecuta los controles de calidad

  1. Revisa tipos y prueba el monorepo

    Desde la raíz del repositorio, ejecuta los controles de calidad de la guía de desarrollo local:

    sh
    pnpm check-types
    pnpm test
    

    pnpm test incluye la prueba de GET /plugins en api y la prueba del registro en @malla/plugins. Para volver a ejecutar solo la prueba de la api, usa pnpm --filter api exec vitest run src/routes/integrations.test.ts.

  2. Haz commits pequeños

    Haz un commit con el plugin y sus pruebas, luego otro con el registro (registro, id reservado, pruebas de listas) y luego otro con la documentación. Sigue las reglas de micro commits de docs/frontend-conventions.md.

Listo

El conector Shop API está en el registro de plugins, sus pruebas pasan y una consulta que lo usa se ejecuta en el editor del sandbox.

Lo que aprendiste

  • Un conector es una llamada a definePlugin: una conexión (configuración Zod, campos secretos, hosts, prueba) y operaciones construidas con defineOperation.
  • .describe() en los esquemas escribe las pistas de los formularios de integración y de consulta.
  • ctx.fetch solo llega a los hosts que devuelve la conexión, y lanza códigos de PluginError para llamadas bloqueadas, lentas o demasiado grandes.
  • Un plugin nuevo también va en PLUGINS, en BUILTIN_PLUGIN_IDS y en las dos pruebas que enumeran todos los plugins.
  • test-utils.ts ejecuta tus operaciones detrás de la política de fetch real con un origen falso.

Lee las guías en las que se basa este tutorial: