Declare plugin settings in the manifest, resolve them from the environment
This commit is contained in:
@@ -27,7 +27,7 @@ The plugin holds **no state** — data lives upstream (README → *Stateless*).
|
||||
|
||||
## Upstream
|
||||
|
||||
Set `SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
|
||||
Set `PLUGIN_SETTING_SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
|
||||
mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box.
|
||||
A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook).
|
||||
|
||||
|
||||
@@ -3,19 +3,20 @@
|
||||
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
||||
|
||||
import { definePlugin } from "@plainpages/plugin-api";
|
||||
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
||||
import { createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
||||
|
||||
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
|
||||
// stateless). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream).
|
||||
const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000";
|
||||
const upstream = createUpstream(upstreamUrl);
|
||||
// stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
|
||||
// over — which is after this manifest is built, hence the getter.
|
||||
let upstreamUrl = "";
|
||||
const upstream = createUpstream(() => upstreamUrl);
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
|
||||
// onBoot runs after discovery, before the server listens: validate the plugin's own config so a
|
||||
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
|
||||
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") },
|
||||
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
|
||||
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
|
||||
hooks: { onBoot: ({ settings }) => { upstreamUrl = settings.upstream; } },
|
||||
|
||||
// Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog
|
||||
// (i18n/<locale>.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling"
|
||||
@@ -45,4 +46,15 @@ export default definePlugin({
|
||||
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
|
||||
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
|
||||
],
|
||||
|
||||
// Operator-supplied config: one PLUGIN_SETTING_SCHEDULING_UPSTREAM variable, validated as a URL at
|
||||
// boot. The default points at the mock backend the dev compose runs (examples/shifts-upstream).
|
||||
settings: [
|
||||
{
|
||||
default: "http://shifts-upstream:4000",
|
||||
description: "Base URL of the backend this plugin reads shifts from and writes them to",
|
||||
key: "upstream",
|
||||
type: "url",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import test from "node:test";
|
||||
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "@plainpages/plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
import {
|
||||
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
||||
buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
||||
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
||||
} from "./shifts.ts";
|
||||
|
||||
@@ -18,7 +18,7 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; ve
|
||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||
return {
|
||||
chrome: CHROME, declaredPermissions: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
||||
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
||||
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
|
||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
};
|
||||
@@ -35,27 +35,25 @@ const asView = (r: RouteResult | void) => {
|
||||
return r as { data: Record<string, unknown>; status?: number; view: string };
|
||||
};
|
||||
|
||||
// ---- upstream config validation (the onBoot hook) ----
|
||||
// ---- the upstream URL as a declared setting ----
|
||||
|
||||
test("assertHttpUrl accepts http(s) and fails loud on a malformed or non-http upstream URL", () => {
|
||||
assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM"));
|
||||
assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM"));
|
||||
assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable
|
||||
assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme
|
||||
assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme
|
||||
test("the manifest declares its upstream as a URL setting the host validates", async () => {
|
||||
const manifest = (await import("./plugin.ts")).default;
|
||||
assert.deepEqual(manifest.settings?.map((s) => s.key), ["upstream"]);
|
||||
assert.equal(manifest.settings?.[0]?.type, "url"); // so a typo'd URL fails the boot, not every request
|
||||
assert.equal(manifest.settings?.[0]?.default, "http://shifts-upstream:4000"); // the dev compose's mock
|
||||
assert.equal(typeof manifest.hooks?.onBoot, "function"); // without it the resolved value never arrives
|
||||
});
|
||||
|
||||
test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not just the helper)", async () => {
|
||||
const prev = process.env["SCHEDULING_UPSTREAM"];
|
||||
process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below
|
||||
try {
|
||||
const manifest = (await import("./plugin.ts")).default;
|
||||
assert.equal(typeof manifest.hooks?.onBoot, "function");
|
||||
assert.throws(() => manifest.hooks!.onBoot!({}), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"];
|
||||
else process.env["SCHEDULING_UPSTREAM"] = prev;
|
||||
}
|
||||
test("the client re-reads its base URL, so onBoot can bind it after the manifest is built", async () => {
|
||||
let baseUrl = "http://first:4000";
|
||||
const seen: string[] = [];
|
||||
const http = (async (url) => { seen.push(String(url)); return new Response("[]", { status: 200 }); }) as typeof fetch;
|
||||
const upstream = createUpstream(() => baseUrl, http);
|
||||
await upstream.list();
|
||||
baseUrl = "http://second:4000";
|
||||
await upstream.list();
|
||||
assert.deepEqual(seen, ["http://first:4000/shifts", "http://second:4000/shifts"]);
|
||||
});
|
||||
|
||||
// ---- upstream client (fetch injected) ----
|
||||
@@ -67,21 +65,21 @@ test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", as
|
||||
assert.equal((init?.headers as Record<string, string>).accept, "application/json");
|
||||
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
const shifts = await createUpstream("http://up:4000/", http).list(); // trailing slash trimmed
|
||||
const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed
|
||||
assert.equal(seen, "http://up:4000/shifts");
|
||||
assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
|
||||
});
|
||||
|
||||
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
|
||||
const http = (async () => new Response("nope", { status: 503 })) as typeof fetch;
|
||||
await assert.rejects(createUpstream("http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
|
||||
await assert.rejects(createUpstream(() => "http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
|
||||
});
|
||||
|
||||
test("createUpstream.create POSTs the input as JSON", async () => {
|
||||
let body: unknown, method = "";
|
||||
const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch;
|
||||
const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" };
|
||||
await createUpstream("http://up:4000", http).create(input);
|
||||
await createUpstream(() => "http://up:4000", http).create(input);
|
||||
assert.equal(method, "POST");
|
||||
assert.deepEqual(body, input);
|
||||
});
|
||||
|
||||
@@ -49,26 +49,16 @@ export interface ShiftsUpstream {
|
||||
list(): Promise<Shift[]>;
|
||||
}
|
||||
|
||||
// Fail loud at boot (the plugin's onBoot hook) on a malformed/non-http upstream URL — a config
|
||||
// typo surfaces at startup, not as a degraded page later. Reachability stays a runtime concern.
|
||||
export function assertHttpUrl(value: string, name: string): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${name} is not a valid URL: ${JSON.stringify(value)}`);
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${name} must be an http(s) URL: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
|
||||
// defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client
|
||||
// span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network.
|
||||
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
|
||||
const base = baseUrl.replace(/\/+$/, "");
|
||||
// `baseUrl` is read per call: the plugin's settings arrive on onBoot, after the manifest that binds
|
||||
// these handlers has already been built.
|
||||
export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
|
||||
const base = (): string => baseUrl().replace(/\/+$/, "");
|
||||
return {
|
||||
async create(input) {
|
||||
const res = await fetchImpl(`${base}/shifts`, {
|
||||
const res = await fetchImpl(`${base()}/shifts`, {
|
||||
body: JSON.stringify(input),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
@@ -76,7 +66,7 @@ export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = traced
|
||||
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
|
||||
},
|
||||
async list() {
|
||||
const res = await fetchImpl(`${base}/shifts`, { headers: { accept: "application/json" } });
|
||||
const res = await fetchImpl(`${base()}/shifts`, { headers: { accept: "application/json" } });
|
||||
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
|
||||
const data: unknown = await res.json();
|
||||
return Array.isArray(data) ? data.map(toShift) : [];
|
||||
|
||||
Reference in New Issue
Block a user