APIルート
このページで分かること
- ルートの定義方法(
src/plugin.tsの既定のエクスポート、ハンドラーが受け取る2つの引数)と、ルートのURLの決まり方 - 認証とCSRF対策(既定で認証が必要なこと、
public: trueにした場合の注意点)、MCPツールとしての公開、入力の検証 - 戻り値とエラーの扱い、HTTPメソッドの確認、リクエストの参照、よく使う書き方(外部APIの中継、Block Kitやキュー・スケジュール処理からの呼び出し)
このページの目次
プラグインは、管理画面や外部との連携のためにAPIルートを公開できます。ルートは /_emdash/api/plugins/<slug>/<route-name> の下に配置されます(<slug> は emdash-plugin.jsonc の slug フィールドで、実行時には ctx.plugin.id として参照できます)。ルートはサンドボックスのランタイムの中で、フックが受け取るのと同じ PluginContext を使って実行されます。
このページでは、サンドボックス型プラグインについて説明します。ネイティブ型プラグインも、ルートのオプション、認証、URLの構成は同じですが、ハンドラーは1つにまとめられたコンテキストオブジェクトを受け取ります。そのシグネチャーは、はじめてのネイティブ型プラグインを参照します。
ルートの定義
ルートは src/plugin.ts の既定のエクスポートで宣言します。ルートで入力を検証する場合や、ルートをMCPツールとして公開する場合は、実行時の依存パッケージとして zod を追加します。
pnpm add zod
次の例は、送信データ(submissions)の取得リクエストを検証し、プラグインのストレージを検索します。
import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "zod";
const submissionsInput = z.object({
formId: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
cursor: z.string().optional(),
});
const plugin: SandboxedPlugin = {
routes: {
status: {
handler: async (_routeCtx, ctx) => {
return { ok: true, plugin: ctx.plugin.id };
},
},
submissions: {
handler: async (routeCtx, ctx) => {
const parsed = submissionsInput.safeParse(routeCtx.input);
if (!parsed.success) {
return { ok: false, error: { code: "VALIDATION_ERROR" } };
}
const { formId, limit, cursor } = parsed.data;
const result = await ctx.storage.submissions.query({
where: formId ? { formId } : undefined,
orderBy: { createdAt: "desc" },
limit,
cursor,
});
return { ok: true, ...result };
},
},
},
};
export default plugin;
SandboxedPlugin の型注釈によってルートとプラグインのコンテキストの型が推論されるため、引数に型注釈は必要ありません。サンドボックス型のルートのハンドラーは、2つの引数 (routeCtx, ctx) を受け取ります。
routeCtxは、リクエストに関するデータ{ input, request, requestMeta }を持ちます。inputはunknownのままのため、使う前に検証します。ctxは、フックの中で受け取るのと同じPluginContextです(ctx.storage、ctx.kv、ctx.content、ctx.http、ctx.logなど)。
やさしい解説
プラグインは、管理画面や外部との連携のためにAPIルートを追加できます。ルートは src/plugin.ts の routes に名前を付けて書き、/_emdash/api/plugins/<slug>/<ルート名> というURLで呼び出されます。ハンドラーは、リクエストの内容を表す routeCtx と、ストレージやログなどを使うための ctx の2つを受け取ります。routeCtx.input は型が決まっていない(unknown)ため、zod などで検証してから使います。
インデックス付きのコンテンツのフィールドで絞り込む
content:read の権限(Capability)を持つプラグインは、コレクションで indexed に指定された独自のフィールドで絞り込めます。絞り込みはデータベースの中で実行され、AND の条件で組み合わされます。
const result = await ctx.content.list("items", {
where: {
fieldFilters: {
priority: { in: ["urgent", "high"] },
score: { gte: 80 },
resolved: false,
},
},
});
スカラー値は完全一致で比較されます。nullとの一致には null を、完全一致する値の集合には { in: [...] } を、範囲の比較には gt、gte、lt、lte を使います。EmDashは、インデックスのないフィールドへの絞り込み、フィールドの型に合わない値、1回の検索で20個を超えるフィールドの絞り込みを拒否します。in の絞り込みには最大50個の値を指定できます。また、完全一致の値、範囲の境界、in の要素は、合わせて1回の検索につき50個のオペランドまでです。nullとの一致はこの上限に数えられません。
ルートのURL
ルートは /_emdash/api/plugins/<slug>/<route-name> に配置されます。ルート名にスラッシュを含めると、入れ子のパスにできます。
| プラグインID | ルート名 | URL |
|---|---|---|
forms |
status |
/_emdash/api/plugins/forms/status |
forms |
submissions |
/_emdash/api/plugins/forms/submissions |
seo |
settings/save |
/_emdash/api/plugins/seo/settings/save |
analytics |
events/recent |
/_emdash/api/plugins/analytics/events/recent |
認証とCSRF
プラグインのルートは、既定で認証が必要です。 ディスパッチャーは、ハンドラーを呼び出す前に、セッション(または admin スコープを持つトークン)を要求します。非公開のルートは、後方互換性のために、既定で plugins:manage の権限を必要とします。処理が既存のコンテンツ、メディア、スキーマ、設定の権限に属する場合は、permission に、より範囲の狭いEmDashのRBACの権限を指定します。
routes: {
create: {
permission: "content:create",
handler: async (routeCtx, ctx) => {
// Validate routeCtx.input, then create content through ctx.
},
},
},
非公開のルートは、すべてのHTTPメソッドで、宣言した権限を必要とします。プラグインのルートはどのメソッドでも同じハンドラーを実行できるため、Cookieで認証したリクエストには、GET と HEAD を含めて、CSRF対策の X-EmDash-Request: 1 ヘッダーも必要です。管理画面はこのヘッダーを自動で送ります。トークンで認証したリクエストはこのヘッダーが免除されますが、admin のトークンスコープとルートの権限は引き続き必要です。
ルートを認証の対象から外すには、public: true を指定します。
routes: {
track: {
public: true,
handler: async (routeCtx, ctx) => {
const parsed = z.object({ event: z.string() }).safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_EVENT" };
ctx.log.info("Tracked", { event: parsed.data.event });
return { ok: true };
},
},
},
認証された呼び出し元
非公開のルートでは、routeCtx.user は、リクエストをしている認証済みのユーザーです。ハンドラーが実行される前にEmDashが特定し、認可しているため、ユーザーごとの処理(ユーザーごとのAPIキー、OAuthの接続、プラグインが管理する個人設定)に信頼して使えます。
routes: {
"connect/start": {
handler: async (routeCtx, ctx) => {
// Never read the acting user from the request body — any authenticated
// session could impersonate another user that way. Use routeCtx.user.
const caller = routeCtx.user;
if (!caller) throw new Error("No caller bound");
await ctx.kv.set(`user:${caller.id}:connection`, { startedAt: Date.now() });
return { userId: caller.id };
},
},
},
公開ルートでは、routeCtx.user は undefined です(公開ルートは認証を省くため、訪問者がたまたま管理者のセッションを持っていても、呼び出し元は結び付けられません)。ユーザーに結び付いていないトークン(マシン用のトークン)で認証したリクエストでも undefined です。形は、ctx.users が返す UserInfo と同じ { id, email, name, role, createdAt } で、機密性の高いフィールドは含みません。
呼び出し元の身元は、users:read の権限とは別のものです。routeCtx.user は誰が呼び出しているかを示し、非公開のルートでは常に使えます。一方、ctx.users はユーザーの一覧を検索するためのもので、この権限が必要です。
やさしい解説
EmDashのプラグインのルートは、既定でログイン(セッション)または admin スコープのトークンが必要です。誰でも呼び出せるようにするには public: true を指定しますが、その場合は認証がなくなるため、入力の検証と、可能であれば署名やトークンの確認をプラグイン側でします。ログインが必要なルートでは、呼び出したユーザーを routeCtx.user で確かめられます。リクエストの本文に書かれたユーザーIDを信じず、routeCtx.user を使うのが、このページのコード例の前提です。
ルートをMCPツールとして公開する
プラグインは、選んだ非公開のルートを、EmDashのMCPサーバーを通じて明示的に公開できます。ルートの一覧から自動でMCPに公開されることはありません。
const createEventInput = z.object({
title: z.string().min(1),
startsAt: z.string().datetime(),
});
const plugin: SandboxedPlugin = {
routes: {
"events/create": {
permission: "content:create",
handler: async (routeCtx, ctx) => {
const parsed = createEventInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_EVENT" };
const input = parsed.data;
return { id: await createEvent(input, ctx) };
},
},
},
mcp: {
tools: {
createEvent: {
description: "Create a calendar event when the user asks to add one.",
route: "events/create",
input: createEventInput,
output: z.object({ id: z.string() }),
destructive: false,
},
},
},
};
export default plugin;
EmDashは、これを <pluginId>__createEvent として公開します。参照するルートは非公開で、permission を宣言している必要があります。入力のスキーマは必須で、出力のスキーマは省略できます。削除、上書き、公開、課金など、元に戻すのが難しい操作をするツールには、destructive: true を指定します。
管理者は、プラグインのMCPツールの名前、説明、ルート、権限、destructiveフラグを確認したうえで、別途それらを有効にする必要があります。有効にしたあと、ツールを呼び出すには、ルートの権限と、mcp:tools または mcp:tools:<pluginId> のトークンスコープの両方が必要です。
入力の検証
EmDashは、POST、PUT、PATCH ではJSONのリクエスト本文を解析します。GET、HEAD、DELETE ではクエリパラメーターを解析します。解析した値は、routeCtx.input: unknown としてサンドボックス型のハンドラーに渡されます。そのため、フィールドを読んだり副作用のある処理をしたりする前に、ハンドラーの中で検証します。
不正な入力が呼び出し元の想定内のエラーである場合は、safeParse を使います。こうすると、不正な入力を内部の例外にせず、ルートから決まった形のJSONの結果を返せます。
const createInput = 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(),
});
routes: {
create: {
handler: async (routeCtx, ctx) => {
const parsed = createInput.safeParse(routeCtx.input);
if (!parsed.success) {
return { ok: false, error: { code: "VALIDATION_ERROR" } };
}
const { title, email, priority, tags } = parsed.data;
await ctx.storage.items.put(`item_${Date.now()}`, {
title,
email,
priority,
tags: tags ?? [],
createdAt: new Date().toISOString(),
});
return { ok: true };
},
},
},
クエリ文字列の入力(GET/HEAD/DELETE)
本文を持たないメソッドにはリクエスト本文がないため、入力はURLのクエリ文字列から来ます。値はすべて文字列です。同じキーを繰り返すと配列になります。?tag=a&tag=b は { tag: ["a", "b"] } になり、?tag=a が1つだけなら { tag: "a" } のままです。数値など、文字列以外の値には z.coerce を使います。
const listInput = z.object({
status: z.enum(["open", "closed"]).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
tag: z.union([z.string(), z.array(z.string())]).optional(),
});
routes: {
list: {
// GET /_emdash/api/plugins/<slug>/list?status=open&limit=20&tag=a&tag=b
handler: async (routeCtx, ctx) => {
const parsed = listInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_QUERY" };
const { status, limit, tag } = parsed.data;
// ...
},
},
},
戻り値
JSONにシリアライズできる値であれば、何でも返せます。ディスパッチャーはその値をEmDashの標準のエンベロープ({ success: true, data: <your value> })で包み、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] }
エラー
サンドボックス型のルートが処理を完了できない場合は、例外をスローします。EmDashは例外をログに出力し、ROUTE_ERROR を返します。スローしたメッセージはレスポンスに含まれることがあるため、例外のメッセージに認証情報、個人データ、内部のパス、スタックトレースを入れないでください。
handler: async (_routeCtx, ctx) => {
try {
return await refreshRemoteIndex(ctx);
} catch {
ctx.log.error("Remote index refresh failed");
throw new Error("Remote index refresh failed");
}
},
サンドボックス型プラグインのコードは、Response をスローして任意のHTTPステータスを選ぶことはできません。Response は、構造化されたエラーとして、すべてのサンドボックスランナーの境界を越えられるわけではないためです。認証、認可、CSRF、ルートが見つからない場合の失敗には、ハンドラーが実行される前にEmDashがステータスを割り当てます。想定内の検証の結果や業務上の結果にはJSONの結果を返し、例外は想定外の失敗のために取っておきます。
JSONとして返した想定内のエラーも、ルートの成功時のHTTPレスポンスを使い、EmDashの外側の { success: true, data: ... } のエンベロープの中に入ります。クライアントがその結果を区別できるように、アプリケーションとしての決まったコードを含めます。
HTTPメソッド
ハンドラーを選ぶのはルート名で、HTTPメソッドではありません。ルートが状態を変更する場合は、変更の処理をする前に、想定したメソッドだけを受け付けるようにします。
routes: {
item: {
handler: async (routeCtx, ctx) => {
const parsed = z.object({ id: z.string() }).safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_ID" };
const { id } = parsed.data;
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:
return { error: "METHOD_NOT_ALLOWED", allowed: ["GET", "DELETE"] };
}
},
},
},
リクエストの参照
routeCtx.request は SandboxedRequest です。これは、プロセス内でも分離された環境(isolate)の中でも同じように動く、持ち運び可能な { url, method, headers } のレコードです。headers は、小文字にしたヘッダー名をキーとする Record<string, string> です。小文字の名前で参照するか、Object.entries で順に処理します。url は文字列のため、new URL(request.url) でクエリパラメーターを解析できます。routeCtx.requestMeta には、利用できる場合、プラットフォームの違いをそろえたIPアドレス、ユーザーエージェント、地域のデータが入っています。
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") return { error: "POST_REQUIRED" };
},
よく使う書き方
設定とページ分割したデータ
プラグインの設定には、非公開のルート、Block Kitのフォーム、ctx.kv を使います。読み込み、検証、フォーム、秘密情報の扱いまでの一通りの書き方は、設定で説明しています。
プラグインのデータを一覧で返すルートは、ctx.storage.<collection>.query() のカーソルを返します。カーソルの渡し方と、1ページ100件の上限を超えずに複数のページを最後まで読み込む方法は、ストレージのページ分割で説明しています。
外部APIの中継
ctx.http を通じて、外部のサービスにリクエストを中継します(network:request の権限と、allowedHosts への記載が必要です)。
routes: {
forecast: {
handler: async (routeCtx, ctx) => {
const parsed = z.object({ city: z.string().min(1) }).safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_CITY" };
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=${encodeURIComponent(parsed.data.city)}`,
{ headers: { "X-API-Key": apiKey } },
);
if (!response.ok) {
throw new Error(`Weather API error: ${response.status}`);
}
return response.json();
},
},
},
Block Kitからルートを呼び出す
サンドボックス型プラグインは、管理画面にReactのコードを配布しません。admin ルートを宣言し、Block Kitのレスポンスを返します。EmDashは、page_load、block_action、form_submit のやり取りを、正しいURLとCSRFのヘッダーを付けて、その非公開のルートに送ります。やり取りの取り決めと、ルートの完全な例は、Block Kitで説明しています。
キューとスケジュールのハンドラーからルートを呼び出す
プラットフォームのイベントのハンドラー(Cloudflare Queueのコンシューマー、独自の scheduled() ハンドラー)にはHTTPリクエストがないため、locals.emdash もありません。emdash/middleware の withEmDashRuntime() を使ってランタイムを直接取得し、リクエストなしでプラグインのルートを呼び出します。
import { withEmDashRuntime } from "emdash/middleware";
export default {
// ... fetch/scheduled from @emdash-cms/cloudflare/worker
async queue(batch: MessageBatch) {
await withEmDashRuntime(async (runtime) => {
for (const message of batch.messages) {
const result = await runtime.handlePluginApiRoute(
"my-plugin",
"POST",
"/finishJob",
new Request("https://internal/", {
method: "POST",
body: JSON.stringify(message.body),
}),
);
if (result.success) message.ack();
else message.retry();
}
});
},
};
これは、リクエストのハンドラーが使うのと同じ、キャッシュされたランタイムを取得します。そのため、プラグインのストレージ、フック、メディアへのアクセスは、リクエストの処理中とまったく同じように動きます。接続を使うデータベースのアダプター(たとえばHyperdrive経由のPostgres)では、コールバックはイベント単位の接続の中で実行され、コールバックが戻るときにコミットされて閉じられます。
外部からルートを呼び出す
公開ルートは、直接呼び出せます。
curl -X POST https://your-site.com/_emdash/api/plugins/forms/track \
-H "Content-Type: application/json" \
-d '{"event": "pageview"}'
非公開のルートには、セッションの認証情報と X-EmDash-Request: 1、または admin スコープを持つAPIトークンが必要です。次のサーバー間のリクエストは、トークンを使っています。
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"}'
ルートのコンテキストのリファレンス
次のインターフェースは、サンドボックス型のルートのハンドラーで使える、持ち運び可能な値をまとめたものです。
// What sandboxed route handlers receive as their two arguments
interface SandboxedRequest {
url: string;
method: string;
headers: Record<string, string>; // lowercased keys
}
interface SandboxedRouteContext {
input: unknown; // validate inside the handler before use
request: SandboxedRequest;
requestMeta?: unknown;
user?: UserInfo; // authenticated caller on private routes; undefined on public routes
}
interface UserInfo {
id: string;
email: string;
name: string | null;
role: number;
createdAt: string;
}
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
taxonomies?: TaxonomyAccess; // when taxonomies:read 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
}
ネイティブ型プラグインは、この2つを組み合わせた1つの RouteContext を引数として受け取ります。ネイティブ型で作る場合は、ネイティブ型プラグインの作成を参照します。