Routes API

Sur cette page

Les plugins peuvent exposer des routes API pour leur interface d’administration et les intégrations externes. Les routes sont montées sous /_emdash/api/plugins/<plugin-id>/<route-name> et s’exécutent à l’intérieur de l’environnement sandbox avec le même PluginContext que reçoivent les hooks.

Cette page couvre les plugins en sandbox (format standard). La surface d’API pour les plugins natifs est la même ; la seule différence est la signature du handler — consultez la note dans Plugins natifs pour plus de détails.

Définir des routes

Déclarez les routes dans definePlugin() depuis votre sandbox-entry.ts :

import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";
import { z } from "astro/zod";

export default definePlugin({
	routes: {
		status: {
			handler: async (_routeCtx, ctx: PluginContext) => {
				return { ok: true, plugin: ctx.plugin.id };
			},
		},

		submissions: {
			input: z.object({
				formId: z.string().optional(),
				limit: z.number().default(50),
				cursor: z.string().optional(),
			}),
			handler: async (routeCtx, ctx: PluginContext) => {
				const { formId, limit, cursor } = routeCtx.input;

				const result = await ctx.storage.submissions.query({
					where: formId ? { formId } : undefined,
					orderBy: { createdAt: "desc" },
					limit,
					cursor,
				});

				return result;
			},
		},
	},
});

Les handlers de routes au format standard prennent deux arguments : (routeCtx, ctx).

  • routeCtx contient les données en forme de requête : { input, request, requestMeta }.
  • ctx est le même PluginContext que vous obtenez dans les hooks — ctx.storage, ctx.kv, ctx.content, ctx.http, ctx.log, etc.

URLs de route

Les routes sont montées à /_emdash/api/plugins/<plugin-id>/<route-name>. Les noms de route peuvent inclure des barres obliques pour des chemins imbriqués.

ID de pluginNom de routeURL
formsstatus/_emdash/api/plugins/forms/status
formssubmissions/_emdash/api/plugins/forms/submissions
seosettings/save/_emdash/api/plugins/seo/settings/save
analyticsevents/recent/_emdash/api/plugins/analytics/events/recent

Authentification et CSRF

Les routes de plugin sont authentifiées par défaut. Le dispatcher nécessite une session (ou un token avec le scope admin) avant d’appeler votre handler :

  • Les méthodes de lecture (GET, HEAD, OPTIONS) nécessitent la permission plugins:read.
  • Les méthodes d’écriture (POST, PUT, PATCH, DELETE) nécessitent plugins:manage.
  • Les méthodes modifiant l’état sur les routes privées nécessitent également l’en-tête CSRF X-EmDash-Request: 1 (le hook usePluginAPI() de l’interface d’administration l’envoie automatiquement ; les appelants externes authentifiés par cookie doivent le définir eux-mêmes ; les requêtes authentifiées par token en sont exemptées).

Pour exclure une route de l’authentification et du CSRF, marquez-la comme public: true :

routes: {
	track: {
		public: true,
		input: z.object({ event: z.string() }),
		handler: async (routeCtx, ctx) => {
			ctx.log.info("Tracked", { event: routeCtx.input.event });
			return { ok: true };
		},
	},
},

Validation d’entrée

input accepte un schéma Zod. Le dispatcher analyse le corps de la requête (POST/PUT/PATCH) ou la chaîne de requête (GET/DELETE), le valide et passe le résultat typé à votre handler comme routeCtx.input. Une entrée invalide retourne un 400 avant que votre handler ne s’exécute.

routes: {
	create: {
		input: z.object({
			title: z.string().min(1).max(200),
			email: z.string().email(),
			priority: z.enum(["low", "medium", "high"]).default("medium"),
			tags: z.array(z.string()).optional(),
		}),
		handler: async (routeCtx, ctx) => {
			const { title, email, priority, tags } = routeCtx.input;

			await ctx.storage.items.put(`item_${Date.now()}`, {
				title,
				email,
				priority,
				tags: tags ?? [],
				createdAt: new Date().toISOString(),
			});

			return { success: true };
		},
	},
},

Valeurs de retour

Retournez n’importe quelle valeur sérialisable en JSON. Le dispatcher l’enveloppe dans l’enveloppe standard d’EmDash ({ success: true, data: <votre valeur> }) et la sert comme application/json.

return { id: "abc", count: 42 };  // enveloppé à { success: true, data: { id, count } }
return [1, 2, 3];                 // enveloppé à { success: true, data: [1, 2, 3] }

Erreurs

Levez une erreur pour retourner une réponse d’erreur. Tout ce qui n’est pas une erreur de plugin connue retourne un message générique — les exceptions internes sont masquées plutôt que de divulguer des traces de pile ou des erreurs de base de données :

handler: async (routeCtx, ctx) => {
	const item = await ctx.storage.items.get(routeCtx.input.id);
	if (!item) {
		throw new Error("Item not found");
	}
	return item;
},

Pour un code d’état spécifique, levez une Response :

handler: async (routeCtx, ctx) => {
	const item = await ctx.storage.items.get(routeCtx.input.id);
	if (!item) {
		throw new Response(JSON.stringify({ error: "Not found" }), {
			status: 404,
			headers: { "Content-Type": "application/json" },
		});
	}
	return item;
},

Méthodes HTTP

Les routes répondent à toutes les méthodes. Branchez sur routeCtx.request.method si vous avez besoin d’un comportement par méthode :

routes: {
	item: {
		input: z.object({ id: z.string() }),
		handler: async (routeCtx, ctx) => {
			const { id } = routeCtx.input;

			switch (routeCtx.request.method) {
				case "GET":
					return await ctx.storage.items.get(id);
				case "DELETE":
					await ctx.storage.items.delete(id);
					return { deleted: true };
				default:
					throw new Response("Method not allowed", { status: 405 });
			}
		},
	},
},

Accès à la requête

L’objet Request complet est disponible comme routeCtx.request pour les en-têtes, l’accès brut au corps et l’analyse d’URL. routeCtx.requestMeta contient les données IP, user agent et géo normalisées entre les plateformes.

handler: async (routeCtx, ctx) => {
	const { request, requestMeta } = routeCtx;

	const auth = request.headers.get("Authorization");
	const url = new URL(request.url);
	const page = url.searchParams.get("page");

	ctx.log.info("Request", { ip: requestMeta.ip, ua: requestMeta.userAgent });

	if (request.method !== "POST") {
		throw new Response("POST required", { status: 405 });
	}
},

Modèles courants

Paramètres via KV

Les plugins en sandbox lisent et écrivent les paramètres via le magasin KV, conventionnellement sous un préfixe settings:. Le formulaire settingsSchema généré automatiquement est réservé aux natifs — pour les plugins en sandbox, exposez la lecture/écriture via des routes et rendez le formulaire dans Block Kit.

routes: {
	settings: {
		handler: async (_routeCtx, ctx) => {
			const settings = await ctx.kv.list("settings:");
			const result: Record<string, unknown> = {};
			for (const entry of settings) {
				result[entry.key.replace("settings:", "")] = entry.value;
			}
			return result;
		},
	},

	"settings/save": {
		input: z.object({
			enabled: z.boolean().optional(),
			apiKey: z.string().optional(),
			maxItems: z.number().optional(),
		}),
		handler: async (routeCtx, ctx) => {
			for (const [key, value] of Object.entries(routeCtx.input)) {
				if (value !== undefined) {
					await ctx.kv.set(`settings:${key}`, value);
				}
			}
			return { success: true };
		},
	},
},

Liste paginée

Retournez une pagination basée sur un curseur depuis une requête de stockage — la forme de réponse correspond à ce que le reste d’EmDash utilise :

routes: {
	list: {
		input: z.object({
			limit: z.number().min(1).max(100).default(50),
			cursor: z.string().optional(),
			status: z.string().optional(),
		}),
		handler: async (routeCtx, ctx) => {
			const { limit, cursor, status } = routeCtx.input;

			const result = await ctx.storage.items.query({
				where: status ? { status } : undefined,
				orderBy: { createdAt: "desc" },
				limit,
				cursor,
			});

			return {
				items: result.items.map((item) => ({ id: item.id, ...item.data })),
				cursor: result.cursor,
				hasMore: result.hasMore,
			};
		},
	},
},

Proxy d’API externe

Proxez une requête vers un service externe via ctx.http (nécessite la capacité network:request et une entrée dans allowedHosts) :

routes: {
	forecast: {
		input: z.object({ city: z.string() }),
		handler: async (routeCtx, ctx) => {
			if (!ctx.http) throw new Error("Network capability not granted");

			const apiKey = await ctx.kv.get<string>("settings:apiKey");
			if (!apiKey) throw new Error("API key not configured");

			const response = await ctx.http.fetch(
				`https://api.weather.example.com/forecast?city=${routeCtx.input.city}`,
				{ headers: { "X-API-Key": apiKey } },
			);

			if (!response.ok) {
				throw new Error(`Weather API error: ${response.status}`);
			}
			return response.json();
		},
	},
},

Appeler des routes depuis l’interface d’administration

Utilisez usePluginAPI() du package d’administration — il ajoute automatiquement l’en-tête CSRF X-EmDash-Request et le préfixe d’ID de plugin :

import { usePluginAPI } from "@emdash-cms/admin";

function SettingsPage() {
	const api = usePluginAPI();

	const handleSave = async (settings) => {
		await api.post("settings/save", settings);
	};

	const loadSettings = async () => {
		return api.get("settings");
	};
}

Appeler des routes en externe

Les routes publiques peuvent être appelées directement :

curl -X POST https://your-site.com/_emdash/api/plugins/forms/track \
  -H "Content-Type: application/json" \
  -d '{"event": "pageview"}'

Les routes privées nécessitent des identifiants de session ou un token API avec le scope admin :

curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"title": "Hello", "email": "[email protected]"}'

Référence du contexte de route

// Ce que les handlers au format standard reçoivent comme leurs deux arguments

interface StandardRouteContext<TInput = unknown> {
	input: TInput;
	request: Request;
	requestMeta: { ip: string | null; userAgent: string | null; geo?: GeoData };
}

interface PluginContext {
	plugin: { id: string; version: string };
	storage: PluginStorage;
	kv: KVAccess;
	log: LogAccess;
	site: SiteInfo;
	url(path: string): string;
	cron?: CronAccess;
	content?: ContentAccess;       // quand content:read ou content:write est déclaré
	media?: MediaAccess;           // quand media:read ou media:write est déclaré
	http?: HttpAccess;             // quand network:request est déclaré
	users?: UserAccess;            // quand users:read est déclaré
	email?: EmailAccess;           // quand email:send est déclaré et le fournisseur configuré
}

Les plugins natifs reçoivent un seul argument RouteContext qui combine les deux — consultez Créer des plugins natifs si vous prenez cette voie.