524 lines
16 KiB
TypeScript
524 lines
16 KiB
TypeScript
/**
|
|
* API Test Plugin for EmDash CMS
|
|
*
|
|
* This plugin exercises all v2 plugin APIs for testing purposes:
|
|
* - ctx.plugin (plugin info)
|
|
* - ctx.kv (key-value store)
|
|
* - ctx.log (logging)
|
|
* - ctx.storage (storage collections)
|
|
* - ctx.content (content access with read/write)
|
|
* - ctx.media (media access with read/write)
|
|
* - ctx.http (network fetch)
|
|
*
|
|
* Each API is exposed via a route for manual testing.
|
|
*/
|
|
|
|
import type { ResolvedPlugin, PluginDescriptor } from "emdash";
|
|
import { definePlugin } from "emdash";
|
|
|
|
/** Narrow unknown to a record */
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
/** Safely extract a string property from an unknown value */
|
|
function getString(value: unknown, key: string): string | undefined {
|
|
if (!isRecord(value)) return undefined;
|
|
const v = value[key];
|
|
return typeof v === "string" ? v : undefined;
|
|
}
|
|
|
|
/** Safely extract a number property from an unknown value */
|
|
function getNumber(value: unknown, key: string): number | undefined {
|
|
if (!isRecord(value)) return undefined;
|
|
const v = value[key];
|
|
return typeof v === "number" ? v : undefined;
|
|
}
|
|
|
|
export interface ApiTestPluginOptions {
|
|
/** Test webhook URL for http.fetch testing */
|
|
testUrl?: string;
|
|
}
|
|
|
|
/**
|
|
* Plugin factory - returns a descriptor for the integration to use
|
|
* The integration will generate a virtual module that imports and calls createPlugin
|
|
*/
|
|
export function apiTestPlugin(
|
|
options: ApiTestPluginOptions = {},
|
|
): PluginDescriptor<ApiTestPluginOptions> {
|
|
return {
|
|
id: "api-test",
|
|
version: "0.0.1",
|
|
entrypoint: "@emdash-cms/plugin-api-test",
|
|
options,
|
|
adminEntry: "@emdash-cms/plugin-api-test/admin",
|
|
adminPages: [{ path: "/test", label: "API Tests", icon: "code" }],
|
|
adminWidgets: [{ id: "api-status", title: "API Status", size: "half" }],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create the resolved plugin - called by the generated virtual module
|
|
*/
|
|
export function createPlugin(_options: ApiTestPluginOptions = {}): ResolvedPlugin {
|
|
return definePlugin({
|
|
id: "api-test",
|
|
version: "0.0.1",
|
|
|
|
// Declare ALL capabilities to test everything
|
|
capabilities: ["read:content", "write:content", "read:media", "write:media", "network:fetch"],
|
|
|
|
// Allowed hosts for fetch testing
|
|
allowedHosts: ["httpbin.org", "*.httpbin.org", "jsonplaceholder.typicode.com"],
|
|
|
|
// Storage collections with indexes
|
|
storage: {
|
|
logs: {
|
|
indexes: ["timestamp", "level", ["level", "timestamp"]],
|
|
},
|
|
counters: {
|
|
indexes: ["name"],
|
|
},
|
|
},
|
|
|
|
// Admin configuration
|
|
admin: {
|
|
entry: "@emdash-cms/plugin-api-test/admin",
|
|
pages: [{ path: "/test", label: "API Tests", icon: "code" }],
|
|
widgets: [{ id: "api-status", title: "API Status", size: "half" }],
|
|
},
|
|
|
|
// Routes that exercise each API
|
|
routes: {
|
|
// =================================================================
|
|
// Plugin Info (always available)
|
|
// =================================================================
|
|
"plugin/info": {
|
|
handler: async (ctx) => {
|
|
return {
|
|
id: ctx.plugin.id,
|
|
version: ctx.plugin.version,
|
|
};
|
|
},
|
|
},
|
|
|
|
// =================================================================
|
|
// Logging (always available)
|
|
// =================================================================
|
|
"log/test": {
|
|
handler: async (ctx) => {
|
|
ctx.log.debug("Debug message from api-test", { route: "log/test" });
|
|
ctx.log.info("Info message from api-test", { route: "log/test" });
|
|
ctx.log.warn("Warning message from api-test", { route: "log/test" });
|
|
ctx.log.error("Error message from api-test", { route: "log/test" });
|
|
return { success: true, message: "Logged at all levels" };
|
|
},
|
|
},
|
|
|
|
// =================================================================
|
|
// KV Store (always available)
|
|
// =================================================================
|
|
"kv/get": {
|
|
handler: async (ctx) => {
|
|
const key = getString(ctx.input, "key") ?? "test-key";
|
|
const value = await ctx.kv.get(key);
|
|
return { key, value };
|
|
},
|
|
},
|
|
|
|
"kv/set": {
|
|
handler: async (ctx) => {
|
|
const key = getString(ctx.input, "key") ?? "";
|
|
const value = isRecord(ctx.input) ? ctx.input.value : undefined;
|
|
await ctx.kv.set(key, value);
|
|
return { success: true, key, value };
|
|
},
|
|
},
|
|
|
|
"kv/delete": {
|
|
handler: async (ctx) => {
|
|
const key = getString(ctx.input, "key") ?? "test-key";
|
|
const deleted = await ctx.kv.delete(key);
|
|
return { key, deleted };
|
|
},
|
|
},
|
|
|
|
"kv/list": {
|
|
handler: async (ctx) => {
|
|
const prefix = getString(ctx.input, "prefix");
|
|
const entries = await ctx.kv.list(prefix);
|
|
return { prefix, entries, count: entries.length };
|
|
},
|
|
},
|
|
|
|
// =================================================================
|
|
// Storage Collections (requires storage declaration)
|
|
// =================================================================
|
|
"storage/logs/put": {
|
|
handler: async (ctx) => {
|
|
const id = `log-${Date.now()}`;
|
|
const data = {
|
|
timestamp: new Date().toISOString(),
|
|
level: getString(ctx.input, "level") ?? "info",
|
|
message: getString(ctx.input, "message") ?? "Test log entry",
|
|
};
|
|
await ctx.storage.logs.put(id, data);
|
|
return { id, data };
|
|
},
|
|
},
|
|
|
|
"storage/logs/get": {
|
|
handler: async (ctx) => {
|
|
const id = getString(ctx.input, "id");
|
|
if (!id) return { error: "id required" };
|
|
const data = await ctx.storage.logs.get(id);
|
|
return { id, data, exists: data !== null };
|
|
},
|
|
},
|
|
|
|
"storage/logs/query": {
|
|
handler: async (ctx) => {
|
|
const level = getString(ctx.input, "level");
|
|
const limit = getNumber(ctx.input, "limit");
|
|
const cursor = getString(ctx.input, "cursor");
|
|
const result = await ctx.storage.logs.query({
|
|
where: level ? { level } : undefined,
|
|
orderBy: { timestamp: "desc" },
|
|
limit: limit ?? 10,
|
|
cursor,
|
|
});
|
|
return result;
|
|
},
|
|
},
|
|
|
|
"storage/logs/count": {
|
|
handler: async (ctx) => {
|
|
const level = getString(ctx.input, "level");
|
|
const count = await ctx.storage.logs.count(level ? { level } : undefined);
|
|
return { level, count };
|
|
},
|
|
},
|
|
|
|
"storage/logs/delete": {
|
|
handler: async (ctx) => {
|
|
const id = getString(ctx.input, "id");
|
|
if (!id) return { error: "id required" };
|
|
const deleted = await ctx.storage.logs.delete(id);
|
|
return { id, deleted };
|
|
},
|
|
},
|
|
|
|
"storage/counters/increment": {
|
|
handler: async (ctx) => {
|
|
const name = getString(ctx.input, "name") ?? "default";
|
|
const raw = await ctx.storage.counters.get(name);
|
|
const currentValue = isRecord(raw) && typeof raw.value === "number" ? raw.value : 0;
|
|
const newValue = currentValue + 1;
|
|
await ctx.storage.counters.put(name, { name, value: newValue });
|
|
return { name, value: newValue };
|
|
},
|
|
},
|
|
|
|
// =================================================================
|
|
// Content Access (requires read:content, write:content)
|
|
// =================================================================
|
|
"content/list": {
|
|
handler: async (ctx) => {
|
|
if (!ctx.content) {
|
|
return { error: "content access not available" };
|
|
}
|
|
const collection = getString(ctx.input, "collection") ?? "posts";
|
|
const limit = getNumber(ctx.input, "limit");
|
|
const cursor = getString(ctx.input, "cursor");
|
|
const result = await ctx.content.list(collection, {
|
|
limit: limit ?? 10,
|
|
cursor,
|
|
});
|
|
return { collection, ...result };
|
|
},
|
|
},
|
|
|
|
"content/get": {
|
|
handler: async (ctx) => {
|
|
if (!ctx.content) {
|
|
return { error: "content access not available" };
|
|
}
|
|
const id = getString(ctx.input, "id");
|
|
if (!id) return { error: "id required" };
|
|
const collection = getString(ctx.input, "collection") ?? "posts";
|
|
const item = await ctx.content.get(collection, id);
|
|
return { collection, id, item, exists: item !== null };
|
|
},
|
|
},
|
|
|
|
"content/create": {
|
|
handler: async (ctx) => {
|
|
if (!ctx.content?.create) {
|
|
return { error: "content write access not available" };
|
|
}
|
|
const collection = getString(ctx.input, "collection") ?? "posts";
|
|
const inputData =
|
|
isRecord(ctx.input) && isRecord(ctx.input.data) ? ctx.input.data : undefined;
|
|
const data = inputData ?? {
|
|
title: `Test Post ${Date.now()}`,
|
|
body: "Created by api-test plugin",
|
|
};
|
|
const item = await ctx.content.create(collection, data);
|
|
return { collection, item };
|
|
},
|
|
},
|
|
|
|
"content/update": {
|
|
handler: async (ctx) => {
|
|
if (!ctx.content?.update) {
|
|
return { error: "content write access not available" };
|
|
}
|
|
const id = getString(ctx.input, "id");
|
|
if (!id) return { error: "id required" };
|
|
const collection = getString(ctx.input, "collection") ?? "posts";
|
|
const inputData =
|
|
isRecord(ctx.input) && isRecord(ctx.input.data) ? ctx.input.data : undefined;
|
|
const data = inputData ?? { updatedAt: new Date().toISOString() };
|
|
const item = await ctx.content.update(collection, id, data);
|
|
return { collection, item };
|
|
},
|
|
},
|
|
|
|
"content/delete": {
|
|
handler: async (ctx) => {
|
|
if (!ctx.content?.delete) {
|
|
return { error: "content write access not available" };
|
|
}
|
|
const id = getString(ctx.input, "id");
|
|
if (!id) return { error: "id required" };
|
|
const collection = getString(ctx.input, "collection") ?? "posts";
|
|
const deleted = await ctx.content.delete(collection, id);
|
|
return { collection, id, deleted };
|
|
},
|
|
},
|
|
|
|
// =================================================================
|
|
// Media Access (requires read:media, write:media)
|
|
// =================================================================
|
|
"media/list": {
|
|
handler: async (ctx) => {
|
|
if (!ctx.media) {
|
|
return { error: "media access not available" };
|
|
}
|
|
const limit = getNumber(ctx.input, "limit");
|
|
const cursor = getString(ctx.input, "cursor");
|
|
const mimeType = getString(ctx.input, "mimeType");
|
|
const result = await ctx.media.list({
|
|
limit: limit ?? 10,
|
|
cursor,
|
|
mimeType,
|
|
});
|
|
return result;
|
|
},
|
|
},
|
|
|
|
"media/get": {
|
|
handler: async (ctx) => {
|
|
if (!ctx.media) {
|
|
return { error: "media access not available" };
|
|
}
|
|
const id = getString(ctx.input, "id");
|
|
if (!id) return { error: "id required" };
|
|
const item = await ctx.media.get(id);
|
|
return { id, item, exists: item !== null };
|
|
},
|
|
},
|
|
|
|
"media/upload-url": {
|
|
handler: async (ctx) => {
|
|
if (!ctx.media?.getUploadUrl) {
|
|
return { error: "media write access not available" };
|
|
}
|
|
const filename = getString(ctx.input, "filename") ?? `test-${Date.now()}.txt`;
|
|
const contentType = getString(ctx.input, "contentType") ?? "text/plain";
|
|
const result = await ctx.media.getUploadUrl(filename, contentType);
|
|
return { filename, contentType, ...result };
|
|
},
|
|
},
|
|
|
|
// =================================================================
|
|
// HTTP Fetch (requires network:fetch)
|
|
// =================================================================
|
|
"http/fetch": {
|
|
handler: async (ctx) => {
|
|
if (!ctx.http) {
|
|
return { error: "http access not available" };
|
|
}
|
|
const url = getString(ctx.input, "url") ?? "https://httpbin.org/get";
|
|
const method = getString(ctx.input, "method") ?? "GET";
|
|
|
|
try {
|
|
const response = await ctx.http.fetch(url, { method });
|
|
const data = await response.json();
|
|
return {
|
|
url,
|
|
method,
|
|
status: response.status,
|
|
ok: response.ok,
|
|
data,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
url,
|
|
method,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
},
|
|
},
|
|
|
|
"http/post": {
|
|
handler: async (ctx) => {
|
|
if (!ctx.http) {
|
|
return { error: "http access not available" };
|
|
}
|
|
const url = getString(ctx.input, "url") ?? "https://httpbin.org/post";
|
|
const body = isRecord(ctx.input) ? ctx.input.body : undefined;
|
|
|
|
try {
|
|
const response = await ctx.http.fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body ?? { test: true }),
|
|
});
|
|
const data = await response.json();
|
|
return { url, status: response.status, ok: response.ok, data };
|
|
} catch (error) {
|
|
return {
|
|
url,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
},
|
|
},
|
|
|
|
// =================================================================
|
|
// Combined Test (exercises multiple APIs)
|
|
// =================================================================
|
|
"test/all": {
|
|
handler: async (ctx) => {
|
|
const results: Record<string, unknown> = {};
|
|
|
|
// 1. Plugin info
|
|
results.plugin = {
|
|
id: ctx.plugin.id,
|
|
version: ctx.plugin.version,
|
|
};
|
|
|
|
// 2. Logging
|
|
ctx.log.info("Running all API tests", { timestamp: Date.now() });
|
|
results.log = "logged";
|
|
|
|
// 3. KV
|
|
const kvKey = `test-all-${Date.now()}`;
|
|
await ctx.kv.set(kvKey, { tested: true });
|
|
const kvValue = await ctx.kv.get(kvKey);
|
|
await ctx.kv.delete(kvKey);
|
|
results.kv = { key: kvKey, value: kvValue, cleaned: true };
|
|
|
|
// 4. Storage
|
|
const logId = `test-${Date.now()}`;
|
|
await ctx.storage.logs.put(logId, {
|
|
timestamp: new Date().toISOString(),
|
|
level: "test",
|
|
message: "API test entry",
|
|
});
|
|
const logEntry = await ctx.storage.logs.get(logId);
|
|
await ctx.storage.logs.delete(logId);
|
|
results.storage = { id: logId, entry: logEntry, cleaned: true };
|
|
|
|
// 5. Content (if available)
|
|
if (ctx.content) {
|
|
const contentList = await ctx.content.list("posts", { limit: 1 });
|
|
results.content = {
|
|
available: true,
|
|
canWrite: !!ctx.content.create,
|
|
sampleCount: contentList.items.length,
|
|
};
|
|
} else {
|
|
results.content = { available: false };
|
|
}
|
|
|
|
// 6. Media (if available)
|
|
if (ctx.media) {
|
|
const mediaList = await ctx.media.list({ limit: 1 });
|
|
results.media = {
|
|
available: true,
|
|
canWrite: !!ctx.media.getUploadUrl,
|
|
sampleCount: mediaList.items.length,
|
|
};
|
|
} else {
|
|
results.media = { available: false };
|
|
}
|
|
|
|
// 7. HTTP (if available)
|
|
if (ctx.http) {
|
|
try {
|
|
const response = await ctx.http.fetch("https://httpbin.org/get");
|
|
results.http = {
|
|
available: true,
|
|
testStatus: response.status,
|
|
};
|
|
} catch (error) {
|
|
results.http = {
|
|
available: true,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
} else {
|
|
results.http = { available: false };
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
timestamp: new Date().toISOString(),
|
|
results,
|
|
};
|
|
},
|
|
},
|
|
},
|
|
|
|
// Hooks to test hook system
|
|
hooks: {
|
|
"plugin:install": {
|
|
handler: async (_event, ctx) => {
|
|
ctx.log.info("api-test plugin installed");
|
|
await ctx.kv.set("state:installed", new Date().toISOString());
|
|
},
|
|
},
|
|
|
|
"plugin:activate": {
|
|
handler: async (_event, ctx) => {
|
|
ctx.log.info("api-test plugin activated");
|
|
await ctx.kv.set("state:activated", new Date().toISOString());
|
|
},
|
|
},
|
|
|
|
"content:afterSave": {
|
|
priority: 200, // Run late to not interfere
|
|
handler: async (event, ctx) => {
|
|
ctx.log.debug("api-test saw content save", {
|
|
collection: event.collection,
|
|
isNew: event.isNew,
|
|
});
|
|
// Log to storage for verification
|
|
await ctx.storage.logs.put(`save-${Date.now()}`, {
|
|
timestamp: new Date().toISOString(),
|
|
level: "info",
|
|
message: `Content saved: ${event.collection}`,
|
|
data: { collection: event.collection, isNew: event.isNew },
|
|
});
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export default createPlugin;
|