Routes API

Sur cette page

Les plugins peuvent exposer des routes API pour leur interface d’administration et leurs intégrations externes. Les routes sont montées sous /_emdash/api/plugins/<slug>/<route-name> (le <slug> est le champ slug de emdash-plugin.jsonc — exposé au runtime comme ctx.plugin.id) et s’exécutent dans le runtime sandbox avec le même PluginContext que reçoivent les hooks.

Cette page couvre les plugins sandboxés. La surface API pour les plugins natifs est la même ; la seule différence est la signature du handler — voir la note dans Plugins natifs pour plus de détails.

Définir des routes

Déclarez les routes dans l’export par défaut de src/plugin.ts :

import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "astro/zod";

export default {
	routes: {
		status: {
			handler: async (_routeCtx, ctx) => {
				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) => {
				const { formId, limit, cursor } = routeCtx.input;

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

				return result;
			},
		},
	},
} satisfies SandboxedPlugin;

satisfies SandboxedPlugin infère routeCtx et ctx — aucune annotation de paramètre nécessaire. Les handlers de routes sandboxés prennent deux arguments : (routeCtx, ctx).

  • routeCtx contient des 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 routes

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

ID du pluginNom de la 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 sont exemptées).

Pour exempter une route d’authentification et CSRF, marquez-la 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 renvoie 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: <your value> }) et la sert comme application/json.

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

Erreurs

Lancez pour retourner une réponse d’erreur. Tout ce qui n’est pas une erreur de plugin connue renvoie un message générique — les exceptions internes sont masquées plutôt que de fuiter 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 de statut spécifique, lancez 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

routeCtx.request est un SandboxedRequest : un enregistrement portable { url, method, headers } qui se comporte de manière identique en processus et à l’intérieur d’un isolat. headers est un Record<string, string> indexé par nom d’en-tête en minuscules — indexez-le par le nom en minuscules ou itérez avec Object.entries. url est une chaîne, donc new URL(request.url) analyse les paramètres de requête. routeCtx.requestMeta contient l’IP, le user agent et les données géo normalisées entre les plateformes lorsqu’elles sont disponibles.

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

	const auth = request.headers["authorization"]; // lowercased key, no .get()
	const url = new URL(request.url);
	const page = url.searchParams.get("page");

	ctx.log.info("Request", { meta: requestMeta });

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

Modèles courants

Paramètres via KV

Les plugins sandboxés lisent et écrivent les paramètres via le magasin KV, conventionnellement sous un préfixe settings:. Le formulaire settingsSchema autogénéré est réservé aux natifs — pour les plugins sandboxés, 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 le curseur depuis une requête de stockage — la forme de la 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 API externe

Proxy 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 du 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 sont appelables 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 credentials 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": "user@example.com"}'

Référence du contexte de route

// Ce que les handlers de route sandboxés reçoivent comme leurs deux arguments

interface SandboxedRequest {
	url: string;
	method: string;
	headers: Record<string, string>; // lowercased keys
}

interface SandboxedRouteContext {
	input: unknown; // narrow with the `input` Zod schema at the route level
	request: SandboxedRequest;
	requestMeta?: unknown;
}

interface PluginContext {
	plugin: { id: string; version: string };
	storage: PluginStorage;
	kv: KVAccess;
	log: LogAccess;
	site: SiteInfo;
	url(path: string): string;
	cron?: CronAccess;
	content?: ContentAccess;       // when content:read or content:write declared
	media?: MediaAccess;           // when media:read or media:write declared
	http?: HttpAccess;             // when network:request declared
	users?: UserAccess;            // when users:read declared
	email?: EmailAccess;           // when email:send declared and provider configured
}

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