Rutas de API

En esta página

Los plugins pueden exponer rutas de API para su UI de administración e integraciones externas. Las rutas se montan bajo /_emdash/api/plugins/<plugin-id>/<route-name> y se ejecutan dentro del entorno de sandbox con el mismo PluginContext que reciben los hooks.

Esta página cubre plugins en sandbox (formato estándar). La superficie de API para plugins nativos es la misma; la única diferencia es la firma del handler — consulte la nota en Plugins nativos para más detalles.

Definir rutas

Declare rutas en definePlugin() desde su 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;
			},
		},
	},
});

Los handlers de rutas en formato estándar toman dos argumentos: (routeCtx, ctx).

  • routeCtx contiene datos con forma de solicitud: { input, request, requestMeta }.
  • ctx es el mismo PluginContext que obtiene dentro de los hooks — ctx.storage, ctx.kv, ctx.content, ctx.http, ctx.log, etc.

URLs de ruta

Las rutas se montan en /_emdash/api/plugins/<plugin-id>/<route-name>. Los nombres de ruta pueden incluir barras para rutas anidadas.

ID de pluginNombre de rutaURL
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

Autenticación y CSRF

Las rutas de plugin están autenticadas por defecto. El despachador requiere una sesión (o un token con el scope admin) antes de llamar a su handler:

  • Los métodos de lectura (GET, HEAD, OPTIONS) requieren el permiso plugins:read.
  • Los métodos de escritura (POST, PUT, PATCH, DELETE) requieren plugins:manage.
  • Los métodos que cambian estado en rutas privadas también requieren el encabezado CSRF X-EmDash-Request: 1 (el hook usePluginAPI() de la UI de administración lo envía automáticamente; los llamadores externos autenticados por cookie deben configurarlo ellos mismos; las solicitudes autenticadas por token están exentas).

Para excluir una ruta de autenticación y CSRF, márquela como 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 };
		},
	},
},

Validación de entrada

input acepta un esquema Zod. El despachador analiza el cuerpo de la solicitud (POST/PUT/PATCH) o la cadena de consulta (GET/DELETE), lo valida y pasa el resultado tipado a su handler como routeCtx.input. Una entrada no válida devuelve un 400 antes de que se ejecute su handler.

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 };
		},
	},
},

Valores de retorno

Devuelva cualquier valor serializable en JSON. El despachador lo envuelve en el sobre estándar de EmDash ({ success: true, data: <su valor> }) y lo sirve como application/json.

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

Errores

Lance un error para devolver una respuesta de error. Cualquier cosa que no sea un error de plugin conocido devuelve un mensaje genérico — las excepciones internas se enmascaran en lugar de filtrar trazas de pila o errores de base de datos:

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

Para un código de estado específico, lance una 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étodos HTTP

Las rutas responden a todos los métodos. Ramifique según routeCtx.request.method si necesita comportamiento por método:

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 });
			}
		},
	},
},

Acceder a la solicitud

El objeto Request completo está disponible como routeCtx.request para encabezados, acceso al cuerpo en bruto y análisis de URL. routeCtx.requestMeta contiene IP, agente de usuario y datos geográficos normalizados entre plataformas.

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 });
	}
},

Patrones comunes

Configuración mediante KV

Los plugins en sandbox leen y escriben configuraciones a través del almacén KV, convencionalmente bajo un prefijo settings:. El formulario settingsSchema generado automáticamente es solo para nativos — para plugins en sandbox, exponga la lectura/escritura a través de rutas y renderice el formulario en 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 };
		},
	},
},

Lista paginada

Devuelva paginación basada en cursor desde una consulta de almacenamiento — la forma de respuesta coincide con lo que usa el resto de EmDash:

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 de API externa

Proxee una solicitud a un servicio externo a través de ctx.http (requiere la capacidad network:request y una entrada en 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();
		},
	},
},

Llamar rutas desde la UI de administración

Use usePluginAPI() del paquete de administración — agrega el encabezado CSRF X-EmDash-Request y el prefijo de ID de plugin automáticamente:

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");
	};
}

Llamar rutas externamente

Las rutas públicas se pueden llamar directamente:

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

Las rutas privadas necesitan credenciales de sesión o un token de API con el 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]"}'

Referencia de contexto de ruta

// Lo que reciben los handlers de formato estándar como sus dos argumentos

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;       // cuando se declara content:read o content:write
	media?: MediaAccess;           // cuando se declara media:read o media:write
	http?: HttpAccess;             // cuando se declara network:request
	users?: UserAccess;            // cuando se declara users:read
	email?: EmailAccess;           // cuando se declara email:send y se configura el proveedor
}

Los plugins nativos reciben un único argumento RouteContext que combina ambos — consulte Crear plugins nativos si va por ese camino.