Files
emdash-patch-imageupload/templates/blog-cloudflare/.agents/skills/creating-plugins/references/api-routes.md
kunthawat 2d1be52177 Emdash source with visual editor image upload fix
Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
2026-05-03 10:44:54 +07:00

6.0 KiB

API Routes

Plugin routes work in both standard and native plugins, and in both trusted and sandboxed modes. Sandboxed plugin routes are invoked via the sandbox runner's invokeRoute() RPC.

Plugin routes expose REST endpoints at /_emdash/api/plugins/<plugin-id>/<route-name>.

Defining Routes

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

definePlugin({
	id: "forms",
	version: "1.0.0",

	routes: {
		// Simple route
		status: {
			handler: async (ctx) => {
				return { ok: true };
			},
		},

		// Route with input validation
		submissions: {
			input: z.object({
				formId: z.string().optional(),
				limit: z.number().default(50),
				cursor: z.string().optional(),
			}),
			handler: async (ctx) => {
				const { formId, limit, cursor } = ctx.input;
				const result = await ctx.storage.submissions!.query({
					where: formId ? { formId } : undefined,
					orderBy: { createdAt: "desc" },
					limit,
					cursor,
				});
				return {
					items: result.items,
					cursor: result.cursor,
					hasMore: result.hasMore,
				};
			},
		},

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

Route URLs

Plugin ID Route Name URL
forms status /_emdash/api/plugins/forms/status
forms submissions /_emdash/api/plugins/forms/submissions
seo settings/save /_emdash/api/plugins/seo/settings/save

Handler Context

interface RouteContext<TInput = unknown> extends PluginContext {
	input: TInput; // Validated input
	request: Request; // Original request
	plugin: { id: string; version: string };
	storage: Record<string, StorageCollection>;
	kv: KVAccess;
	content?: ContentAccess; // If capability declared
	media?: MediaAccess;
	http?: HttpAccess;
	log: LogAccess;
}

Input Validation

Use Zod schemas. Invalid input returns 400.

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 (ctx) => {
			// ctx.input is typed and validated
			const { title, email, priority } = ctx.input;
			// ...
		},
	},
}

Input sources:

  • POST/PUT/PATCH — Request body (JSON)
  • GET/DELETE — URL query parameters

Return Values

Return any JSON-serializable value. Response is always Content-Type: application/json.

return { success: true, data: items }; // Object
return items; // Array
return 42; // Primitive

Errors

Throw to return error response:

throw new Error("Item not found"); // 500 with { error: "Item not found" }

// Custom status code
throw new Response(JSON.stringify({ error: "Not found" }), {
	status: 404,
	headers: { "Content-Type": "application/json" },
});

HTTP Methods

Routes respond to all methods. Check ctx.request.method:

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

Common Patterns

Settings CRUD

routes: {
	settings: {
		handler: async (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": {
		handler: async (ctx) => {
			const input = await ctx.request.json();
			for (const [key, value] of Object.entries(input)) {
				if (value !== undefined) await ctx.kv.set(`settings:${key}`, value);
			}
			return { success: true };
		},
	},
}

Paginated List

routes: {
	list: {
		input: z.object({
			limit: z.number().min(1).max(100).default(50),
			cursor: z.string().optional(),
			status: z.string().optional(),
		}),
		handler: async (ctx) => {
			const { limit, cursor, status } = ctx.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,
			};
		},
	},
}

External API Proxy

Requires network:fetch capability and allowedHosts:

definePlugin({
	capabilities: ["network:fetch"],
	allowedHosts: ["api.weather.example.com"],

	routes: {
		forecast: {
			input: z.object({ city: z.string() }),
			handler: async (ctx) => {
				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=${ctx.input.city}`,
					{ headers: { "X-API-Key": apiKey } },
				);

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

Calling from Admin UI

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

const api = usePluginAPI();
const data = await api.get("status");
await api.post("settings/save", { enabled: true });

Calling Externally

curl https://your-site.com/_emdash/api/plugins/forms/submissions?limit=10

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

Plugin routes don't have built-in auth. Admin-only routes are protected by the admin session middleware.