/** * 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 { 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 { 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 = {}; // 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;