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
This commit is contained in:
15
packages/plugins/api-test/CHANGELOG.md
Normal file
15
packages/plugins/api-test/CHANGELOG.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# @emdash-cms/plugin-api-test
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`3c319ed`](https://github.com/emdash-cms/emdash/commit/3c319ed6411a595e6974a86bc58c2a308b91c214)]:
|
||||
- emdash@0.0.3
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`b09bfd5`](https://github.com/emdash-cms/emdash/commit/b09bfd51cece5e88fe8314668a591ab11de36b4d)]:
|
||||
- emdash@0.0.2
|
||||
37
packages/plugins/api-test/package.json
Normal file
37
packages/plugins/api-test/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@emdash-cms/plugin-api-test",
|
||||
"private": true,
|
||||
"version": "0.0.3",
|
||||
"description": "Test plugin that exercises all EmDash plugin APIs",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./admin": "./src/admin.tsx"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"keywords": [
|
||||
"emdash",
|
||||
"cms",
|
||||
"plugin",
|
||||
"test",
|
||||
"api"
|
||||
],
|
||||
"author": "Matt Kane",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"emdash": "workspace:>=0.9.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"@phosphor-icons/react": "^2.1.10"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {},
|
||||
"optionalDependencies": {},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/emdash-cms/emdash.git",
|
||||
"directory": "packages/plugins/api-test"
|
||||
}
|
||||
}
|
||||
357
packages/plugins/api-test/src/admin.tsx
Normal file
357
packages/plugins/api-test/src/admin.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* API Test Plugin - Admin Components
|
||||
*
|
||||
* Provides a dashboard widget and test page for exercising plugin APIs.
|
||||
*/
|
||||
|
||||
import {
|
||||
Play,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
CircleNotch,
|
||||
Database,
|
||||
Key,
|
||||
Globe,
|
||||
FileText,
|
||||
ImageSquare,
|
||||
Terminal,
|
||||
ArrowsClockwise,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { PluginAdminExports } from "emdash";
|
||||
import { apiFetch, getErrorMessage, parseApiResponse } from "emdash/plugin-utils";
|
||||
import * as React from "react";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
status: "pending" | "running" | "success" | "error";
|
||||
duration?: number;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface ApiTestResults {
|
||||
plugin: { id: string; version: string };
|
||||
log: string;
|
||||
kv: { key: string; value: unknown; cleaned: boolean };
|
||||
storage: { id: string; entry: unknown; cleaned: boolean };
|
||||
content: { available: boolean; canWrite: boolean; sampleCount: number };
|
||||
media: { available: boolean; canWrite: boolean; sampleCount: number };
|
||||
http: { available: boolean; testStatus?: number; error?: string };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Dashboard Widget
|
||||
// =============================================================================
|
||||
|
||||
function ApiTestWidget() {
|
||||
const [lastRun, setLastRun] = React.useState<Date | null>(null);
|
||||
const [results, setResults] = React.useState<ApiTestResults | null>(null);
|
||||
const [isRunning, setIsRunning] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const runTests = async () => {
|
||||
setIsRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiFetch("/_emdash/api/plugins/api-test/test/all", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await parseApiResponse<{ results: ApiTestResults }>(response);
|
||||
setResults(data.results);
|
||||
setLastRun(new Date());
|
||||
} else {
|
||||
setError(await getErrorMessage(response, "Test failed"));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Test failed");
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const apiStatus = React.useMemo(() => {
|
||||
if (!results) return [];
|
||||
return [
|
||||
{ name: "Plugin", ok: !!results.plugin?.id, icon: Terminal },
|
||||
{ name: "KV", ok: results.kv?.cleaned, icon: Key },
|
||||
{ name: "Storage", ok: results.storage?.cleaned, icon: Database },
|
||||
{ name: "Content", ok: results.content?.available, icon: FileText },
|
||||
{ name: "Media", ok: results.media?.available, icon: ImageSquare },
|
||||
{ name: "HTTP", ok: results.http?.testStatus === 200, icon: Globe },
|
||||
];
|
||||
}, [results]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && <div className="text-xs text-red-500 bg-red-500/10 rounded p-2">{error}</div>}
|
||||
|
||||
{results ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{apiStatus.map(({ name, ok, icon: Icon }) => (
|
||||
<div key={name} className="flex items-center gap-1.5 text-xs">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{name}</span>
|
||||
{ok ? (
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500 ms-auto" />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500 ms-auto" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-sm text-muted-foreground py-4">No test results yet</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
{lastRun && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last run: {lastRun.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={runTests}
|
||||
disabled={isRunning}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground disabled:opacity-50 ms-auto"
|
||||
>
|
||||
{isRunning ? (
|
||||
<CircleNotch className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ArrowsClockwise className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isRunning ? "Running..." : "Run Tests"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Test Page
|
||||
// =============================================================================
|
||||
|
||||
const API_TESTS = [
|
||||
{
|
||||
id: "plugin-info",
|
||||
name: "Plugin Info",
|
||||
route: "plugin/info",
|
||||
icon: Terminal,
|
||||
},
|
||||
{
|
||||
id: "kv-set",
|
||||
name: "KV Set",
|
||||
route: "kv/set",
|
||||
icon: Key,
|
||||
body: { key: "admin-test", value: { from: "admin" } },
|
||||
},
|
||||
{
|
||||
id: "kv-get",
|
||||
name: "KV Get",
|
||||
route: "kv/get",
|
||||
icon: Key,
|
||||
body: { key: "admin-test" },
|
||||
},
|
||||
{ id: "kv-list", name: "KV List", route: "kv/list", icon: Key },
|
||||
{
|
||||
id: "storage-put",
|
||||
name: "Storage Put",
|
||||
route: "storage/logs/put",
|
||||
icon: Database,
|
||||
body: { level: "info", message: "Test from admin" },
|
||||
},
|
||||
{
|
||||
id: "storage-query",
|
||||
name: "Storage Query",
|
||||
route: "storage/logs/query",
|
||||
icon: Database,
|
||||
body: { limit: 5 },
|
||||
},
|
||||
{
|
||||
id: "content-list",
|
||||
name: "Content List",
|
||||
route: "content/list",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
id: "media-list",
|
||||
name: "Media List",
|
||||
route: "media/list",
|
||||
icon: ImageSquare,
|
||||
},
|
||||
{
|
||||
id: "http-fetch",
|
||||
name: "HTTP Fetch",
|
||||
route: "http/fetch",
|
||||
icon: Globe,
|
||||
body: { url: "https://httpbin.org/get" },
|
||||
},
|
||||
{ id: "log-test", name: "Logging", route: "log/test", icon: Terminal },
|
||||
];
|
||||
|
||||
function TestPage() {
|
||||
const [results, setResults] = React.useState<Record<string, TestResult>>({});
|
||||
const [isRunningAll, setIsRunningAll] = React.useState(false);
|
||||
|
||||
const runTest = async (testId: string, route: string, body?: unknown) => {
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[testId]: { name: testId, status: "running" },
|
||||
}));
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
const response = await apiFetch(`/_emdash/api/plugins/api-test/${route}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
const duration = Date.now() - start;
|
||||
|
||||
if (response.ok) {
|
||||
const data = await parseApiResponse<unknown>(response);
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[testId]: { name: testId, status: "success", duration, data },
|
||||
}));
|
||||
} else {
|
||||
const errorMsg = await getErrorMessage(response, "Failed");
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[testId]: {
|
||||
name: testId,
|
||||
status: "error",
|
||||
duration,
|
||||
error: errorMsg,
|
||||
},
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[testId]: {
|
||||
name: testId,
|
||||
status: "error",
|
||||
duration: Date.now() - start,
|
||||
error: e instanceof Error ? e.message : "Failed",
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const runAllTests = async () => {
|
||||
setIsRunningAll(true);
|
||||
for (const test of API_TESTS) {
|
||||
await runTest(test.id, test.route, test.body);
|
||||
}
|
||||
setIsRunningAll(false);
|
||||
};
|
||||
|
||||
const successCount = Object.values(results).filter((r) => r.status === "success").length;
|
||||
const errorCount = Object.values(results).filter((r) => r.status === "error").length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">API Tests</h1>
|
||||
<p className="text-muted-foreground mt-1">Test all plugin v2 APIs</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{Object.keys(results).length > 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="text-green-500">{successCount} passed</span>
|
||||
{errorCount > 0 && (
|
||||
<>
|
||||
{" / "}
|
||||
<span className="text-red-500">{errorCount} failed</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={runAllTests}
|
||||
disabled={isRunningAll}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isRunningAll ? (
|
||||
<CircleNotch className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
{isRunningAll ? "Running..." : "Run All Tests"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{API_TESTS.map((test) => {
|
||||
const result = results[test.id];
|
||||
const Icon = test.icon;
|
||||
|
||||
return (
|
||||
<div key={test.id} className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{test.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{result?.status === "success" && (
|
||||
<span className="text-xs text-muted-foreground">{result.duration}ms</span>
|
||||
)}
|
||||
{result?.status === "running" ? (
|
||||
<CircleNotch className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : result?.status === "success" ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : result?.status === "error" ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => runTest(test.id, test.route, test.body)}
|
||||
disabled={result?.status === "running" || isRunningAll}
|
||||
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
POST /_emdash/api/plugins/api-test/{test.route}
|
||||
</div>
|
||||
|
||||
{result?.status === "error" && (
|
||||
<div className="text-xs text-red-500 bg-red-500/10 rounded p-2">{result.error}</div>
|
||||
)}
|
||||
|
||||
{result?.status === "success" && result.data && (
|
||||
<pre className="text-xs bg-muted rounded p-2 overflow-auto max-h-32">
|
||||
{JSON.stringify(result.data, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Exports
|
||||
// =============================================================================
|
||||
|
||||
export const widgets: PluginAdminExports["widgets"] = {
|
||||
"api-status": ApiTestWidget,
|
||||
};
|
||||
|
||||
export const pages: PluginAdminExports["pages"] = {
|
||||
"/test": TestPage,
|
||||
};
|
||||
523
packages/plugins/api-test/src/index.ts
Normal file
523
packages/plugins/api-test/src/index.ts
Normal file
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user