import { type MatchFunction, match } from "path-to-regexp"; import z, { type ZodObject, type ZodRawShape, type ZodType } from "zod"; import type { ServerContext } from "./context.ts"; import { ServerError, type ServerErrorClass } from "./errors.ts"; import type { Hooks } from "./hooks.ts"; export class Route { readonly type = "route" as const; declare readonly $params: TState["params"] extends ZodObject ? z.input : never; declare readonly $query: TState["query"] extends ZodObject ? z.input : never; declare readonly $body: TState["body"] extends ZodType ? z.input : never; declare readonly $response: TState["response"] extends ZodType ? z.output : never; #matchFn?: MatchFunction; /** * Instantiate a new Route instance. * * @param state - Route state. */ constructor(readonly state: TState) {} /** * HTTP Method */ get method(): RouteMethod { return this.state.method; } /** * URL pattern of the route. */ get matchFn(): MatchFunction { if (this.#matchFn === undefined) { this.#matchFn = match(this.path); } return this.#matchFn; } /** * 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.matchFn(url) !== false; } /** * Extract parameters from the provided URL based on the route pattern. * * @param url - HTTP request.url */ getParsedParams : object>( url: string, ): TParams { const result = match(this.path)(url); if (result === false) { return {} as TParams; } return result.params as TParams; } /** * Set the meta data for this route which can be used in e.g. OpenAPI generation * * @param meta - Meta object * * @examples * * ```ts * route.post("/foo").meta({ description: "Super route" }); * ``` */ meta(meta: TRouteMeta): Route & { meta: TRouteMeta }>> { return new Route({ ...this.state, meta }); } /** * Set cryptographic keys used to resolve cryptographic requests. * * @param crypto - Crypto configuration object. * * @examples * * ```ts * route.post("/foo").crypto({ publicKey: "..." }); * ``` */ crypto( crypto: TCrypto, ): Route & { crypto: TCrypto }>> { return new Route({ ...this.state, crypto }); } /** * Access level of the route which acts as the first barrier of entry * to ensure that requests are valid. * * By default on the server the lack of access definition will result * in an error as all routes needs an access definition. * * @param access - Access level of the route. * * @examples * * ```ts * const hasFooBar = action * .make("hasFooBar") * .response(z.object({ foobar: z.number() })) * .handle(async () => { * return { * foobar: 1, * }; * }); * * // ### Public Endpoint * * route * .post("/foo") * .access("public") * .handle(async ({ foobar }) => { * console.log(typeof foobar); // => number * }); * * // ### Require Session * * route * .post("/foo") * .access("session") * .handle(async ({ foobar }) => { * console.log(typeof foobar); // => number * }); * * // ### Require Session & Resource Assignment * * route * .post("/foo") * .access([resource("foo", "create")]) * .handle(async ({ foobar }) => { * console.log(typeof foobar); // => number * }); * ``` */ access(access: TAccess): Route & { access: TAccess }>> { return new Route({ ...this.state, access: access as TAccess }); } /** * 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.coerce.number() * }) * .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 ({ body: { bar } }) => { * console.log(typeof bar); // => number * }); * ``` */ body(body: TBody): Route & { body: TBody }>> { return new Route({ ...this.state, body }); } /** * Instances of the possible error responses this route produces. * * @param errors - Error shapes of the route. * * @examples * * ```ts * route * .post("/foo") * .errors([ * BadRequestError * ]) * .handle(async () => { * return new BadRequestError(); * }); * ``` */ errors( errors: TErrors, ): Route & { errors: TErrors }>> { return new Route({ ...this.state, errors }); } /** * Shape of the success 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( response: TResponse, ): Route & { response: TResponse }>> { return new Route({ ...this.state, response }); } /** * 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("/api/v1/foo/:bar") * .params({ bar: z.string() }) * .body(z.tuple([z.string(), z.number()])) * .handle(async ({ bar }, [ "string", number ]) => {}); * ``` */ handle, TState["response"]>>( handle: THandleFn, ): Route & { handle: THandleFn }> { return new Route({ ...this.state, handle }); } /** * Assign lifetime hooks to a route allowing for custom handling of * events that can occur during a request or response. * * Can be used on both server and client with the appropriate * implementation. * * @param hooks - Hooks to register with the route. */ hooks(hooks: THooks): Route & { hooks: THooks }>> { return new Route({ ...this.state, hooks }); } } /* |-------------------------------------------------------------------------------- | Factories |-------------------------------------------------------------------------------- */ /** * Route factories allowing for easy generation of relay compliant routes. */ export const route: { post( path: TPath, ): Route<{ method: "POST"; path: TPath; content: "json"; errors: [ServerErrorClass] }>; get( path: TPath, ): Route<{ method: "GET"; path: TPath; content: "json"; errors: [ServerErrorClass] }>; put( path: TPath, ): Route<{ method: "PUT"; path: TPath; content: "json"; errors: [ServerErrorClass] }>; patch( path: TPath, ): Route<{ method: "PATCH"; path: TPath; content: "json"; errors: [ServerErrorClass] }>; delete( path: TPath, ): Route<{ method: "DELETE"; path: TPath; content: "json"; errors: [ServerErrorClass] }>; } = { /** * 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, content: "json", errors: [ServerError] }); }, /** * 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, content: "json", errors: [ServerError] }); }, /** * 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, content: "json", errors: [ServerError] }); }, /** * 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, content: "json", errors: [ServerError] }); }, /** * 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, content: "json", errors: [ServerError] }); }, }; /* |-------------------------------------------------------------------------------- | Types |-------------------------------------------------------------------------------- */ export type Routes = { [key: string]: Routes | Route | RouteFn; }; export type RouteFn = (...args: any[]) => any; type RouteState = { method: RouteMethod; path: string; crypto?: { publicKey: string; }; meta?: RouteMeta; access?: RouteAccess; params?: ZodObject; query?: ZodObject; body?: ZodType; errors: ServerErrorClass[]; response?: ZodType; handle?: HandleFn; hooks?: Hooks; }; export type RouteMeta = { openapi?: "internal" | "external"; description?: string; summary?: string; tags?: string[]; } & Record; export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE"; export type RouteAccess = "public" | "session" | ["internal:public", string] | ["internal:session", string]; type HandleFn = any[], TResponse = any> = ( ...args: TArgs ) => TResponse extends ZodType ? Promise | Response | ServerError> : Promise; type ServerArgs = HasInputArgs extends true ? [ (TState["params"] extends ZodObject ? { params: z.output } : unknown) & (TState["query"] extends ZodObject ? { query: z.output } : unknown) & (TState["body"] extends ZodType ? { body: z.output } : unknown), ServerContext, ] : [ServerContext]; type HasInputArgs = TState["params"] extends ZodObject ? true : TState["query"] extends ZodObject ? true : TState["body"] extends ZodType ? true : false; type Prettify = { [K in keyof T]: T[K]; } & {};