196 lines
6.5 KiB
TypeScript
196 lines
6.5 KiB
TypeScript
import type { Context } from "./context.ts";
|
|
|
|
export interface SchemaLike<T> {
|
|
parse(input: unknown): T;
|
|
}
|
|
|
|
export interface OutputSchemaLike<T> {
|
|
readonly __output: T;
|
|
parse(input: unknown): unknown;
|
|
}
|
|
export type InferEndpointSchema<TSchema> =
|
|
TSchema extends OutputSchemaLike<infer TValue> ? TValue : never;
|
|
|
|
export interface EndpointErrorBody {
|
|
code: string;
|
|
message: string;
|
|
details?: unknown;
|
|
}
|
|
|
|
export class EndpointError extends Error {
|
|
constructor(
|
|
readonly status: number,
|
|
readonly code: string,
|
|
message: string,
|
|
readonly details?: unknown,
|
|
) {
|
|
super(message);
|
|
this.name = "EndpointError";
|
|
}
|
|
}
|
|
|
|
export interface EndpointDefinition<I, O> {
|
|
input?: SchemaLike<I> | OutputSchemaLike<I>;
|
|
output?: SchemaLike<O> | OutputSchemaLike<O>;
|
|
auth?: "optional" | "required";
|
|
/** Permission checked through an installed @wrnexus/authz middleware. */
|
|
permission?: string;
|
|
/** Resource supplied to bound authorization policies. */
|
|
resource?: (ctx: Context, input: I) => unknown | Promise<unknown>;
|
|
description?: string;
|
|
tags?: string[];
|
|
handler(input: I, ctx: Context): O | Promise<O>;
|
|
}
|
|
|
|
export interface DefinedEndpoint<I, O> {
|
|
readonly definition: EndpointDefinition<I, O>;
|
|
(ctx: Context, input?: unknown): Promise<Response>;
|
|
}
|
|
|
|
function json(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json; charset=utf-8" },
|
|
});
|
|
}
|
|
|
|
function schemaValue<T>(schema: SchemaLike<T> | OutputSchemaLike<T>, input: unknown): T {
|
|
const parsed = schema.parse(input);
|
|
if (
|
|
parsed &&
|
|
typeof parsed === "object" &&
|
|
"ok" in parsed &&
|
|
"value" in parsed &&
|
|
typeof (parsed as { ok?: unknown }).ok === "boolean"
|
|
) {
|
|
const result = parsed as { ok: boolean; value: T; errors?: unknown };
|
|
if (!result.ok)
|
|
throw new EndpointError(
|
|
400,
|
|
"VALIDATION_ERROR",
|
|
"Endpoint validation failed.",
|
|
result.errors,
|
|
);
|
|
return result.value;
|
|
}
|
|
return parsed as T;
|
|
}
|
|
|
|
/** Define a validated, typed endpoint that can also drive SDK/OpenAPI generation. */
|
|
export function defineEndpoint<
|
|
InputSchema extends OutputSchemaLike<unknown>,
|
|
OutputSchema extends OutputSchemaLike<unknown>,
|
|
>(
|
|
definition: Omit<
|
|
EndpointDefinition<InferEndpointSchema<InputSchema>, InferEndpointSchema<OutputSchema>>,
|
|
"input" | "output"
|
|
> & {
|
|
input: InputSchema;
|
|
output: OutputSchema;
|
|
},
|
|
): DefinedEndpoint<InferEndpointSchema<InputSchema>, InferEndpointSchema<OutputSchema>>;
|
|
export function defineEndpoint<I = unknown, O = unknown>(
|
|
definition: EndpointDefinition<I, O>,
|
|
): DefinedEndpoint<I, O>;
|
|
export function defineEndpoint(
|
|
definition: EndpointDefinition<unknown, unknown>,
|
|
): DefinedEndpoint<unknown, unknown> {
|
|
const endpoint = async (ctx: Context, rawInput?: unknown): Promise<Response> => {
|
|
try {
|
|
if (definition.auth === "required" && !ctx.user) {
|
|
throw new EndpointError(401, "UNAUTHENTICATED", "Authentication is required.");
|
|
}
|
|
// The real HTTP router invokes route handlers as `handler(ctx)` — it never
|
|
// supplies a second argument. Callers that already have a parsed payload
|
|
// (unit tests, internal RPC-style calls) may still pass one explicitly, and
|
|
// that always wins. Otherwise, read the request ourselves: query params for
|
|
// GET/HEAD, JSON body for everything else.
|
|
let input: unknown = rawInput;
|
|
if (definition.input) {
|
|
const resolvedInput =
|
|
rawInput !== undefined
|
|
? rawInput
|
|
: ctx.req.method.toUpperCase() === "GET" || ctx.req.method.toUpperCase() === "HEAD"
|
|
? Object.fromEntries(ctx.url.searchParams)
|
|
: await ctx.req.json().catch(() => ({}));
|
|
input = schemaValue(definition.input, resolvedInput);
|
|
}
|
|
if (definition.permission) {
|
|
const authz = (
|
|
ctx as Context & {
|
|
authz?: {
|
|
decide(
|
|
permission: string,
|
|
resource?: unknown,
|
|
): Promise<{ allowed: boolean; reason?: string }>;
|
|
};
|
|
}
|
|
).authz;
|
|
if (!authz) {
|
|
throw new EndpointError(
|
|
500,
|
|
"AUTHZ_NOT_CONFIGURED",
|
|
"Authorization middleware is not configured.",
|
|
);
|
|
}
|
|
const resource = definition.resource ? await definition.resource(ctx, input) : undefined;
|
|
const decision = await authz.decide(definition.permission, resource);
|
|
if (!decision.allowed) {
|
|
throw new EndpointError(403, "FORBIDDEN", "Permission denied.");
|
|
}
|
|
ctx.resource = resource;
|
|
}
|
|
const rawOutput = await definition.handler(input, ctx);
|
|
const output = definition.output ? schemaValue(definition.output, rawOutput) : rawOutput;
|
|
return output instanceof Response ? output : json({ data: output });
|
|
} catch (error) {
|
|
if (error instanceof EndpointError) {
|
|
return json(
|
|
{ error: { code: error.code, message: error.message, details: error.details } },
|
|
error.status,
|
|
);
|
|
}
|
|
return json(
|
|
{
|
|
error: {
|
|
code: "INTERNAL_ERROR",
|
|
message: "The endpoint failed unexpectedly.",
|
|
} satisfies EndpointErrorBody,
|
|
},
|
|
500,
|
|
);
|
|
}
|
|
};
|
|
return Object.assign(endpoint, { definition });
|
|
}
|
|
|
|
export interface RpcClientOptions {
|
|
baseUrl?: string;
|
|
fetch?: typeof globalThis.fetch;
|
|
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
}
|
|
|
|
/** Create a tiny typed RPC caller for endpoints exposed by a WrNexus app. */
|
|
export function createRpcClient(options: RpcClientOptions = {}) {
|
|
const request = options.fetch ?? globalThis.fetch;
|
|
return async function call<I, O>(path: string, input: I): Promise<O> {
|
|
const headers =
|
|
typeof options.headers === "function" ? await options.headers() : (options.headers ?? {});
|
|
const response = await request(new URL(path, options.baseUrl ?? globalThis.location?.origin), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", ...Object.fromEntries(new Headers(headers)) },
|
|
body: JSON.stringify(input),
|
|
});
|
|
const body = (await response.json()) as { data?: O; error?: EndpointErrorBody };
|
|
if (!response.ok || body.error) {
|
|
throw new EndpointError(
|
|
response.status,
|
|
body.error?.code ?? "RPC_ERROR",
|
|
body.error?.message ?? `RPC request failed with ${response.status}`,
|
|
body.error?.details,
|
|
);
|
|
}
|
|
return body.data as O;
|
|
};
|
|
}
|