feat: add rest support
This commit is contained in:
@@ -1,15 +1,25 @@
|
||||
import { RelayError } from "./errors.ts";
|
||||
import type { RouteMethod } from "./route.ts";
|
||||
|
||||
export type RelayAdapter = {
|
||||
send(input: RelayRequestInput): Promise<RelayResponse>;
|
||||
readonly url: string;
|
||||
fetch(input: RelayRESTInput): Promise<unknown>;
|
||||
send(input: RelayProcedureInput): Promise<RelayProcedureResponse>;
|
||||
};
|
||||
|
||||
export type RelayRequestInput = {
|
||||
export type RelayRESTInput = {
|
||||
method: RouteMethod;
|
||||
url: string;
|
||||
query?: string;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
export type RelayProcedureInput = {
|
||||
method: string;
|
||||
params: any;
|
||||
};
|
||||
|
||||
export type RelayResponse =
|
||||
export type RelayProcedureResponse =
|
||||
| {
|
||||
relay: "1.0";
|
||||
result: unknown;
|
||||
|
||||
276
libraries/api.ts
276
libraries/api.ts
@@ -3,22 +3,54 @@ import z from "zod";
|
||||
import { BadRequestError, InternalServerError, NotFoundError, RelayError } from "./errors.ts";
|
||||
import { Procedure } from "./procedure.ts";
|
||||
import { RelayRequest, request } from "./request.ts";
|
||||
import { Route, RouteMethod } from "./route.ts";
|
||||
|
||||
const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||
|
||||
export class RelayApi<TRelays extends (Procedure | Route)[]> {
|
||||
readonly #index = {
|
||||
rest: new Map<string, Route>(),
|
||||
rpc: new Map<string, Procedure>(),
|
||||
};
|
||||
|
||||
export class RelayApi<TProcedures extends Procedure[]> {
|
||||
/**
|
||||
* Route index in the '${method} ${path}' format allowing for quick access to
|
||||
* a specific route.
|
||||
* Route maps funneling registered routes to the specific methods supported by
|
||||
* the relay instance.
|
||||
*/
|
||||
readonly #index = new Map<string, Procedure>();
|
||||
readonly routes: Routes = {
|
||||
POST: [],
|
||||
GET: [],
|
||||
PUT: [],
|
||||
PATCH: [],
|
||||
DELETE: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* List of paths in the '${method} ${path}' format allowing us to quickly throw
|
||||
* errors if a duplicate route path is being added.
|
||||
*/
|
||||
readonly #paths = new Set<string>();
|
||||
|
||||
/**
|
||||
* Instantiate a new Server instance.
|
||||
*
|
||||
* @param routes - Routes to register with the instance.
|
||||
*/
|
||||
constructor({ procedures }: Config<TProcedures>) {
|
||||
for (const procedure of procedures) {
|
||||
this.#index.set(procedure.method, procedure);
|
||||
constructor(relays: TRelays) {
|
||||
const methods: (keyof typeof this.routes)[] = [];
|
||||
for (const relay of relays) {
|
||||
if (relay instanceof Procedure === true) {
|
||||
this.#index.rpc.set(relay.method, relay);
|
||||
}
|
||||
if (relay instanceof Route === true) {
|
||||
this.#validateRoutePath(relay);
|
||||
this.routes[relay.method].push(relay);
|
||||
methods.push(relay.method);
|
||||
this.#index.rest.set(`${relay.method} ${relay.path}`, relay);
|
||||
}
|
||||
}
|
||||
for (const method of methods) {
|
||||
this.routes[method].sort(byStaticPriority);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,14 +64,122 @@ export class RelayApi<TProcedures extends Procedure[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a incoming fetch request.
|
||||
* Handle a incoming REST request.
|
||||
*
|
||||
* @param request - REST request to pass to a route handler.
|
||||
*/
|
||||
async rest(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const matched = this.#resolve(request.method, request.url);
|
||||
if (matched === undefined) {
|
||||
return toRestResponse(
|
||||
new NotFoundError(`Invalid routing path provided for ${request.url}`, {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const { route, params } = matched;
|
||||
|
||||
// ### Context
|
||||
// Context is passed to every route handler and provides a suite of functionality
|
||||
// and request data.
|
||||
|
||||
const context: any[] = [];
|
||||
|
||||
// ### Params
|
||||
// If the route has params we want to coerce the values to the expected types.
|
||||
|
||||
if (route.state.params !== undefined) {
|
||||
const result = await route.state.params.safeParseAsync(params);
|
||||
if (result.success === false) {
|
||||
return toRestResponse(new BadRequestError("Invalid request params", z.prettifyError(result.error)));
|
||||
}
|
||||
context.push(result.data);
|
||||
}
|
||||
|
||||
// ### Query
|
||||
// If the route has a query schema we need to validate and parse the query.
|
||||
|
||||
if (route.state.query !== undefined) {
|
||||
const result = await route.state.query.safeParseAsync(toQuery(url.searchParams) ?? {});
|
||||
if (result.success === false) {
|
||||
return toRestResponse(new BadRequestError("Invalid request query", z.prettifyError(result.error)));
|
||||
}
|
||||
context.push(result.data);
|
||||
}
|
||||
|
||||
// ### Body
|
||||
// If the route has a body schema we need to validate and parse the body.
|
||||
|
||||
if (route.state.body !== undefined) {
|
||||
let body: Record<string, unknown> = {};
|
||||
if (request.headers.get("content-type")?.includes("json")) {
|
||||
body = await request.json();
|
||||
}
|
||||
const result = await route.state.body.safeParseAsync(body);
|
||||
if (result.success === false) {
|
||||
return toRestResponse(new BadRequestError("Invalid request body", z.prettifyError(result.error)));
|
||||
}
|
||||
context.push(result.data);
|
||||
}
|
||||
|
||||
// ### Actions
|
||||
// Run through all assigned actions for the route.
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
if (route.state.actions !== undefined) {
|
||||
for (const entry of route.state.actions) {
|
||||
let action = entry;
|
||||
let input: any;
|
||||
|
||||
if (Array.isArray(entry)) {
|
||||
action = entry[0];
|
||||
input = entry[1](...context);
|
||||
}
|
||||
|
||||
const result = (await action.state.input?.safeParseAsync(input)) ?? { success: true, data: {} };
|
||||
if (result.success === false) {
|
||||
return toRestResponse(new BadRequestError("Invalid action input", z.prettifyError(result.error)));
|
||||
}
|
||||
|
||||
if (action.state.handle === undefined) {
|
||||
return toRestResponse(new InternalServerError(`Action '${action.state.name}' is missing handler.`));
|
||||
}
|
||||
|
||||
const output = await action.state.handle(result.data);
|
||||
if (output instanceof RelayError) {
|
||||
return toRestResponse(output);
|
||||
}
|
||||
|
||||
for (const key in output) {
|
||||
data[key] = output[key];
|
||||
}
|
||||
}
|
||||
context.push(data);
|
||||
}
|
||||
|
||||
// ### Handler
|
||||
// Execute the route handler and apply the result.
|
||||
|
||||
if (route.state.handle === undefined) {
|
||||
return toRestResponse(new InternalServerError(`Path '${route.method} ${route.path}' is missing request handler.`));
|
||||
}
|
||||
return toRestResponse(await route.state.handle(...context).catch((error) => error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a incoming RPC request.
|
||||
*
|
||||
* @param method - Method name being executed.
|
||||
* @param params - Parameters provided with the method request.
|
||||
* @param id - Request id used for response identification.
|
||||
*/
|
||||
async call({ method, params, id }: RelayRequest): Promise<Response> {
|
||||
const procedure = this.#index.get(method);
|
||||
async rpc({ method, params, id }: RelayRequest): Promise<Response> {
|
||||
const procedure = this.#index.rpc.get(method);
|
||||
if (procedure === undefined) {
|
||||
return toResponse(new NotFoundError(`Method '' does not exist`), id);
|
||||
}
|
||||
@@ -108,6 +248,35 @@ export class RelayApi<TProcedures extends Procedure[]> {
|
||||
}
|
||||
return toResponse(await procedure.state.handle(...args).catch((error) => error), id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to resolve a route based on the given method and pathname.
|
||||
*
|
||||
* @param method - HTTP method.
|
||||
* @param url - HTTP request url.
|
||||
*/
|
||||
#resolve(method: string, url: string): ResolvedRoute | undefined {
|
||||
this.#assertMethod(method);
|
||||
for (const route of this.routes[method]) {
|
||||
if (route.match(url) === true) {
|
||||
return { route, params: route.getParsedParams(url) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#validateRoutePath(route: Route): void {
|
||||
const path = `${route.method} ${route.path}`;
|
||||
if (this.#paths.has(path)) {
|
||||
throw new Error(`Router > Path ${path} already exists`);
|
||||
}
|
||||
this.#paths.add(path);
|
||||
}
|
||||
|
||||
#assertMethod(method: string): asserts method is RouteMethod {
|
||||
if (!SUPPORTED_MEHODS.includes(method)) {
|
||||
throw new Error(`Router > Unsupported method '${method}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -116,6 +285,80 @@ export class RelayApi<TProcedures extends Procedure[]> {
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sorting method for routes to ensure that static properties takes precedence
|
||||
* for when a route is matched against incoming requests.
|
||||
*
|
||||
* @param a - Route A
|
||||
* @param b - Route B
|
||||
*/
|
||||
function byStaticPriority(a: Route, b: Route) {
|
||||
const aSegments = a.path.split("/");
|
||||
const bSegments = b.path.split("/");
|
||||
|
||||
const maxLength = Math.max(aSegments.length, bSegments.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const aSegment = aSegments[i] || "";
|
||||
const bSegment = bSegments[i] || "";
|
||||
|
||||
const isADynamic = aSegment.startsWith(":");
|
||||
const isBDynamic = bSegment.startsWith(":");
|
||||
|
||||
if (isADynamic !== isBDynamic) {
|
||||
return isADynamic ? 1 : -1;
|
||||
}
|
||||
|
||||
if (isADynamic === false && aSegment !== bSegment) {
|
||||
return aSegment.localeCompare(bSegment);
|
||||
}
|
||||
}
|
||||
|
||||
return a.path.localeCompare(b.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and return query object from the provided search parameters, or undefined
|
||||
* if the search parameters does not have any entries.
|
||||
*
|
||||
* @param searchParams - Search params to create a query object from.
|
||||
*/
|
||||
function toQuery(searchParams: URLSearchParams): object | undefined {
|
||||
if (searchParams.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a server side request result and returns a fetch Response.
|
||||
*
|
||||
* @param result - Result to send back as a Response.
|
||||
*/
|
||||
function toRestResponse(result: object | RelayError | Response | void): Response {
|
||||
if (result instanceof Response) {
|
||||
return result;
|
||||
}
|
||||
if (result instanceof RelayError) {
|
||||
return new Response(result.message, {
|
||||
status: result.status,
|
||||
});
|
||||
}
|
||||
if (result === undefined) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a server side request result and returns a fetch Response.
|
||||
*
|
||||
@@ -177,6 +420,15 @@ export function toResponse(result: object | RelayError | Response | void, id: st
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type Config<TProcedures extends Procedure[]> = {
|
||||
procedures: TProcedures;
|
||||
type Routes = {
|
||||
POST: Route[];
|
||||
GET: Route[];
|
||||
PUT: Route[];
|
||||
PATCH: Route[];
|
||||
DELETE: Route[];
|
||||
};
|
||||
|
||||
type ResolvedRoute = {
|
||||
route: Route;
|
||||
params: any;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import z, { ZodType } from "zod";
|
||||
|
||||
import type { RelayAdapter } from "./adapter.ts";
|
||||
import { Procedure, type Procedures } from "./procedure.ts";
|
||||
import type { RelayAdapter, RelayRESTInput } from "./adapter.ts";
|
||||
import { Procedure } from "./procedure.ts";
|
||||
import type { Relays } from "./relay.ts";
|
||||
import { Route } from "./route.ts";
|
||||
|
||||
/**
|
||||
* Make a new relay client instance.
|
||||
@@ -9,8 +11,8 @@ import { Procedure, type Procedures } from "./procedure.ts";
|
||||
* @param config - Client configuration.
|
||||
* @param procedures - Map of procedures to make available to the client.
|
||||
*/
|
||||
export function makeRelayClient<TProcedures extends Procedures>(config: RelayClientConfig, procedures: TProcedures): RelayClient<TProcedures> {
|
||||
return mapProcedures(procedures, config.adapter);
|
||||
export function makeRelayClient<TRelays extends Relays>(config: RelayClientConfig, relays: TRelays): RelayClient<TRelays> {
|
||||
return mapRelays(relays, config.adapter);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -19,23 +21,59 @@ export function makeRelayClient<TProcedures extends Procedures>(config: RelayCli
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function mapProcedures<TProcedures extends Procedures>(procedures: TProcedures, adapter: RelayAdapter): RelayClient<TProcedures> {
|
||||
function mapRelays<TRelays extends Relays>(relays: TRelays, adapter: RelayAdapter): RelayClient<TRelays> {
|
||||
const client: any = {};
|
||||
for (const key in procedures) {
|
||||
const entry = procedures[key];
|
||||
if (entry instanceof Procedure) {
|
||||
for (const key in relays) {
|
||||
const relay = relays[key];
|
||||
if (relay instanceof Procedure) {
|
||||
client[key] = async (params: unknown) => {
|
||||
const response = await adapter.send({ method: entry.method, params });
|
||||
const response = await adapter.send({ method: relay.method, params });
|
||||
if ("error" in response) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
if ("result" in response && entry.state.result !== undefined) {
|
||||
return entry.state.result.parseAsync(response.result);
|
||||
if ("result" in response && relay.state.result !== undefined) {
|
||||
return relay.state.result.parseAsync(response.result);
|
||||
}
|
||||
return response.result;
|
||||
};
|
||||
} else if (relay instanceof Route) {
|
||||
client[key] = async (...args: any[]) => {
|
||||
const input: RelayRESTInput = { method: relay.state.method, url: `${adapter.url}${relay.state.path}`, query: "" };
|
||||
|
||||
let index = 0; // argument incrementor
|
||||
|
||||
if (relay.state.params !== undefined) {
|
||||
const params = args[index++] as { [key: string]: string };
|
||||
for (const key in params) {
|
||||
input.url = input.url.replace(`:${key}`, params[key]);
|
||||
}
|
||||
}
|
||||
|
||||
if (relay.state.query !== undefined) {
|
||||
const query = args[index++] as { [key: string]: string };
|
||||
const pieces: string[] = [];
|
||||
for (const key in query) {
|
||||
pieces.push(`${key}=${query[key]}`);
|
||||
}
|
||||
if (pieces.length > 0) {
|
||||
input.query = `?${pieces.join("&")}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (relay.state.body !== undefined) {
|
||||
input.body = JSON.stringify(args[index++]);
|
||||
}
|
||||
|
||||
// ### Fetch
|
||||
|
||||
const data = await adapter.fetch(input);
|
||||
if (relay.state.output !== undefined) {
|
||||
return relay.state.output.parse(data);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
} else {
|
||||
client[key] = mapProcedures(entry, adapter);
|
||||
client[key] = mapRelays(relay, adapter);
|
||||
}
|
||||
}
|
||||
return client;
|
||||
@@ -47,16 +85,20 @@ function mapProcedures<TProcedures extends Procedures>(procedures: TProcedures,
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type RelayClient<TProcedures extends Procedures> = {
|
||||
[TKey in keyof TProcedures]: TProcedures[TKey] extends Procedure<infer TState>
|
||||
export type RelayClient<TRelays extends Relays> = {
|
||||
[TKey in keyof TRelays]: TRelays[TKey] extends Procedure<infer TState>
|
||||
? TState["params"] extends ZodType
|
||||
? (params: z.infer<TState["params"]>) => Promise<TState["result"] extends ZodType ? z.infer<TState["result"]> : void>
|
||||
: () => Promise<TState["result"] extends ZodType ? z.infer<TState["result"]> : void>
|
||||
: TProcedures[TKey] extends Procedures
|
||||
? RelayClient<TProcedures[TKey]>
|
||||
: never;
|
||||
: TRelays[TKey] extends Route
|
||||
? (...args: TRelays[TKey]["args"]) => Promise<RelayRouteResponse<TRelays[TKey]>>
|
||||
: TRelays[TKey] extends Relays
|
||||
? RelayClient<TRelays[TKey]>
|
||||
: never;
|
||||
};
|
||||
|
||||
type RelayRouteResponse<TRoute extends Route> = TRoute["state"]["output"] extends ZodType ? z.infer<TRoute["state"]["output"]> : void;
|
||||
|
||||
export type RelayClientConfig = {
|
||||
adapter: RelayAdapter;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,9 @@ import z, { ZodObject, ZodType } from "zod";
|
||||
import { Action } from "./action.ts";
|
||||
import { RelayError } from "./errors.ts";
|
||||
|
||||
export class Procedure<TState extends State = State> {
|
||||
export class Procedure<const TState extends State = State> {
|
||||
readonly type = "rpc" as const;
|
||||
|
||||
declare readonly args: Args<TState>;
|
||||
|
||||
constructor(readonly state: TState) {}
|
||||
@@ -110,11 +112,11 @@ export class Procedure<TState extends State = State> {
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const procedure: {
|
||||
method<const TMethod extends string>(method: TMethod): Procedure<{ method: TMethod }>;
|
||||
export const rpc: {
|
||||
method<const TMethod extends string>(method: TMethod): Procedure<{ type: "rpc"; method: TMethod }>;
|
||||
} = {
|
||||
method<const TMethod extends string>(method: TMethod): Procedure<{ method: TMethod }> {
|
||||
return new Procedure({ method });
|
||||
method<const TMethod extends string>(method: TMethod): Procedure<{ type: "rpc"; method: TMethod }> {
|
||||
return new Procedure({ type: "rpc", method });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -124,10 +126,6 @@ export const procedure: {
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export type Procedures = {
|
||||
[key: string]: Procedures | Procedure;
|
||||
};
|
||||
|
||||
type State = {
|
||||
method: string;
|
||||
params?: ZodType;
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { makeRelayClient, RelayClient, RelayClientConfig } from "./client.ts";
|
||||
import { Procedure, Procedures } from "./procedure.ts";
|
||||
import { Procedure } from "./procedure.ts";
|
||||
import { Route, RouteMethod } from "./route.ts";
|
||||
|
||||
export class Relay<TProcedures extends Procedures, TProcedureIndex = ProcedureIndex<TProcedures>> {
|
||||
readonly #index = new Map<keyof TProcedureIndex, Procedure>();
|
||||
export class Relay<
|
||||
TRelays extends Relays,
|
||||
TRPCIndex = RPCIndex<TRelays>,
|
||||
TPostIndex = RouteIndex<"POST", TRelays>,
|
||||
TGetIndex = RouteIndex<"GET", TRelays>,
|
||||
TPutIndex = RouteIndex<"PUT", TRelays>,
|
||||
TPatchIndex = RouteIndex<"PATCH", TRelays>,
|
||||
TDeleteIndex = RouteIndex<"DELETE", TRelays>,
|
||||
> {
|
||||
readonly #index = new Map<string, Procedure | Route>();
|
||||
|
||||
declare readonly $inferClient: RelayClient<TProcedures>;
|
||||
declare readonly $inferIndex: TProcedureIndex;
|
||||
declare readonly $inferClient: RelayClient<TRelays>;
|
||||
|
||||
/**
|
||||
* Instantiate a new Relay instance.
|
||||
*
|
||||
* @param procedures - Procedures to register with the instance.
|
||||
*/
|
||||
constructor(readonly procedures: TProcedures) {
|
||||
indexProcedures(procedures, this.#index);
|
||||
constructor(readonly relays: TRelays) {
|
||||
indexRelays(relays, this.#index);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,7 +30,7 @@ export class Relay<TProcedures extends Procedures, TProcedureIndex = ProcedureIn
|
||||
* @param config - Client configuration.
|
||||
*/
|
||||
client(config: RelayClientConfig): this["$inferClient"] {
|
||||
return makeRelayClient(config, this.procedures) as any;
|
||||
return makeRelayClient(config, this.relays) as any;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,30 +38,79 @@ export class Relay<TProcedures extends Procedures, TProcedureIndex = ProcedureIn
|
||||
*
|
||||
* @param method - Method name assigned to the procedure.
|
||||
*/
|
||||
method<TMethod extends keyof TProcedureIndex>(method: TMethod): TProcedureIndex[TMethod] {
|
||||
return this.#index.get(method) as TProcedureIndex[TMethod];
|
||||
method<TMethod extends keyof TRPCIndex>(method: TMethod): TRPCIndex[TMethod] {
|
||||
return this.#index.get(method as string) as TRPCIndex[TMethod];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a registered 'POST' route registered with the relay instance.
|
||||
*
|
||||
* @param path - Route path to retrieve.
|
||||
*/
|
||||
post<TPath extends keyof TPostIndex>(path: TPath): TPostIndex[TPath] {
|
||||
return this.#index.get(`POST ${path as string}`) as TPostIndex[TPath];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a registered 'GET' route registered with the relay instance.
|
||||
*
|
||||
* @param path - Route path to retrieve.
|
||||
*/
|
||||
get<TPath extends keyof TGetIndex>(path: TPath): TGetIndex[TPath] {
|
||||
return this.#index.get(`GET ${path as string}`) as TGetIndex[TPath];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a registered 'PUT' route registered with the relay instance.
|
||||
*
|
||||
* @param path - Route path to retrieve.
|
||||
*/
|
||||
put<TPath extends keyof TPutIndex>(path: TPath): TPutIndex[TPath] {
|
||||
return this.#index.get(`PUT ${path as string}`) as TPutIndex[TPath];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a registered 'PATCH' route registered with the relay instance.
|
||||
*
|
||||
* @param path - Route path to retrieve.
|
||||
*/
|
||||
patch<TPath extends keyof TPatchIndex>(path: TPath): TPatchIndex[TPath] {
|
||||
return this.#index.get(`PATCH ${path as string}`) as TPatchIndex[TPath];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a registered 'DELETE' route registered with the relay instance.
|
||||
*
|
||||
* @param path - Route path to retrieve.
|
||||
*/
|
||||
delete<TPath extends keyof TDeleteIndex>(path: TPath): TDeleteIndex[TPath] {
|
||||
return this.#index.get(`DELETE ${path as string}`) as TDeleteIndex[TPath];
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
| Helpers
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function indexProcedures<TProcedures extends Procedures, TProcedureIndex = ProcedureIndex<TProcedures>, TProcedureKey = keyof TProcedureIndex>(
|
||||
procedures: TProcedures,
|
||||
index: Map<TProcedureKey, Procedure>,
|
||||
) {
|
||||
for (const key in procedures) {
|
||||
if (procedures[key] instanceof Procedure) {
|
||||
const method = procedures[key].method as TProcedureKey;
|
||||
function indexRelays(relays: Relays, index: Map<string, Procedure | Route>) {
|
||||
for (const key in relays) {
|
||||
const relay = relays[key];
|
||||
if (relay instanceof Procedure) {
|
||||
const method = relay.method;
|
||||
if (index.has(method)) {
|
||||
throw new Error(`Relay > Procedure with method '${method}' already exists!`);
|
||||
}
|
||||
index.set(method, procedures[key]);
|
||||
index.set(method, relay);
|
||||
} else if (relay instanceof Route) {
|
||||
const path = `${relay.method} ${relay.path}`;
|
||||
if (index.has(path)) {
|
||||
throw new Error(`Relay > Procedure with path 'path' already exists!`);
|
||||
}
|
||||
index.set(path, relay);
|
||||
} else {
|
||||
indexProcedures(procedures[key], index);
|
||||
indexRelays(relay, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,14 +121,28 @@ function indexProcedures<TProcedures extends Procedures, TProcedureIndex = Proce
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type ProcedureIndex<TProcedures extends Procedures> = MergeUnion<FlattenProcedures<TProcedures>>;
|
||||
export type Relays = {
|
||||
[key: string]: Relays | Procedure | Route;
|
||||
};
|
||||
|
||||
type FlattenProcedures<TProcedures extends Procedures> = {
|
||||
[TKey in keyof TProcedures]: TProcedures[TKey] extends Procedure<infer TState>
|
||||
? Record<TState["method"], TProcedures[TKey]>
|
||||
: TProcedures[TKey] extends Procedures
|
||||
? FlattenProcedures<TProcedures[TKey]>
|
||||
type RPCIndex<TRelays extends Relays> = MergeUnion<FlattenRPCRelays<TRelays>>;
|
||||
|
||||
type RouteIndex<TMethod extends RouteMethod, TRelays extends Relays> = MergeUnion<FlattenRouteRelays<TMethod, TRelays>>;
|
||||
|
||||
type FlattenRPCRelays<TRelays extends Relays> = {
|
||||
[TKey in keyof TRelays]: TRelays[TKey] extends Procedure<infer TState>
|
||||
? Record<TState["method"], TRelays[TKey]>
|
||||
: TRelays[TKey] extends Relays
|
||||
? FlattenRPCRelays<TRelays[TKey]>
|
||||
: never;
|
||||
}[keyof TProcedures];
|
||||
}[keyof TRelays];
|
||||
|
||||
type FlattenRouteRelays<TMethod extends RouteMethod, TRelays extends Relays> = {
|
||||
[TKey in keyof TRelays]: TRelays[TKey] extends { state: { method: TMethod; path: infer TPath extends string } }
|
||||
? Record<TPath, TRelays[TKey]>
|
||||
: TRelays[TKey] extends Relays
|
||||
? FlattenRouteRelays<TMethod, TRelays[TKey]>
|
||||
: never;
|
||||
}[keyof TRelays];
|
||||
|
||||
type MergeUnion<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? { [K in keyof I]: I[K] } : never;
|
||||
|
||||
348
libraries/route.ts
Normal file
348
libraries/route.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import z, { ZodObject, ZodRawShape, ZodType } from "zod";
|
||||
|
||||
import { Action } from "./action.ts";
|
||||
import { RelayError } from "./errors.ts";
|
||||
|
||||
export class Route<const TState extends State = State> {
|
||||
readonly type = "route" as const;
|
||||
|
||||
#pattern?: URLPattern;
|
||||
|
||||
declare readonly args: Args<TState>;
|
||||
declare readonly context: RouteContext<TState>;
|
||||
|
||||
constructor(readonly state: TState) {}
|
||||
|
||||
/**
|
||||
* HTTP Method
|
||||
*/
|
||||
get method(): RouteMethod {
|
||||
return this.state.method;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL pattern of the route.
|
||||
*/
|
||||
get pattern(): URLPattern {
|
||||
if (this.#pattern === undefined) {
|
||||
this.#pattern = new URLPattern({ pathname: this.path });
|
||||
}
|
||||
return this.#pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL path
|
||||
*/
|
||||
get path(): string {
|
||||
return this.state.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the provided URL matches the route pattern.
|
||||
*
|
||||
* @param url - HTTP request.url
|
||||
*/
|
||||
match(url: string): boolean {
|
||||
return this.pattern.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameters from the provided URL based on the route pattern.
|
||||
*
|
||||
* @param url - HTTP request.url
|
||||
*/
|
||||
getParsedParams(url: string): object {
|
||||
const params = this.pattern.exec(url)?.pathname.groups;
|
||||
if (params === undefined) {
|
||||
return {};
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Params allows for custom casting of URL parameters. If a parameter does not
|
||||
* have a corresponding zod schema the default param type is "string".
|
||||
*
|
||||
* @param params - URL params.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .post("/foo/:bar")
|
||||
* .params({
|
||||
* bar: z.number({ coerce: true })
|
||||
* })
|
||||
* .handle(async ({ bar }) => {
|
||||
* console.log(typeof bar); // => number
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
params<TParams extends ZodRawShape>(params: TParams): Route<Omit<TState, "params"> & { params: ZodObject<TParams> }> {
|
||||
return new Route({ ...this.state, params: z.object(params) as any });
|
||||
}
|
||||
|
||||
/**
|
||||
* Search allows for custom casting of URL query parameters. If a parameter does
|
||||
* not have a corresponding zod schema the default param type is "string".
|
||||
*
|
||||
* @param query - URL query arguments.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .post("/foo")
|
||||
* .query({
|
||||
* bar: z.number({ coerce: true })
|
||||
* })
|
||||
* .handle(async ({ bar }) => {
|
||||
* console.log(typeof bar); // => number
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
query<TQuery extends ZodRawShape>(query: TQuery): Route<Omit<TState, "search"> & { query: ZodObject<TQuery> }> {
|
||||
return new Route({ ...this.state, query: z.object(query) as any });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the body this route expects to receive. This is used by all
|
||||
* mutator routes and has no effect when defined on "GET" methods.
|
||||
*
|
||||
* @param body - Body the route expects.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .post("/foo")
|
||||
* .body(
|
||||
* z.object({
|
||||
* bar: z.number()
|
||||
* })
|
||||
* )
|
||||
* .handle(async ({ bar }) => {
|
||||
* console.log(typeof bar); // => number
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
body<TBody extends ZodType>(body: TBody): Route<Omit<TState, "body"> & { body: TBody }> {
|
||||
return new Route({ ...this.state, body });
|
||||
}
|
||||
|
||||
/**
|
||||
* List of route level middleware action to execute before running the
|
||||
* route handler.
|
||||
*
|
||||
* @param actions - Actions to execute on this route.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* const hasFooBar = action
|
||||
* .make("hasFooBar")
|
||||
* .response(z.object({ foobar: z.number() }))
|
||||
* .handle(async () => {
|
||||
* return {
|
||||
* foobar: 1,
|
||||
* };
|
||||
* });
|
||||
*
|
||||
* route
|
||||
* .post("/foo")
|
||||
* .actions([hasFooBar])
|
||||
* .handle(async ({ foobar }) => {
|
||||
* console.log(typeof foobar); // => number
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
actions<TAction extends Action, TActionFn extends ActionFn<TAction, this["state"]>>(
|
||||
actions: (TAction | [TAction, TActionFn])[],
|
||||
): Route<Omit<TState, "actions"> & { actions: TAction[] }> {
|
||||
return new Route({ ...this.state, actions: actions as TAction[] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the response this route produces. This is used by the transform
|
||||
* tools to ensure the client receives parsed data.
|
||||
*
|
||||
* @param response - Response shape of the route.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .post("/foo")
|
||||
* .response(
|
||||
* z.object({
|
||||
* bar: z.number()
|
||||
* })
|
||||
* )
|
||||
* .handle(async () => {
|
||||
* return {
|
||||
* bar: 1
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
response<TResponse extends ZodType>(output: TResponse): Route<Omit<TState, "output"> & { output: TResponse }> {
|
||||
return new Route({ ...this.state, output });
|
||||
}
|
||||
|
||||
/**
|
||||
* Server handler callback method.
|
||||
*
|
||||
* @param handle - Handle function to trigger when the route is executed.
|
||||
*/
|
||||
handle<THandleFn extends HandleFn<this["args"], this["state"]["output"]>>(handle: THandleFn): Route<Omit<TState, "handle"> & { handle: THandleFn }> {
|
||||
return new Route({ ...this.state, handle });
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Factories
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Route factories allowing for easy generation of relay compliant routes.
|
||||
*/
|
||||
export const route: {
|
||||
post<TPath extends string>(path: TPath): Route<{ method: "POST"; path: TPath }>;
|
||||
get<TPath extends string>(path: TPath): Route<{ method: "GET"; path: TPath }>;
|
||||
put<TPath extends string>(path: TPath): Route<{ method: "PUT"; path: TPath }>;
|
||||
patch<TPath extends string>(path: TPath): Route<{ method: "PATCH"; path: TPath }>;
|
||||
delete<TPath extends string>(path: TPath): Route<{ method: "DELETE"; path: TPath }>;
|
||||
} = {
|
||||
/**
|
||||
* Create a new "POST" route for the given path.
|
||||
*
|
||||
* @param path - Path to generate route for.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .post("/foo")
|
||||
* .body(
|
||||
* z.object({ bar: z.string() })
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
post<TPath extends string>(path: TPath) {
|
||||
return new Route({ method: "POST", path });
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new "GET" route for the given path.
|
||||
*
|
||||
* @param path - Path to generate route for.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route.get("/foo");
|
||||
* ```
|
||||
*/
|
||||
get<TPath extends string>(path: TPath) {
|
||||
return new Route({ method: "GET", path });
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new "PUT" route for the given path.
|
||||
*
|
||||
* @param path - Path to generate route for.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .put("/foo")
|
||||
* .body(
|
||||
* z.object({ bar: z.string() })
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
put<TPath extends string>(path: TPath) {
|
||||
return new Route({ method: "PUT", path });
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new "PATCH" route for the given path.
|
||||
*
|
||||
* @param path - Path to generate route for.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .patch("/foo")
|
||||
* .body(
|
||||
* z.object({ bar: z.string() })
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
patch<TPath extends string>(path: TPath) {
|
||||
return new Route({ method: "PATCH", path });
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new "DELETE" route for the given path.
|
||||
*
|
||||
* @param path - Path to generate route for.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route.delete("/foo");
|
||||
* ```
|
||||
*/
|
||||
delete<TPath extends string>(path: TPath) {
|
||||
return new Route({ method: "DELETE", path });
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------
|
||||
| Types
|
||||
|--------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
type State = {
|
||||
method: RouteMethod;
|
||||
path: string;
|
||||
params?: ZodObject;
|
||||
query?: ZodObject;
|
||||
body?: ZodType;
|
||||
actions?: Array<Action>;
|
||||
output?: ZodType;
|
||||
handle?: HandleFn;
|
||||
};
|
||||
|
||||
export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
type ActionFn<TAction extends Action, TState extends State> = (
|
||||
...args: Args<TState>
|
||||
) => TAction["state"]["input"] extends ZodType ? z.infer<TAction["state"]["input"]> : void;
|
||||
|
||||
export type HandleFn<TArgs extends Array<any> = any[], TResponse = any> = (
|
||||
...args: TArgs
|
||||
) => TResponse extends ZodType ? Promise<z.infer<TResponse> | Response | RelayError> : Promise<Response | RelayError | void>;
|
||||
|
||||
type RouteContext<TState extends State = State> = (TState["params"] extends ZodObject ? z.infer<TState["params"]> : object) &
|
||||
(TState["query"] extends ZodObject ? z.infer<TState["query"]> : object) &
|
||||
(TState["body"] extends ZodType ? z.infer<TState["body"]> : object) &
|
||||
(TState["actions"] extends Array<Action> ? UnionToIntersection<MergeAction<TState["actions"]>> : object);
|
||||
|
||||
type Args<TState extends State = State> = [
|
||||
...(TState["params"] extends ZodObject ? [z.infer<TState["params"]>] : []),
|
||||
...(TState["query"] extends ZodObject ? [z.infer<TState["query"]>] : []),
|
||||
...(TState["body"] extends ZodType ? [z.infer<TState["body"]>] : []),
|
||||
...(TState["actions"] extends Array<Action> ? [UnionToIntersection<MergeAction<TState["actions"]>>] : []),
|
||||
];
|
||||
|
||||
type MergeAction<TActions extends Array<Action>> =
|
||||
TActions[number] extends Action<infer TActionState> ? (TActionState["output"] extends ZodObject ? z.infer<TActionState["output"]> : object) : object;
|
||||
|
||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
||||
Reference in New Issue
Block a user