feat(core): share API request assembly between both transports
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Assemble an API request from a block's declared input.
|
||||||
|
*
|
||||||
|
* Shared by both transports on purpose. The browser and the in-process server
|
||||||
|
* caller must send the same thing for the same call; two copies of these rules
|
||||||
|
* would drift, and the drift would be invisible because each side is tested
|
||||||
|
* separately.
|
||||||
|
*/
|
||||||
|
export interface BuiltApiRequest {
|
||||||
|
url: string;
|
||||||
|
body?: string;
|
||||||
|
contentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildApiRequest(
|
||||||
|
path: string,
|
||||||
|
method: string,
|
||||||
|
input: Record<string, unknown> | undefined,
|
||||||
|
): BuiltApiRequest {
|
||||||
|
const verb = String(method || "GET").toUpperCase();
|
||||||
|
const values = input ?? {};
|
||||||
|
|
||||||
|
if (verb === "GET" || verb === "HEAD") {
|
||||||
|
const query: string[] = [];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
// 0 and false are legitimate values and must survive.
|
||||||
|
if (value === undefined || value === null || value === "") continue;
|
||||||
|
query.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url: query.length ? `${path}?${query.join("&")}` : path };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url: path, body: JSON.stringify(values), contentType: "application/json" };
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ export {
|
|||||||
requireRequestContext,
|
requireRequestContext,
|
||||||
} from "./request-context.ts";
|
} from "./request-context.ts";
|
||||||
export { createExecutionContext, executionContextFromHttp } from "./execution-context.ts";
|
export { createExecutionContext, executionContextFromHttp } from "./execution-context.ts";
|
||||||
|
export { buildApiRequest } from "./api-request.ts";
|
||||||
|
export type { BuiltApiRequest } from "./api-request.ts";
|
||||||
export type {
|
export type {
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
ExecutionContextInput,
|
ExecutionContextInput,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { buildApiRequest } from "../src/api-request.ts";
|
||||||
|
import { REACTIVE_RUNTIME } from "../../csr/src/reactive-runtime.ts";
|
||||||
|
|
||||||
|
test("GET builds a query string", () => {
|
||||||
|
expect(buildApiRequest("/api/users", "GET", { name: "Ajay" }).url).toBe("/api/users?name=Ajay");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET omits undefined, null and empty string", () => {
|
||||||
|
const built = buildApiRequest("/api/users", "GET", {
|
||||||
|
name: "Ajay",
|
||||||
|
age: undefined,
|
||||||
|
team: null,
|
||||||
|
note: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(built.url).toBe("/api/users?name=Ajay");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET keeps 0 and false", () => {
|
||||||
|
// A filter of 0 or false is a real value; dropping it silently would be a bug.
|
||||||
|
const built = buildApiRequest("/api/users", "GET", { count: 0, active: false });
|
||||||
|
|
||||||
|
expect(built.url).toContain("count=0");
|
||||||
|
expect(built.url).toContain("active=false");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET has no body", () => {
|
||||||
|
expect(buildApiRequest("/api/users", "GET", { name: "Ajay" }).body).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST sends a JSON body and no query", () => {
|
||||||
|
const built = buildApiRequest("/api/users", "POST", { name: "Ajay" });
|
||||||
|
|
||||||
|
expect(built.url).toBe("/api/users");
|
||||||
|
expect(built.body).toBe(JSON.stringify({ name: "Ajay" }));
|
||||||
|
expect(built.contentType).toBe("application/json");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST with no input sends an empty object", () => {
|
||||||
|
expect(buildApiRequest("/api/users", "POST", undefined).body).toBe("{}");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("values are encoded", () => {
|
||||||
|
expect(buildApiRequest("/api/users", "GET", { name: "a b&c" }).url).toBe(
|
||||||
|
"/api/users?name=a%20b%26c",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the browser runtime and the shared builder agree", () => {
|
||||||
|
// Cheap structural guard: the runtime must apply the same omission rule.
|
||||||
|
// If someone changes one side's rules, this fails.
|
||||||
|
expect(REACTIVE_RUNTIME).toContain('value === ""');
|
||||||
|
expect(REACTIVE_RUNTIME).toContain("application/json");
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import { brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
bridgeRealtime,
|
bridgeRealtime,
|
||||||
|
buildApiRequest,
|
||||||
createContext,
|
createContext,
|
||||||
createCorsPreflightResponse,
|
createCorsPreflightResponse,
|
||||||
createRealtimeRegistry,
|
createRealtimeRegistry,
|
||||||
@@ -1454,7 +1455,12 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callApiFromContext(ctx: Context, path: string, method = "GET"): Promise<unknown> {
|
async function callApiFromContext(
|
||||||
|
ctx: Context,
|
||||||
|
path: string,
|
||||||
|
method = "GET",
|
||||||
|
input?: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
if (!isSafeApiPath(path)) {
|
if (!isSafeApiPath(path)) {
|
||||||
throw new Error("Unsafe framework API path");
|
throw new Error("Unsafe framework API path");
|
||||||
}
|
}
|
||||||
@@ -1464,10 +1470,15 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
throw new Error(`Unsupported framework API method: ${normalizedMethod}`);
|
throw new Error(`Unsupported framework API method: ${normalizedMethod}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiUrl = new URL(path, ctx.req.url);
|
const built = buildApiRequest(path, normalizedMethod, input);
|
||||||
|
const apiUrl = new URL(built.url, ctx.req.url);
|
||||||
|
const headers = new Headers(ctx.req.headers);
|
||||||
|
if (built.contentType) headers.set("content-type", built.contentType);
|
||||||
|
|
||||||
const apiReq = new Request(apiUrl, {
|
const apiReq = new Request(apiUrl, {
|
||||||
method: normalizedMethod,
|
method: normalizedMethod,
|
||||||
headers: ctx.req.headers,
|
headers,
|
||||||
|
...(built.body === undefined ? {} : { body: built.body }),
|
||||||
});
|
});
|
||||||
const apiCtx = createContext(apiReq, apiUrl);
|
const apiCtx = createContext(apiReq, apiUrl);
|
||||||
apiCtx.locals = ctx.locals;
|
apiCtx.locals = ctx.locals;
|
||||||
|
|||||||
Reference in New Issue
Block a user