Vous allez créer : Un connecteur Shop API testé, avec une clé API, une requête de produits et une mutation de commande, qui s’exécute dans la sandbox locale
À lire avant : Développement localRequêtes et intégrations
Dans ce tutoriel, vous écrivez un connecteur pour une petite API JSON, d’un fichier vide jusqu’à une suite de tests qui passe et une requête qui s’exécute dans l’éditeur. L’exemple est une Shop API avec une clé API, une requête (lister les produits) et une mutation (créer une commande). Il vous faut le dépôt cloné et installé, et vous devez savoir comment fonctionnent les requêtes et les intégrations dans l’éditeur.
Concevoir le connecteur
Un plugin a deux parties. La connexion décrit une intégration : ses réglages (un schéma Zod), ses champs secrets, les hôtes qu’elle peut joindre et un test. Les opérations sont ce qu’appellent les requêtes et les workflows : chacune a un type (query ou mutation), un schéma d’entrée Zod et une fonction run. Le guide Plugin SDK contient la référence complète ; ce tutoriel en construit un.
Dans cet exemple, la Shop API répond à GET /products?search= avec { "items": [...] } et à POST /orders avec { "id", "status" }. Elle attend la clé dans un en-tête X-Api-Key.
Écrire la connexion
Créer le fichier et le schéma de configuration
Créez
packages/plugins/src/shop.ts. L’identifiant du plugin estshop: le nom du fichier suit donc la convention des autres plugins du dossier.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>Le schéma de configuration devient le formulaire dans l’éditeur. Chaque clé est un champ, son libellé est la clé découpée en mots (
baseUrls’affiche « Base url »), et le texte de.describe()est l’indication sous le champ. Les champs avec un.default()sont facultatifs dans le formulaire. Le schéma autorisehttppour que la sandbox puisse joindre une API locale ; la politique de requêtes sortantes refuse quand même le HTTP non chiffré vers tout autre hôte qu’un hôte local, et seulement quand les intégrations locales sont autorisées.Décrire les entrées
Ajoutez les schémas d’entrée des opérations sous la configuration. Ils valident l’entrée résolue avant l’appel de
run, et ils deviennent le formulaire d’entrée de la requête.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'), })Lire le secret et appeler l’API
Ajoutez deux fonctions utilitaires.
ctx.secretscontient les secrets déchiffrés qui ont une valeur : une clé manquante lève doncinvalid_config.ctx.fetchest le fetch qui applique la politique.readBodyetupstreamErrorde./httpanalysent la réponse et transforment un statut hors 2xx en erreuroperation_failed.joinUrlde./restgarde les chemins sous l’URL de 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 }Définir le plugin et sa connexion
Appelez maintenant
definePlugin. Laissezoperationsvide pour l’instant.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: {}, })secretsliste les champs secrets. Les valeurs sont chiffrées au repos et ne reviennent jamais de l’api. Les noms de champ doivent être composés de lettres et de chiffres (apiKey, pasapi-key) ; sinon,definePluginlève une erreur.hostsest la liste d’autorisation des requêtes sortantes :ctx.fetchne peut joindre que les hôtes qu’elle renvoie pour la configuration enregistrée. Il n’y a pas de permission réseau séparée pour les plugins maison ;permissions.networkconcerne les manifestes du catalogue tiers.secretBoundFieldsnomme les réglages qui déterminent où part la clé. En modifier un, ou modifier l’ensemble des hôtes, efface les secrets enregistrés, sauf si la même mise à jour envoie de nouvelles valeurs.tests’exécute quand quelqu’un clique sur Run test. Faites un appel peu coûteux qui nécessite la clé, et levez unePluginErroren cas d’échec. UneErrorsimple n’est pas convertie, et le test renvoie une erreur serveur au lieu d’un message lisible.iconest un nom d’icône Lucide. L’éditeur n’embarque que les noms d’icônes présents dans ses propres sources : choisissez-en une qu’il utilise déjà (lucide:packageen fait partie), sinon l’icône s’affiche vide.firstParty: truedonne au contexte les capacités maison décrites plus bas.
Ajouter les opérations
Ajouter la requête
Remplacez
operations: {}par la table des opérations. Les identifiants d’opération doivent être de la forme<plugin id>.<name>en camelCase ;definePluginle vérifie au chargement du module.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 }, }), },defineOperationtypeinputà partir du schéma :input.searchest donc unstring | undefined. Annotezctxavec votre type de configuration pour lirectx.config.baseUrlavec son type.readPathlit un chemin pointé (items,data.items.0.name) en ne passant que par les propriétés propres, et renvoieundefinedpour une étape manquante. Le schémaoutputn’est pas appliqué ; l’éditeur s’en sert pour l’autocomplétion.Ajouter la mutation
Ajoutez la seconde entrée dans la même table. Une
mutationmodifie des données côté API distante : l’éditeur l’affiche donc avec « (mutation) » après son nom.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' } }, }),Ici,
input.quantityest unnumber, et non une valeur facultative : la valeur par défaut du schéma le remplit avantrun.
pnpm --filter @malla/plugins check-types passe avec le nouveau fichier.
Erreurs et autres capacités
Levez une PluginError de @malla/plugin-sdk avec l’un de ses codes. L’api convertit le code en statut HTTP et en code d’erreur, et le bandeau Test connection affiche le message avec le code entre parenthèses.
| Code | Qui la lève |
|---|---|
invalid_config | Votre code, pour un secret manquant ou un réglage incorrect. Le SDK aussi, quand la configuration enregistrée ne correspond pas au schéma. |
invalid_input | Le SDK, quand l’entrée ne correspond pas au schéma de l’opération. Votre code, pour les vérifications que le schéma ne peut pas exprimer. |
operation_failed | Votre code (upstreamError), pour un appel distant qui échoue. ctx.fetch aussi, pour les erreurs réseau et les redirections trop nombreuses. |
fetch_blocked | ctx.fetch : un hôte absent de hosts, du HTTP non chiffré, une adresse privée ou une redirection vers un autre hôte. |
dns_failed | ctx.fetch : l’hôte n’a pas pu être résolu. |
timeout | ctx.fetch : aucune réponse dans le délai imparti. |
response_too_large | ctx.fetch : le corps dépasse 2 Mo. |
En plus de config, secrets et fetch, le contexte fournit ctx.signal (interrompu quand l’appelant annule) et ctx.published (true sur le site en ligne, false dans l’éditeur). Les plugins maison reçoivent aussi ctx.cms, pour lister et lire des éléments CMS, et ctx.postgres, pour Postgres en TCP avec les mêmes contrôles d’adresse que ctx.fetch (voir cms.ts et postgres.ts). Dans l’éditeur uniquement, ctx.saveConfig enregistre une nouvelle configuration pour l’intégration.
Enregistrer le plugin
L’ajouter au registre
Ouvrez
packages/plugins/src/index.ts. Importez le plugin, exportez-le avec son type de configuration et ajoutez-le àPLUGINS. L’ordre dePLUGINSest l’ordre du menu de l’éditeur.tsimport { shopPlugin } from './shop' export { shopPlugin, type ShopConfig } from './shop' export const PLUGINS: readonly Plugin[] = [ restPlugin, // …the other plugins openaiPlugin, shopPlugin, ] as readonly Plugin[]Réserver l’identifiant
Ajoutez
'shop'à la fin deBUILTIN_PLUGIN_IDSdanspackages/schema/src/data.ts. Cette liste empêche les plugins du catalogue de prendre un identifiant intégré, etplugins.test.tsvérifie que les deux listes correspondent.Mettre à jour les tests qui listent tous les plugins
Deux tests listent les identifiants de plugin dans l’ordre. Ajoutez
'shop'à la fin des deux :- le premier test de
packages/plugins/src/plugins.test.ts(« bundles every first-party plugin, each reserved as a built-in id »), - le test
GET /pluginsdeapps/api/src/routes/integrations.test.ts.
- le premier test de
Mettre à jour l’éditeur et la documentation
Si votre plugin a des
secretBoundFields, ajoutez-le àSECRET_BOUND_FIELDSdansapps/web/app/editor/logic/integrationSecrets.ts, avec le champ qui contient l’adresse et les autres champs liés. L’éditeur utilise cette table pour avertir avant qu’un enregistrement efface les secrets ; l’api les efface de toute façon.Ajoutez ensuite une ligne pour le plugin dans le tableau « Other built-in connectors » de
docs/architecture/05-bindings-data.md.
Écrire les tests
Créer le fichier de test
Créez
packages/plugins/src/shop.test.ts. Les utilitaires detest-utils.tsconstruisent un contexte comme le fait l’api :context()analyse la configuration, place la vraie politique de fetch devant une API distante factice et enregistre chaque requête ;failure()renvoie le code et les détails d’unePluginError;json()construit une réponse 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']!Tester les cas nominaux
Exécutez les opérations via
runOperation, comme le fait l’api, pour que le schéma d’entrée soit appliqué en premier. Vérifiez la requête envoyée par le plugin autant que le résultat.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' }) })Tester les échecs et le test de connexion
Couvrez chaque erreur levée par votre code, la politique (un hôte hors de
hostsest bloqué) et les deux issues du test de connexion. Fermez le blocdescribeà la fin.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' }) }) })Les exécuter
Lancez les tests du plugin depuis la racine du dépôt :
shpnpm --filter @malla/plugins test
Tous les tests de @malla/plugins passent, y compris les cinq de shop.test.ts et le test du registre dans plugins.test.ts.
L’essayer dans la sandbox
Démarrer une Shop API locale
L’API factice des tests e2e répond à
GET /products?search=derrière un en-têteX-Api-Key, ce qui suffit pour la requête et le test de connexion. Démarrez-la dans son propre terminal avec un port et une clé :shnode apps/web/e2e/support/fake-api.mjs 4199 shop-demo-keyElle n’a pas de
POST /orders: essayez donc la mutation dans vos tests unitaires, ou contre une vraie API.Démarrer la sandbox
Dans un autre terminal, lancez
pnpm sandboxet connectez-vous surhttp://localhost:3200/loginavec le compte de démonstration indiqué dansscripts/sandbox/README.md. La sandbox définitALLOW_LOCAL_INTEGRATIONS=1: l’api peut donc appelerhttp://localhost. Si la sandbox tournait déjà, wrangler recharge l’api quand les fichiers du plugin changent ; si Shop API manque dans le menu à l’étape suivante, arrêtez la sandbox avec ⌃C et relancez-la.Créer l’intégration
Ouvrez Demo site et choisissez Data dans la barre latérale gauche. À côté de Integrations, cliquez sur + (Add integration) et choisissez Shop API. Nommez-la, réglez Base url sur
http://localhost:4199, gardez Currency, saisissezshop-demo-keycomme API key dans Secrets, puis cliquez sur Create integration.Tester la connexion
Dans la carte Test connection, cliquez sur Run test. Un bandeau vert indique Connected. Remplacez la clé par une valeur erronée, cliquez sur Save changes et relancez le test : le bandeau affiche
Upstream answered 401 (operation_failed). Remettez la bonne clé.Exécuter une requête
À côté de Queries, cliquez sur + (Add query). Dans Source, réglez Integration sur votre intégration Shop API et Operation sur List products. Tapez
lampdans le champ Search sous Input, puis cliquez sur Run dans la carte Result.
La carte Result affiche deux produits, Alpine Lamp et Dune Lamp, venant de votre plugin.
Lancer les contrôles qualité
Vérifier les types et tester le monorepo
Depuis la racine du dépôt, lancez les contrôles qualité du guide Développement local :
shpnpm check-types pnpm testpnpm testinclut le testGET /pluginsdansapiet le test du registre dans@malla/plugins. Pour relancer uniquement le test de l’api, utilisezpnpm --filter api exec vitest run src/routes/integrations.test.ts.Committer par petites étapes
Committez le plugin et ses tests, puis l’enregistrement (registre, identifiant réservé, tests de liste), puis la documentation. Suivez les règles de micro-commits de
docs/frontend-conventions.md.
Le connecteur Shop API est dans le registre des plugins, ses tests passent, et une requête qui l’utilise s’exécute dans l’éditeur de la sandbox.
Ce que vous avez appris
- Un connecteur est un appel à
definePlugin: une connexion (configuration Zod, champs secrets, hôtes, test) et des opérations construites avecdefineOperation. .describe()sur les schémas écrit les indications des formulaires d’intégration et de requête.ctx.fetchne joint que les hôtes renvoyés par la connexion, et lève des codesPluginErrorpour les appels bloqués, lents ou trop volumineux.- Un nouveau plugin va aussi dans
PLUGINS,BUILTIN_PLUGIN_IDSet les deux tests qui listent tous les plugins. test-utils.tsexécute vos opérations derrière la vraie politique de fetch, avec une API distante factice.
Lisez les guides sur lesquels ce tutoriel s’appuie :