feat(csr): add the api block transport
This commit is contained in:
@@ -1380,6 +1380,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
state: stateProxy,
|
||||
output: outputProxy,
|
||||
server: serverProxy,
|
||||
callApi: wrnexusCallApi,
|
||||
props: propsProxy,
|
||||
refs: refsProxy,
|
||||
};
|
||||
@@ -4246,6 +4247,65 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Transport for compiled api blocks.
|
||||
*
|
||||
* Only the request and the failure shape live here. A block's response and
|
||||
* error bodies are page code, so they are emitted into the browser module
|
||||
* and applied by the caller.
|
||||
*/
|
||||
function readCsrfToken() {
|
||||
var meta = document.querySelector('meta[name="wrnexus-csrf"]');
|
||||
if (meta) return meta.getAttribute("content") || "";
|
||||
var match = /(?:^|;\s*)wrn-csrf=([^;]+)/.exec(document.cookie || "");
|
||||
return match ? decodeURIComponent(match[1]) : "";
|
||||
}
|
||||
|
||||
function wrnexusCallApi(path, method, input) {
|
||||
var verb = String(method || "GET").toUpperCase();
|
||||
var values = input || {};
|
||||
var url = path;
|
||||
var headers = { accept: "application/json" };
|
||||
var init = { method: verb, credentials: "same-origin", headers: headers };
|
||||
|
||||
if (verb === "GET" || verb === "HEAD") {
|
||||
var query = [];
|
||||
Object.keys(values).forEach(function (key) {
|
||||
var value = values[key];
|
||||
// An omitted filter must not become "name=undefined".
|
||||
if (value === undefined || value === null || value === "") return;
|
||||
query.push(encodeURIComponent(key) + "=" + encodeURIComponent(String(value)));
|
||||
});
|
||||
if (query.length) url = path + "?" + query.join("&");
|
||||
} else {
|
||||
headers["content-type"] = "application/json";
|
||||
headers["x-csrf-token"] = readCsrfToken();
|
||||
init.body = JSON.stringify(values);
|
||||
}
|
||||
|
||||
return fetch(url, init).then(function (response) {
|
||||
return response.json().then(
|
||||
function (data) {
|
||||
if (response.ok) return data;
|
||||
var message =
|
||||
data && data.error ? String(data.error) : "Request failed with " + response.status;
|
||||
var failure = new Error(message);
|
||||
failure.status = response.status;
|
||||
failure.data = data;
|
||||
throw failure;
|
||||
},
|
||||
function () {
|
||||
var failure = new Error("Response was not valid JSON");
|
||||
failure.status = response.status;
|
||||
failure.data = undefined;
|
||||
throw failure;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
window.__wrnexusCallApi = wrnexusCallApi;
|
||||
|
||||
function dispatchComponentEvent(root, name, detail) {
|
||||
if (!root || !name) return null;
|
||||
var EventConstructor =
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { expect, test, beforeEach } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
||||
import { restoreGlobalsAfterAll } from "./global-restore.ts";
|
||||
|
||||
const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "NodeFilter"];
|
||||
restoreGlobalsAfterAll(REPLACED_GLOBALS);
|
||||
|
||||
beforeEach(() => {
|
||||
for (const name of REPLACED_GLOBALS) delete (globalThis as Record<string, unknown>)[name];
|
||||
});
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
init: RequestInit;
|
||||
}
|
||||
|
||||
/** Mount the runtime with a recording fetch and return its callApi plus the calls made. */
|
||||
function harness(response: { status: number; payload: unknown }) {
|
||||
const win = new Window() as unknown as Window & Record<string, unknown>;
|
||||
win.document.body.innerHTML = `<div data-scope="x: 1"></div>`;
|
||||
const calls: Call[] = [];
|
||||
|
||||
(globalThis as Record<string, unknown>).window = win;
|
||||
(globalThis as Record<string, unknown>).document = win.document;
|
||||
(globalThis as Record<string, unknown>).location = win.location;
|
||||
(globalThis as Record<string, unknown>).NodeFilter = (
|
||||
win as unknown as { NodeFilter: unknown }
|
||||
).NodeFilter;
|
||||
(globalThis as Record<string, unknown>).fetch = (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
return Promise.resolve({
|
||||
ok: response.status >= 200 && response.status < 300,
|
||||
status: response.status,
|
||||
json: () => Promise.resolve(response.payload),
|
||||
});
|
||||
};
|
||||
|
||||
(0, eval)(REACTIVE_RUNTIME);
|
||||
const callApi = (win as unknown as { __wrnexusCallApi: Function }).__wrnexusCallApi;
|
||||
return { callApi, calls, win };
|
||||
}
|
||||
|
||||
test("GET builds a query string and omits undefined fields", async () => {
|
||||
const { callApi, calls } = harness({ status: 200, payload: { users: [] } });
|
||||
|
||||
await callApi("/api/users", "GET", { name: "Ajay", age: undefined });
|
||||
|
||||
expect(calls[0]!.url).toBe("/api/users?name=Ajay");
|
||||
expect(calls[0]!.init.method).toBe("GET");
|
||||
expect(calls[0]!.init.body).toBeUndefined();
|
||||
});
|
||||
|
||||
test("POST sends a JSON body", async () => {
|
||||
const { callApi, calls } = harness({ status: 200, payload: { ok: true } });
|
||||
|
||||
await callApi("/api/users", "POST", { name: "Ajay" });
|
||||
|
||||
expect(calls[0]!.url).toBe("/api/users");
|
||||
expect(calls[0]!.init.body).toBe(JSON.stringify({ name: "Ajay" }));
|
||||
expect((calls[0]!.init.headers as Record<string, string>)["content-type"]).toBe(
|
||||
"application/json",
|
||||
);
|
||||
});
|
||||
|
||||
test("a non-GET request carries the CSRF token from the cookie", async () => {
|
||||
const { callApi, calls, win } = harness({ status: 200, payload: {} });
|
||||
win.document.cookie = "wrn-csrf=token-123";
|
||||
|
||||
await callApi("/api/users", "POST", {});
|
||||
|
||||
expect((calls[0]!.init.headers as Record<string, string>)["x-csrf-token"]).toBe("token-123");
|
||||
});
|
||||
|
||||
test("a 2xx resolves to the parsed payload", async () => {
|
||||
const { callApi } = harness({ status: 200, payload: { users: [{ name: "Ajay" }] } });
|
||||
|
||||
expect(await callApi("/api/users", "GET", {})).toEqual({ users: [{ name: "Ajay" }] });
|
||||
});
|
||||
|
||||
test("a non-2xx rejects with status, message and data", async () => {
|
||||
const { callApi } = harness({ status: 400, payload: { error: "Bad filter" } });
|
||||
|
||||
const failure = await callApi("/api/users", "GET", {}).catch(
|
||||
(error: Error & { status?: number; data?: unknown }) => error,
|
||||
);
|
||||
|
||||
expect(failure.status).toBe(400);
|
||||
expect(failure.message).toContain("Bad filter");
|
||||
expect(failure.data).toEqual({ error: "Bad filter" });
|
||||
});
|
||||
Reference in New Issue
Block a user