MallaDocs

Écrire un plugin

D’un fichier vide à un connecteur testé : opérations, secrets, erreurs, tests et une exécution dans la sandbox.

Guide développeur
Intermédiaire45 min

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.

Écrire un pluginVidéo bientôt disponible. Les étapes écrites ci-dessous couvrent tout.
Écrire un plugin· 12 min

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

  1. Créer le fichier et le schéma de configuration

    Créez packages/plugins/src/shop.ts. L’identifiant du plugin est shop : le nom du fichier suit donc la convention des autres plugins du dossier.

    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>
    

    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 (baseUrl s’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 autorise http pour 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.

  2. 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.

    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. Lire le secret et appeler l’API

    Ajoutez deux fonctions utilitaires. ctx.secrets contient les secrets déchiffrés qui ont une valeur : une clé manquante lève donc invalid_config. ctx.fetch est le fetch qui applique la politique. readBody et upstreamError de ./http analysent la réponse et transforment un statut hors 2xx en erreur operation_failed. joinUrl de ./rest garde les chemins sous l’URL de 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. Définir le plugin et sa connexion

    Appelez maintenant definePlugin. Laissez operations vide pour l’instant.

    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 liste 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, pas api-key) ; sinon, definePlugin lève une erreur.
    • hosts est la liste d’autorisation des requêtes sortantes : ctx.fetch ne 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.network concerne les manifestes du catalogue tiers.
    • secretBoundFields nomme 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.
    • test s’exécute quand quelqu’un clique sur Run test. Faites un appel peu coûteux qui nécessite la clé, et levez une PluginError en cas d’échec. Une Error simple n’est pas convertie, et le test renvoie une erreur serveur au lieu d’un message lisible.
    • icon est 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:package en fait partie), sinon l’icône s’affiche vide.
    • firstParty: true donne au contexte les capacités maison décrites plus bas.

Ajouter les opérations

  1. 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 ; definePlugin le vérifie au chargement du module.

    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 type input à partir du schéma : input.search est donc un string | undefined. Annotez ctx avec votre type de configuration pour lire ctx.config.baseUrl avec son type. readPath lit un chemin pointé (items, data.items.0.name) en ne passant que par les propriétés propres, et renvoie undefined pour une étape manquante. Le schéma output n’est pas appliqué ; l’éditeur s’en sert pour l’autocomplétion.

  2. Ajouter la mutation

    Ajoutez la seconde entrée dans la même table. Une mutation modifie 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.quantity est un number, et non une valeur facultative : la valeur par défaut du schéma le remplit avant run.

Point de contrôle

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.

CodeQui la lève
invalid_configVotre 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_inputLe 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_failedVotre code (upstreamError), pour un appel distant qui échoue. ctx.fetch aussi, pour les erreurs réseau et les redirections trop nombreuses.
fetch_blockedctx.fetch : un hôte absent de hosts, du HTTP non chiffré, une adresse privée ou une redirection vers un autre hôte.
dns_failedctx.fetch : l’hôte n’a pas pu être résolu.
timeoutctx.fetch : aucune réponse dans le délai imparti.
response_too_largectx.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

  1. 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 de PLUGINS est l’ordre du menu de l’éditeur.

    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. Réserver l’identifiant

    Ajoutez 'shop' à la fin de BUILTIN_PLUGIN_IDS dans packages/schema/src/data.ts. Cette liste empêche les plugins du catalogue de prendre un identifiant intégré, et plugins.test.ts vérifie que les deux listes correspondent.

  3. 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 /plugins de apps/api/src/routes/integrations.test.ts.
  4. Mettre à jour l’éditeur et la documentation

    Si votre plugin a des secretBoundFields, ajoutez-le à SECRET_BOUND_FIELDS dans apps/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

  1. Créer le fichier de test

    Créez packages/plugins/src/shop.test.ts. Les utilitaires de test-utils.ts construisent 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’une PluginError ; json() construit une réponse 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. 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.

    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. Tester les échecs et le test de connexion

    Couvrez chaque erreur levée par votre code, la politique (un hôte hors de hosts est bloqué) et les deux issues du test de connexion. Fermez le bloc describe à la fin.

    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. Les exécuter

    Lancez les tests du plugin depuis la racine du dépôt :

    sh
    pnpm --filter @malla/plugins test
    
Point de contrôle

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

  1. Démarrer une Shop API locale

    L’API factice des tests e2e répond à GET /products?search= derrière un en-tête X-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é :

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

    Elle n’a pas de POST /orders : essayez donc la mutation dans vos tests unitaires, ou contre une vraie API.

  2. Démarrer la sandbox

    Dans un autre terminal, lancez pnpm sandbox et connectez-vous sur http://localhost:3200/login avec le compte de démonstration indiqué dans scripts/sandbox/README.md. La sandbox définit ALLOW_LOCAL_INTEGRATIONS=1 : l’api peut donc appeler http://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.

  3. 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, saisissez shop-demo-key comme API key dans Secrets, puis cliquez sur Create integration.

  4. 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é.

  5. 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 lamp dans le champ Search sous Input, puis cliquez sur Run dans la carte Result.

Point de contrôle

La carte Result affiche deux produits, Alpine Lamp et Dune Lamp, venant de votre plugin.

Lancer les contrôles qualité

  1. 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 :

    sh
    pnpm check-types
    pnpm test
    

    pnpm test inclut le test GET /plugins dans api et le test du registre dans @malla/plugins. Pour relancer uniquement le test de l’api, utilisez pnpm --filter api exec vitest run src/routes/integrations.test.ts.

  2. 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.

Terminé

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 avec defineOperation.
  • .describe() sur les schémas écrit les indications des formulaires d’intégration et de requête.
  • ctx.fetch ne joint que les hôtes renvoyés par la connexion, et lève des codes PluginError pour les appels bloqués, lents ou trop volumineux.
  • Un nouveau plugin va aussi dans PLUGINS, BUILTIN_PLUGIN_IDS et les deux tests qui listent tous les plugins.
  • test-utils.ts exé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 :