From 3b9a5cb4561bdfc8fe6d28fa19c6ce69572d4a71 Mon Sep 17 00:00:00 2001 From: kodemon Date: Mon, 21 Apr 2025 00:18:46 +0000 Subject: [PATCH] feat: convert to rpc pattern --- adapters/http.ts | 26 +- deno.json | 2 +- libraries/action.ts | 14 +- libraries/adapter.ts | 24 +- libraries/api.ts | 283 +++++------------ libraries/client.ts | 178 +++-------- libraries/errors.ts | 34 ++ libraries/procedure.ts | 153 +++++++++ libraries/relay.ts | 113 ++++--- libraries/route.ts | 341 --------------------- mod.ts | 2 +- tests/mocks/actions.ts | 16 +- tests/mocks/relay.ts | 45 +-- tests/mocks/server.ts | 28 +- tests/{route.test.ts => procedure.test.ts} | 36 ++- 15 files changed, 489 insertions(+), 806 deletions(-) create mode 100644 libraries/procedure.ts delete mode 100644 libraries/route.ts rename tests/{route.test.ts => procedure.test.ts} (52%) diff --git a/adapters/http.ts b/adapters/http.ts index 5a13e87..50d485a 100644 --- a/adapters/http.ts +++ b/adapters/http.ts @@ -1,15 +1,15 @@ -import type { RelayAdapter, RequestInput } from "../libraries/adapter.ts"; +import type { RelayAdapter, RelayRequestInput, RelayResponse } from "../libraries/adapter.ts"; -export const adapter: RelayAdapter = { - async fetch({ method, url, search, body }: RequestInput) { - const res = await fetch(`${url}${search}`, { method, headers: { "content-type": "application/json" }, body }); - const data = await res.text(); - if (res.status >= 400) { - throw new Error(data); +export class HttpAdapter implements RelayAdapter { + #id: number = 0; + + constructor(readonly url: string) {} + + async send({ method, params }: RelayRequestInput): Promise { + const res = await fetch(this.url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ method, params, id: this.#id++ }) }); + if (res.headers.get("content-type")?.includes("application/json") === false) { + throw new Error("Unexpected return type"); } - if (res.headers.get("content-type")?.includes("json")) { - return JSON.parse(data); - } - return data; - }, -}; + return res.json(); + } +} diff --git a/deno.json b/deno.json index 904010c..71adc30 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@valkyr/relay", - "version": "0.2.0", + "version": "0.3.0", "exports": { ".": "./mod.ts", "./http": "./adapters/http.ts" diff --git a/libraries/action.ts b/libraries/action.ts index 848dbec..ef592cf 100644 --- a/libraries/action.ts +++ b/libraries/action.ts @@ -1,4 +1,4 @@ -import z, { ZodObject, ZodRawShape } from "zod"; +import z, { ZodObject, ZodRawShape, ZodType } from "zod"; import type { RelayError } from "./errors.ts"; @@ -10,8 +10,8 @@ export class Action { * * @param input - Schema defining the input requirements of the action. */ - input(input: TInput): Action & { input: ZodObject }> { - return new Action({ ...this.state, input: z.object(input) as any }); + input(input: TInput): Action & { input: TInput }> { + return new Action({ ...this.state, input }); } /** @@ -57,11 +57,15 @@ export const action: { type ActionState = { name: string; - input?: ZodObject; + input?: ZodType; output?: ZodObject; handle?: ActionHandlerFn; }; -type ActionHandlerFn = TInput extends ZodObject +export type ActionPrepareFn = ( + params: z.infer, +) => TAction["state"]["input"] extends ZodType ? z.infer : void; + +type ActionHandlerFn = TInput extends ZodType ? (input: z.infer) => TOutput extends ZodObject ? Promise | RelayError> : Promise : () => TOutput extends ZodObject ? Promise | RelayError> : Promise; diff --git a/libraries/adapter.ts b/libraries/adapter.ts index 2b4d810..c810390 100644 --- a/libraries/adapter.ts +++ b/libraries/adapter.ts @@ -1,12 +1,20 @@ -import type { RouteMethod } from "./route.ts"; - export type RelayAdapter = { - fetch(input: RequestInput): Promise; + send(input: RelayRequestInput): Promise; }; -export type RequestInput = { - method: RouteMethod; - url: string; - search: string; - body?: string; +export type RelayRequestInput = { + method: string; + params: any; }; + +export type RelayResponse = + | { + result: unknown; + id: string; + } + | { + error: { + message: string; + }; + id: string; + }; diff --git a/libraries/api.ts b/libraries/api.ts index fd1cfa5..e8b87f3 100644 --- a/libraries/api.ts +++ b/libraries/api.ts @@ -1,180 +1,102 @@ import z from "zod"; import { BadRequestError, InternalServerError, NotFoundError, RelayError } from "./errors.ts"; -import type { Route, RouteMethod } from "./route.ts"; - -const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; - -export class RelayAPI { - /** - * Route maps funneling registered routes to the specific methods supported by - * the relay instance. - */ - 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(); +import { Procedure } from "./procedure.ts"; +export class RelayApi { /** * Route index in the '${method} ${path}' format allowing for quick access to * a specific route. */ - readonly #index = new Map(); + readonly #index = new Map(); /** * Instantiate a new Server instance. * * @param routes - Routes to register with the instance. */ - constructor({ routes }: Config) { - const methods: (keyof typeof this.routes)[] = []; - for (const route of routes) { - this.#validateRoutePath(route); - this.routes[route.method].push(route); - methods.push(route.method); - this.#index.set(`${route.method} ${route.path}`, route); - } - for (const method of methods) { - this.routes[method].sort(byStaticPriority); + constructor({ procedures }: Config) { + for (const procedure of procedures) { + this.#index.set(procedure.method, procedure); } } /** * Handle a incoming fetch request. * - * @param request - Fetch request to pass to a route handler. + * @param method - Method name being executed. + * @param params - Parameters provided with the method request. + * @param id - Request id used for response identification. */ - async handle(request: Request): Promise { - const url = new URL(request.url); - - const matched = this.#resolve(request.method, request.url); - if (matched === undefined) { - return toResponse( - new NotFoundError(`Invalid routing path provided for ${request.url}`, { - method: request.method, - url: request.url, - }), - ); + async call(method: string, params: unknown, id: string): Promise { + const procedure = this.#index.get(method); + if (procedure === undefined) { + return toResponse(new NotFoundError(`Method '' does not exist`), id); } - const { route, params } = matched; - // ### Context // Context is passed to every route handler and provides a suite of functionality // and request data. - const context: Record = {}; + const args: 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 toResponse(new BadRequestError("Invalid request params", z.prettifyError(result.error))); - } - for (const key in result.data) { - context[key] = (result.data as any)[key]; - } - } - - // ### Query - // If the route has a query schema we need to validate and parse the query. - - if (route.state.search !== undefined) { - const result = await route.state.search.safeParseAsync(toSearch(url.searchParams) ?? {}); - if (result.success === false) { - return toResponse(new BadRequestError("Invalid request query", z.prettifyError(result.error))); - } - for (const key in result.data) { - context[key] = (result.data as any)[key]; - } - } - - // ### Body // If the route has a body schema we need to validate and parse the body. - if (route.state.body !== undefined) { - let body: Record = {}; - if (request.headers.get("content-type")?.includes("json")) { - body = await request.json(); + if (procedure.state.params !== undefined) { + if (params === undefined) { + return toResponse(new BadRequestError("Procedure expected 'params' but got 'undefined'."), id); } - const result = await route.state.body.safeParseAsync(body); + const result = await procedure.state.params.safeParseAsync(params); if (result.success === false) { - return toResponse(new BadRequestError("Invalid request body", z.prettifyError(result.error))); - } - for (const key in result.data) { - context[key] = (result.data as any)[key]; + return toResponse(new BadRequestError("Invalid request body", z.prettifyError(result.error)), id); } + args.push(result.data); } // ### Actions // Run through all assigned actions for the route. - if (route.state.actions !== undefined) { - for (const action of route.state.actions) { - const result = (await action.state.input?.safeParseAsync(context)) ?? { success: true, data: {} }; + const data: Record = {}; + + if (procedure.state.actions !== undefined) { + for (const entry of procedure.state.actions) { + let action = entry; + let input: any; + + if (Array.isArray(entry)) { + action = entry[0]; + input = entry[1](args[0]); + } + + const result = (await action.state.input?.safeParseAsync(input)) ?? { success: true, data: {} }; if (result.success === false) { - return toResponse(new BadRequestError("Invalid action input", z.prettifyError(result.error))); + return toResponse(new BadRequestError("Invalid action input", z.prettifyError(result.error)), id); } + if (action.state.handle === undefined) { - return toResponse(new InternalServerError(`Action '${action.state.name}' is missing handler.`)); + return toResponse(new InternalServerError(`Action '${action.state.name}' is missing handler.`), id); } + const output = await action.state.handle(result.data); if (output instanceof RelayError) { - return toResponse(output); + return toResponse(output, id); } + for (const key in output) { - context[key] = output[key]; + data[key] = output[key]; } } + args.push(data); } // ### Handler // Execute the route handler and apply the result. - if (route.state.handle === undefined) { - return toResponse(new InternalServerError(`Path '${route.method} ${route.path}' is missing request handler.`)); - } - return toResponse(await route.state.handle(context).catch((error) => error)); - } - - /** - * 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}'`); + if (procedure.state.handle === undefined) { + return toResponse(new InternalServerError(`Path '${procedure.method}' is missing request handler.`), id); } + return toResponse(await procedure.state.handle(...args).catch((error) => error), id); } } @@ -184,78 +106,56 @@ export class RelayAPI { |-------------------------------------------------------------------------------- */ -/** - * 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 toSearch(searchParams: URLSearchParams): object | undefined { - if (searchParams.size === 0) { - return undefined; - } - const result: Record = {}; - 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. + * @param id - Request id which can be used to identify the response. */ -function toResponse(result: object | RelayError | Response | void): Response { +function toResponse(result: object | RelayError | Response | void, id: string): Response { + if (result === undefined) { + return new Response( + JSON.stringify({ + result: null, + id, + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + }, + ); + } if (result instanceof Response) { return result; } if (result instanceof RelayError) { - return new Response(result.message, { - status: result.status, - }); + return new Response( + JSON.stringify({ + error: result, + id, + }), + { + status: result.status, + headers: { + "content-type": "application/json", + }, + }, + ); } - if (result === undefined) { - return new Response(null, { status: 204 }); - } - return new Response(JSON.stringify(result), { - status: 200, - headers: { - "content-type": "application/json", + return new Response( + JSON.stringify({ + result, + id, + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, }, - }); + ); } /* @@ -264,19 +164,6 @@ function toResponse(result: object | RelayError | Response | void): Response { |-------------------------------------------------------------------------------- */ -type Config = { - routes: TRoutes; -}; - -type Routes = { - POST: Route[]; - GET: Route[]; - PUT: Route[]; - PATCH: Route[]; - DELETE: Route[]; -}; - -type ResolvedRoute = { - route: Route; - params: any; +type Config = { + procedures: TProcedures; }; diff --git a/libraries/client.ts b/libraries/client.ts index 1ab7d17..0fccce1 100644 --- a/libraries/client.ts +++ b/libraries/client.ts @@ -1,134 +1,44 @@ import z, { ZodType } from "zod"; -import type { RelayAdapter, RequestInput } from "./adapter.ts"; -import type { Route, RouteMethod } from "./route.ts"; +import type { RelayAdapter } from "./adapter.ts"; +import { Procedure, type Procedures } from "./procedure.ts"; -export class RelayClient { - /** - * Route index in the '${method} ${path}' format allowing for quick access to - * a specific route. - */ - readonly #index = new Map(); +/** + * Make a new relay client instance. + * + * @param config - Client configuration. + * @param procedures - Map of procedures to make available to the client. + */ +export function makeRelayClient(config: RelayClientConfig, procedures: TProcedures): RelayClient { + return mapProcedures(procedures, config.adapter); +} - /** - * Instantiate a new Relay instance. - * - * @param config - Relay configuration to apply to the instance. - * @param routes - Routes to register with the instance. - */ - constructor(readonly config: RelayConfig) { - for (const route of config.routes) { - this.#index.set(`${route.method} ${route.path}`, route); +/* + |-------------------------------------------------------------------------------- + | Helpers + |-------------------------------------------------------------------------------- + */ + +function mapProcedures(procedures: TProcedures, adapter: RelayAdapter): RelayClient { + const client: any = {}; + for (const key in procedures) { + const entry = procedures[key]; + if (entry instanceof Procedure) { + client[key] = async (params: unknown) => { + const response = await adapter.send({ method: entry.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); + } + return response.result; + }; + } else { + client[key] = mapProcedures(entry, adapter); } } - - /** - * Send a "POST" request through the relay `fetch` adapter. - * - * @param path - Path to send request to. - * @param args - List of request arguments. - */ - async post< - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(path: TPath, ...args: TRoute["args"]): Promise> { - return this.#send("POST", path, args) as RelayResponse; - } - - /** - * Send a "GET" request through the relay `fetch` adapter. - * - * @param path - Path to send request to. - * @param args - List of request arguments. - */ - async get< - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(path: TPath, ...args: TRoute["args"]): Promise> { - return this.#send("GET", path, args) as RelayResponse; - } - - /** - * Send a "PUT" request through the relay `fetch` adapter. - * - * @param path - Path to send request to. - * @param args - List of request arguments. - */ - async put< - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(path: TPath, ...args: TRoute["args"]): Promise> { - return this.#send("PUT", path, args) as RelayResponse; - } - - /** - * Send a "PATCH" request through the relay `fetch` adapter. - * - * @param path - Path to send request to. - * @param args - List of request arguments. - */ - async patch< - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(path: TPath, ...args: TRoute["args"]): Promise> { - return this.#send("PATCH", path, args) as RelayResponse; - } - - /** - * Send a "DELETE" request through the relay `fetch` adapter. - * - * @param path - Path to send request to. - * @param args - List of request arguments. - */ - async delete< - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(path: TPath, ...args: TRoute["args"]): Promise> { - return this.#send("DELETE", path, args) as RelayResponse; - } - - async #send(method: RouteMethod, url: string, args: any[]) { - const route = this.#index.get(`${method} ${url}`); - if (route === undefined) { - throw new Error(`RelayClient > Failed to send request for '${method} ${url}' route, not found.`); - } - - // ### Input - - const input: RequestInput = { method, url: `${this.config.url}${url}`, search: "" }; - - let index = 0; // argument incrementor - - if (route.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 (route.state.search !== undefined) { - const search = args[index++] as { [key: string]: string }; - const pieces: string[] = []; - for (const key in search) { - pieces.push(`${key}=${search[key]}`); - } - if (pieces.length > 0) { - input.search = `?${pieces.join("&")}`; - } - } - - if (route.state.body !== undefined) { - input.body = JSON.stringify(args[index++]); - } - - // ### Fetch - - const data = await this.config.adapter.fetch(input); - if (route.state.output !== undefined) { - return route.state.output.parse(data); - } - return data; - } + return client; } /* @@ -137,10 +47,16 @@ export class RelayClient { |-------------------------------------------------------------------------------- */ -type RelayResponse = TRoute["state"]["output"] extends ZodType ? z.infer : void; - -type RelayConfig = { - url: string; - adapter: RelayAdapter; - routes: TRoutes; +export type RelayClient = { + [TKey in keyof TProcedures]: TProcedures[TKey] extends Procedure + ? TState["params"] extends ZodType + ? (params: z.infer) => Promise : void> + : () => Promise : void> + : TProcedures[TKey] extends Procedures + ? RelayClient + : never; +}; + +export type RelayClientConfig = { + adapter: RelayAdapter; }; diff --git a/libraries/errors.ts b/libraries/errors.ts index fa8dffb..2003d62 100644 --- a/libraries/errors.ts +++ b/libraries/errors.ts @@ -104,6 +104,23 @@ export class NotFoundError extends RelayError { } } +export class MethodNotAllowedError extends RelayError { + /** + * Instantiate a new MethodNotAllowedError. + * + * The **HTTP 405 Method Not Allowed** response code indicates that the + * request method is known by the server but is not supported by the target resource. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405 + * + * @param message - Optional message to send with the error. Default: "Method Not Allowed". + * @param data - Optional data to send with the error. + */ + constructor(message = "Method Not Allowed", data?: D) { + super(message, 405, data); + } +} + export class NotAcceptableError extends RelayError { /** * Instantiate a new NotAcceptableError. @@ -165,6 +182,23 @@ export class GoneError extends RelayError { } } +export class UnsupportedMediaTypeError extends RelayError { + /** + * Instantiate a new UnsupportedMediaTypeError. + * + * The **HTTP 415 Unsupported Media Type** response code indicates that the + * server refuses to accept the request because the payload format is in an unsupported format. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/415 + * + * @param message - Optional message to send with the error. Default: "Unsupported Media Type". + * @param data - Optional data to send with the error. + */ + constructor(message = "Unsupported Media Type", data?: D) { + super(message, 415, data); + } +} + export class UnprocessableContentError extends RelayError { /** * Instantiate a new UnprocessableContentError. diff --git a/libraries/procedure.ts b/libraries/procedure.ts new file mode 100644 index 0000000..d64e56a --- /dev/null +++ b/libraries/procedure.ts @@ -0,0 +1,153 @@ +import z, { ZodObject, ZodType } from "zod"; + +import { Action } from "./action.ts"; +import { RelayError } from "./errors.ts"; + +export class Procedure { + declare readonly args: Args; + + constructor(readonly state: TState) {} + + get method(): TState["method"] { + return this.state.method; + } + + /** + * 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 + * relay + * .method("user:create") + * .params({ + * bar: z.number() + * }) + * .handle(async ({ bar }) => { + * console.log(typeof bar); // => number + * }); + * ``` + */ + params(params: TParams): Procedure & { params: TParams }> { + return new Procedure({ ...this.state, params }); + } + + /** + * 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, + * }; + * }); + * + * relay + * .method("foo") + * .actions([hasFooBar]) + * .handle(async ({ foobar }) => { + * console.log(typeof foobar); // => number + * }); + * ``` + */ + actions>( + actions: (TAction | [TAction, TActionFn])[], + ): Procedure & { actions: TAction[] }> { + return new Procedure({ ...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 + * relay + * .method("foo") + * .result( + * z.object({ + * bar: z.number() + * }) + * ) + * .handle(async () => { + * return { bar: 1 }; + * }); + * ``` + */ + result(result: TResult): Procedure & { result: TResult }> { + return new Procedure({ ...this.state, result }); + } + + /** + * Server handler callback method. + * + * @param handle - Handle function to trigger when the route is executed. + */ + handle>(handle: THandleFn): Procedure & { handle: THandleFn }> { + return new Procedure({ ...this.state, handle }); + } +} + +/* + |-------------------------------------------------------------------------------- + | Factories + |-------------------------------------------------------------------------------- + */ + +export const procedure: { + method(method: TMethod): Procedure<{ method: TMethod }>; +} = { + method(method: TMethod): Procedure<{ method: TMethod }> { + return new Procedure({ method }); + }, +}; + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type Procedures = { + [key: string]: Procedures | Procedure; +}; + +type State = { + method: string; + params?: ZodType; + actions?: Array; + result?: ZodType; + handle?: HandleFn; +}; + +type ActionFn = TState["params"] extends ZodType + ? (params: z.infer) => TAction["state"]["input"] extends ZodType ? z.infer : void + : () => TAction["state"]["input"] extends ZodType ? z.infer : void; + +type HandleFn = any[], TResponse = any> = ( + ...args: TArgs +) => TResponse extends ZodType ? Promise | Response | RelayError> : Promise; + +type Args = [ + ...(TState["params"] extends ZodType ? [z.infer] : []), + ...(TState["actions"] extends Array ? [UnionToIntersection>] : []), +]; + +type MergeAction> = + TActions[number] extends Action ? (TState["output"] extends ZodObject ? z.infer : object) : object; + +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; diff --git a/libraries/relay.ts b/libraries/relay.ts index b9cac82..7485615 100644 --- a/libraries/relay.ts +++ b/libraries/relay.ts @@ -1,64 +1,77 @@ -import type { Route, RouteMethod } from "./route.ts"; +import { makeRelayClient, RelayClient, RelayClientConfig } from "./client.ts"; +import { Procedure, Procedures } from "./procedure.ts"; -export class Relay { - /** - * Route index in the '${method} ${path}' format allowing for quick access to - * a specific route. - */ - readonly #index = new Map(); +export class Relay> { + readonly #index = new Map(); - declare readonly $inferRoutes: TRoutes; + declare readonly $inferClient: RelayClient; + declare readonly $inferIndex: TProcedureIndex; /** * Instantiate a new Relay instance. * - * @param config - Relay configuration to apply to the instance. - * @param routes - Routes to register with the instance. + * @param procedures - Procedures to register with the instance. */ - constructor(readonly routes: TRoutes) { - for (const route of routes) { - this.#index.set(`${route.method} ${route.path}`, route); - } + constructor(readonly procedures: TProcedures) { + indexProcedures(procedures, this.#index); } /** - * Retrieve a route for the given method/path combination which can be further extended - * for serving incoming third party requests. + * Retrieve a registered procedure registered with the relay instance. * - * @param method - Method the route is registered for. - * @param path - Path the route is registered under. - * - * @examples - * - * ```ts - * const relay = new Relay([ - * route - * .post("/users") - * .body( - * z.object({ - * name: z.object({ family: z.string(), given: z.string() }), - * email: z.string().check(z.email()), - * }) - * ) - * ]); - * - * relay - * .route("POST", "/users") - * .actions([hasSessionUser, hasAccess("users", "create")]) - * .handle(async ({ name, email, sessionUserId }) => { - * // await db.users.insert({ name, email, createdBy: sessionUserId }); - * }) - * ``` + * @param method - Method name assigned to the procedure. */ - route< - TMethod extends RouteMethod, - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(method: TMethod, path: TPath): TRoute { - const route = this.#index.get(`${method} ${path}`); - if (route === undefined) { - throw new Error(`Relay > Route not found at '${method} ${path}' index`); - } - return route as TRoute; + procedure(method: TMethod): TProcedureIndex[TMethod] { + return this.#index.get(method) as TProcedureIndex[TMethod]; + } + + /** + * Create a new relay client instance from the instance procedures. + * + * @param config - Client configuration. + */ + client(config: RelayClientConfig): this["$inferClient"] { + return makeRelayClient(config, this.procedures) as any; } } + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +function indexProcedures, TProcedureKey = keyof TProcedureIndex>( + procedures: TProcedures, + index: Map, +) { + for (const key in procedures) { + if (procedures[key] instanceof Procedure) { + const method = procedures[key].method as TProcedureKey; + if (index.has(method)) { + throw new Error(`Relay > Procedure with method '${method}' already exists!`); + } + index.set(method, procedures[key]); + } else { + indexProcedures(procedures[key], index); + } + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type ProcedureIndex = MergeUnion>; + +type FlattenProcedures = { + [TKey in keyof TProcedures]: TProcedures[TKey] extends Procedure + ? Record + : TProcedures[TKey] extends Procedures + ? FlattenProcedures + : never; +}[keyof TProcedures]; + +type MergeUnion = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? { [K in keyof I]: I[K] } : never; diff --git a/libraries/route.ts b/libraries/route.ts deleted file mode 100644 index 419e68c..0000000 --- a/libraries/route.ts +++ /dev/null @@ -1,341 +0,0 @@ -import z, { ZodObject, ZodRawShape, ZodType } from "zod"; - -import { Action } from "./action.ts"; -import { RelayError } from "./errors.ts"; - -export class Route { - #pattern?: URLPattern; - - declare readonly args: RouteArgs; - declare readonly context: RouteContext; - - constructor(readonly state: TRouteState) {} - - /** - * 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 ({ params: { 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 search parameters. If a parameter does - * not have a corresponding zod schema the default param type is "string". - * - * @param search - URL search arguments. - * - * @examples - * - * ```ts - * route - * .post("/foo") - * .search({ - * bar: z.number({ coerce: true }) - * }) - * .handle(async ({ search: { bar } }) => { - * console.log(typeof bar); // => number - * }); - * ``` - */ - search(search: TSearch): Route & { search: ZodObject }> { - return new Route({ ...this.state, search: z.object(search) 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[]): Route & { actions: TAction[] }> { - return new Route({ ...this.state, actions }); - } - - /** - * 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. - * - * @param handle - Handle function to trigger when the route is executed. - */ - 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 RouteState = { - method: RouteMethod; - path: string; - params?: ZodObject; - search?: ZodObject; - body?: ZodObject; - actions?: Array; - output?: ZodType; - handle?: HandleFn; -}; - -export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE"; - -export type HandleFn = ( - context: TContext, -) => TResponse extends ZodType ? Promise | Response | RelayError> : Promise; - -type RouteContext = (TRouteState["params"] extends ZodObject ? z.infer : object) & - (TRouteState["search"] extends ZodObject ? z.infer : object) & - (TRouteState["body"] extends ZodObject ? z.infer : object) & - (TRouteState["actions"] extends Array ? UnionToIntersection> : object); - -type RouteArgs = [ - ...TupleIfZod, - ...TupleIfZod, - ...TupleIfZod, -]; - -type TupleIfZod = TState extends ZodObject ? [z.infer] : []; - -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; diff --git a/mod.ts b/mod.ts index 2f76b4e..a2c1f61 100644 --- a/mod.ts +++ b/mod.ts @@ -3,5 +3,5 @@ export * from "./libraries/adapter.ts"; export * from "./libraries/api.ts"; export * from "./libraries/client.ts"; export * from "./libraries/errors.ts"; +export * from "./libraries/procedure.ts"; export * from "./libraries/relay.ts"; -export * from "./libraries/route.ts"; diff --git a/tests/mocks/actions.ts b/tests/mocks/actions.ts index 62d1906..698dd2a 100644 --- a/tests/mocks/actions.ts +++ b/tests/mocks/actions.ts @@ -3,15 +3,13 @@ import z from "zod"; import { action } from "../../libraries/action.ts"; import { BadRequestError } from "../../mod.ts"; -export const addTwoNumbers = action - .make("addTwoNumbers") - .input({ a: z.number(), b: z.number() }) - .output({ added: z.number() }) - .handle(async ({ a, b }) => { +export const addNumbers = action + .make("number:add") + .input(z.tuple([z.number(), z.number()])) + .output({ sum: z.number() }) + .handle(async ([a, b]) => { if (a < 0 || b < 0) { - return new BadRequestError("Invalid input numbers added"); + return new BadRequestError("Invalid numbers provided"); } - return { - added: a + b, - }; + return { sum: a + b }; }); diff --git a/tests/mocks/relay.ts b/tests/mocks/relay.ts index d24605f..94de2a6 100644 --- a/tests/mocks/relay.ts +++ b/tests/mocks/relay.ts @@ -1,24 +1,31 @@ import z from "zod"; +import { procedure } from "../../libraries/procedure.ts"; import { Relay } from "../../libraries/relay.ts"; -import { route } from "../../libraries/route.ts"; import { UserSchema } from "./user.ts"; -export const relay = new Relay([ - route - .post("/users") - .body(UserSchema.omit({ id: true, createdAt: true })) - .response(z.string()), - route - .get("/users/:userId") - .params({ userId: z.string().check(z.uuid()) }) - .response(UserSchema), - route - .put("/users/:userId") - .params({ userId: z.string().check(z.uuid()) }) - .body(UserSchema.omit({ id: true, createdAt: true })), - route.delete("/users/:userId").params({ userId: z.string().check(z.uuid()) }), - route.get("/add-two").search({ a: z.coerce.number(), b: z.coerce.number() }).response(z.number()), -]); - -export type RelayRoutes = typeof relay.$inferRoutes; +export const relay = new Relay({ + user: { + create: procedure + .method("user:create") + .params(UserSchema.omit({ id: true, createdAt: true })) + .result(z.string()), + get: procedure.method("user:get").params(z.string().check(z.uuid())).result(UserSchema), + update: procedure.method("user:update").params( + z.tuple([ + z.string(), + z.object({ + name: z.string().optional(), + email: z.string().check(z.email()).optional(), + }), + ]), + ), + delete: procedure.method("user:delete").params(z.string().check(z.uuid())), + }, + numbers: { + add: procedure + .method("number:add") + .params(z.tuple([z.number(), z.number()])) + .result(z.number()), + }, +}); diff --git a/tests/mocks/server.ts b/tests/mocks/server.ts index a5da003..39abe23 100644 --- a/tests/mocks/server.ts +++ b/tests/mocks/server.ts @@ -1,40 +1,42 @@ -import { RelayAPI } from "../../libraries/api.ts"; +import { RelayApi } from "../../libraries/api.ts"; import { NotFoundError } from "../../mod.ts"; -import { addTwoNumbers } from "./actions.ts"; +import { addNumbers } from "./actions.ts"; import { relay } from "./relay.ts"; import { User } from "./user.ts"; export let users: User[] = []; -export const api = new RelayAPI({ - routes: [ - relay.route("POST", "/users").handle(async ({ name, email }) => { +export const api = new RelayApi({ + procedures: [ + relay.procedure("user:create").handle(async ({ name, email }) => { const id = crypto.randomUUID(); users.push({ id, name, email, createdAt: new Date() }); return id; }), - relay.route("GET", "/users/:userId").handle(async ({ userId }) => { + relay.procedure("user:get").handle(async (userId) => { const user = users.find((user) => user.id === userId); if (user === undefined) { return new NotFoundError(); } return user; }), - relay.route("PUT", "/users/:userId").handle(async ({ userId, name, email }) => { + relay.procedure("user:update").handle(async ([userId, { name, email }]) => { for (const user of users) { if (user.id === userId) { - user.name = name; - user.email = email; + user.name = name ?? user.name; + user.email = email ?? user.email; break; } } }), - relay.route("DELETE", "/users/:userId").handle(async ({ userId }) => { + relay.procedure("user:delete").handle(async (userId) => { users = users.filter((user) => user.id !== userId); }), relay - .route("GET", "/add-two") - .actions([addTwoNumbers]) - .handle(async ({ added }) => added), + .procedure("number:add") + .actions([[addNumbers, (params) => params]]) + .handle(async (_, { sum }) => { + return sum; + }), ], }); diff --git a/tests/route.test.ts b/tests/procedure.test.ts similarity index 52% rename from tests/route.test.ts rename to tests/procedure.test.ts index 01fb294..bf9fd8f 100644 --- a/tests/route.test.ts +++ b/tests/procedure.test.ts @@ -1,29 +1,31 @@ -import "./mocks/server.ts"; - import { assertEquals, assertObjectMatch, assertRejects } from "@std/assert"; import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; -import { adapter } from "../adapters/http.ts"; -import { RelayClient } from "../libraries/client.ts"; -import { relay, RelayRoutes } from "./mocks/relay.ts"; +import { HttpAdapter } from "../adapters/http.ts"; +import { relay } from "./mocks/relay.ts"; import { api, users } from "./mocks/server.ts"; -describe("Relay", () => { +describe("Procedure", () => { let server: Deno.HttpServer; - let client: RelayClient; + let client: typeof relay.$inferClient; - beforeAll(() => { + beforeAll(async () => { server = Deno.serve( { - port: 36573, + port: 8080, hostname: "localhost", onListen({ port, hostname }) { console.log(`Listening at http://${hostname}:${port}`); }, }, - async (request) => api.handle(request), + async (request) => { + const { method, params, id } = await request.json(); + return api.call(method, params, id); + }, ); - client = new RelayClient({ url: "http://localhost:36573", adapter, routes: relay.routes }); + client = relay.client({ + adapter: new HttpAdapter("http://localhost:8080"), + }); }); afterAll(async () => { @@ -31,16 +33,16 @@ describe("Relay", () => { }); it("should successfully relay users", async () => { - const userId = await client.post("/users", { name: "John Doe", email: "john.doe@fixture.none" }); + const userId = await client.user.create({ name: "John Doe", email: "john.doe@fixture.none" }); assertEquals(typeof userId, "string"); assertEquals(users.length, 1); - const user = await client.get("/users/:userId", { userId }); + const user = await client.user.get(userId); assertEquals(user.createdAt instanceof Date, true); - await client.put("/users/:userId", { userId }, { name: "Jane Doe", email: "jane.doe@fixture.none" }); + await client.user.update([userId, { name: "Jane Doe", email: "jane.doe@fixture.none" }]); assertEquals(users.length, 1); assertObjectMatch(users[0], { @@ -48,16 +50,16 @@ describe("Relay", () => { email: "jane.doe@fixture.none", }); - await client.delete("/users/:userId", { userId }); + await client.user.delete(userId); assertEquals(users.length, 0); }); it("should successfully run .actions", async () => { - assertEquals(await client.get("/add-two", { a: 1, b: 1 }), 2); + assertEquals(await client.numbers.add([1, 1]), 2); }); it("should reject .actions with error", async () => { - await assertRejects(() => client.get("/add-two", { a: -1, b: 1 }), "Invalid input numbers added"); + await assertRejects(() => client.numbers.add([-1, 1]), "Invalid input numbers added"); }); });