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.
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
Crea el archivo y el esquema de configuración
Crea
packages/plugins/src/shop.ts. El id del plugin esshop, así que el nombre del archivo sigue el de los demás plugins de la carpeta.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>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 (
baseUrlse 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 admitehttppara 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.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.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'), })Lee el secreto y llama a la API
Agrega dos funciones auxiliares.
ctx.secretscontiene los secretos descifrados que tienen valor, así que una clave que falta lanzainvalid_config.ctx.fetches el fetch que aplica la política.readBodyyupstreamErrorde./httpanalizan la respuesta y convierten un estado que no es 2xx en un erroroperation_failed.joinUrlde./restmantiene las rutas bajo la URL base.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 }Define el plugin y su conexión
Ahora llama a
definePlugin. Dejaoperationsvacío por el momento.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: {}, })secretsenumera 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, noapi-key); si no,definePluginlanza un error.hostses la lista de hosts salientes permitidos:ctx.fetchsolo puede llegar a los hosts que devuelve para la configuración guardada. No hay un permiso de red aparte para los plugins propios;permissions.networkpertenece a los manifiestos del catálogo de terceros.secretBoundFieldsnombra 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.testse ejecuta cuando alguien presiona Run test. Haz una llamada barata que necesite la clave y lanza unPluginErrorcuando falle. UnErrorsimple no se mapea, y la prueba responde con un error de servidor en lugar de un mensaje legible.icones 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:packagees uno) o el ícono se mostrará vacío.firstParty: truele da al contexto las capacidades de plugin propio descritas más abajo.
Agrega las operaciones
Agrega la consulta
Reemplaza
operations: {}por el mapa de operaciones. Los ids de operación deben ser<plugin id>.<name>en camelCase;definePluginlo comprueba cuando se carga el módulo.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 }, }), },defineOperationtipainputa partir del esquema, así queinput.searches unstring | undefined. Anotactxcon tu tipo de configuración para leerctx.config.baseUrlcon su tipo.readPathlee una ruta con puntos (items,data.items.0.name) solo a través de propiedades propias y devuelveundefinedsi falta un tramo. El esquemaoutputno se aplica; el editor lo usa para el autocompletado.Agrega la mutación
Agrega la segunda entrada al mismo mapa. Una
mutationcambia 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.quantityes unnumber, no opcional: el valor por defecto del esquema lo completa antes derun.
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ódigo | Quién lo lanza |
|---|---|
invalid_config | Tu 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_input | El 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_failed | Tu código (upstreamError), por una llamada fallida al origen. También ctx.fetch, por errores de red y demasiadas redirecciones. |
fetch_blocked | ctx.fetch: un host que no está en hosts, HTTP sin cifrar, una dirección privada o una redirección a otro host. |
dns_failed | ctx.fetch: no se pudo resolver el host. |
timeout | ctx.fetch: no hubo respuesta dentro del tiempo límite. |
response_too_large | ctx.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
Agrégalo al registro
Abre
packages/plugins/src/index.ts. Importa el plugin, expórtalo con su tipo de configuración y agrégalo aPLUGINS. El orden dePLUGINSes el orden del menú del editor.tsimport { shopPlugin } from './shop' export { shopPlugin, type ShopConfig } from './shop' export const PLUGINS: readonly Plugin[] = [ restPlugin, // …the other plugins openaiPlugin, shopPlugin, ] as readonly Plugin[]Reserva el id
Agrega
'shop'al final deBUILTIN_PLUGIN_IDSenpackages/schema/src/data.ts. La lista impide que los plugins del catálogo tomen un id integrado, yplugins.test.tscomprueba que ambas listas coincidan.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 /pluginsenapps/api/src/routes/integrations.test.ts.
- la primera prueba de
Actualiza el editor y la documentación
Si tu plugin tiene
secretBoundFields, agrégalo aSECRET_BOUND_FIELDSenapps/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
Crea el archivo de pruebas
Crea
packages/plugins/src/shop.test.ts. Las funciones auxiliares detest-utils.tsconstruyen 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 unPluginError;json()construye una respuesta 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']!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.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' }) })Prueba los fallos y la prueba de conexión
Cubre cada error que lanza tu código, la política (un host fuera de
hostsse bloquea) y los dos resultados de la prueba de conexión. Cierra el bloquedescribeal final.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' }) }) })Ejecútalas
Ejecuta las pruebas del plugin desde la raíz del repositorio:
shpnpm --filter @malla/plugins test
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
Inicia una Shop API local
La API falsa de e2e responde a
GET /products?search=detrás de un encabezadoX-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:shnode apps/web/e2e/support/fake-api.mjs 4199 shop-demo-keyNo tiene
POST /orders, así que prueba la mutación en tus pruebas unitarias o contra una API real.Inicia el sandbox
En otra terminal, ejecuta
pnpm sandboxe inicia sesión enhttp://localhost:3200/logincon la cuenta de demostración descripts/sandbox/README.md. El sandbox defineALLOW_LOCAL_INTEGRATIONS=1, así que la api puede llamar ahttp://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.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, escribeshop-demo-keycomo API key en Secrets y presiona Create integration.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.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
lampen el campo Search de Input y presiona Run en la tarjeta Result.
La tarjeta Result muestra dos productos, Alpine Lamp y Dune Lamp, que vienen de tu plugin.
Ejecuta los controles de calidad
Revisa tipos y prueba el monorepo
Desde la raíz del repositorio, ejecuta los controles de calidad de la guía de desarrollo local:
shpnpm check-types pnpm testpnpm testincluye la prueba deGET /pluginsenapiy la prueba del registro en@malla/plugins. Para volver a ejecutar solo la prueba de la api, usapnpm --filter api exec vitest run src/routes/integrations.test.ts.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.
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 condefineOperation. .describe()en los esquemas escribe las pistas de los formularios de integración y de consulta.ctx.fetchsolo llega a los hosts que devuelve la conexión, y lanza códigos dePluginErrorpara llamadas bloqueadas, lentas o demasiado grandes.- Un plugin nuevo también va en
PLUGINS, enBUILTIN_PLUGIN_IDSy en las dos pruebas que enumeran todos los plugins. test-utils.tsejecuta 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: