import z, { ZodObject, ZodRawShape, ZodType } from "zod"; import { Action } from "./action.ts"; import { RelayError } from "./errors.ts"; export class Route { readonly type = "route" as const; #pattern?: URLPattern; declare readonly args: Args; declare readonly context: RouteContext; 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(params: TParams): Route & { params: ZodObject }> { 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(query: TQuery): Route & { query: ZodObject }> { 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(body: TBody): Route & { 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>( actions: (TAction | [TAction, TActionFn])[], ): Route & { 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(output: TResponse): Route & { output: TResponse }> { return new Route({ ...this.state, output }); } /** * Server handler callback method. * * Handler receives the params, query, body, actions in order of definition. * So if your route has params, and body the route handle method will * receive (params, body) as arguments. * * @param handle - Handle function to trigger when the route is executed. * * @examples * * ```ts * relay * .post("/foo/:bar") * .params({ bar: z.string() }) * .body(z.tuple([z.string(), z.number()])) * .handle(async ({ bar }, [ "string", number ]) => {}); * ``` * * ```ts * const prefix = actions * .make("prefix") * .input(z.string()) * .output({ prefixed: z.string() }) * .handle(async (value) => ({ * prefixed: `prefix_${value}`; * })) * * relay * .post("/foo") * .body(z.object({ bar: z.string() })) * .actions([prefix, (body) => body.bar]) * .handle(async ({ bar }, { prefixed }) => { * console.log(prefixed); => prefixed_${bar} * }); * ``` */ handle>(handle: THandleFn): Route & { handle: THandleFn }> { return new Route({ ...this.state, handle }); } } /* |-------------------------------------------------------------------------------- | Factories |-------------------------------------------------------------------------------- */ /** * Route factories allowing for easy generation of relay compliant routes. */ export const route: { post(path: TPath): Route<{ method: "POST"; path: TPath }>; get(path: TPath): Route<{ method: "GET"; path: TPath }>; put(path: TPath): Route<{ method: "PUT"; path: TPath }>; patch(path: TPath): Route<{ method: "PATCH"; path: TPath }>; delete(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(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(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(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(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(path: TPath) { return new Route({ method: "DELETE", path }); }, }; /* |-------------------------------------------------------------------------------- | Types |-------------------------------------------------------------------------------- */ type State = { method: RouteMethod; path: string; params?: ZodObject; query?: ZodObject; body?: ZodType; actions?: Array; output?: ZodType; handle?: HandleFn; }; export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE"; type ActionFn = ( ...args: Args ) => TAction["state"]["input"] extends ZodType ? z.infer : void; export type HandleFn = any[], TResponse = any> = ( ...args: TArgs ) => TResponse extends ZodType ? Promise | Response | RelayError> : Promise; type RouteContext = (TState["params"] extends ZodObject ? z.infer : object) & (TState["query"] extends ZodObject ? z.infer : object) & (TState["body"] extends ZodType ? z.infer : object) & (TState["actions"] extends Array ? UnionToIntersection> : object); type Args = [ ...(TState["params"] extends ZodObject ? [z.infer] : []), ...(TState["query"] extends ZodObject ? [z.infer] : []), ...(TState["body"] extends ZodType ? [z.infer] : []), ...(TState["actions"] extends Array ? [UnionToIntersection>] : []), ]; type MergeAction> = TActions[number] extends Action ? (TActionState["output"] extends ZodObject ? z.infer : object) : object; type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;