diff --git a/adapters/http.ts b/adapters/http.ts index e9daf1e..f1e9d02 100644 --- a/adapters/http.ts +++ b/adapters/http.ts @@ -1,16 +1,17 @@ -import type { RelayAdapter, RelayRequestInput, RelayResponse } from "../libraries/adapter.ts"; +import type { RelayAdapter, RelayRESTInput } from "../libraries/adapter.ts"; import { RelayError, UnprocessableContentError } from "../libraries/errors.ts"; +import { RelayProcedureInput, RelayProcedureResponse } from "../mod.ts"; export class HttpAdapter implements RelayAdapter { #id: number = 0; constructor(readonly url: string) {} - async send({ method, params }: RelayRequestInput): Promise { + async send({ method, params }: RelayProcedureInput): Promise { const id = this.#id++; const res = await fetch(this.url, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { "x-relay-type": "rpc", "content-type": "application/json" }, body: JSON.stringify({ relay: "1.0", method, params, id }), }); const contentType = res.headers.get("content-type"); @@ -31,4 +32,20 @@ export class HttpAdapter implements RelayAdapter { } return json; } + + async fetch({ method, url, query, body }: RelayRESTInput) { + const res = await fetch(`${url}${query}`, { + method, + headers: { "x-relay-type": "rest", "content-type": "application/json" }, + body, + }); + const data = await res.text(); + if (res.status >= 400) { + throw new Error(data); + } + if (res.headers.get("content-type")?.includes("json")) { + return JSON.parse(data); + } + return data; + } } diff --git a/deno.json b/deno.json index a1dd375..7b006b4 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@valkyr/relay", - "version": "0.3.1", + "version": "0.4.0", "exports": { ".": "./mod.ts", "./http": "./adapters/http.ts" diff --git a/libraries/adapter.ts b/libraries/adapter.ts index f0db0f0..1c606a8 100644 --- a/libraries/adapter.ts +++ b/libraries/adapter.ts @@ -1,15 +1,25 @@ import { RelayError } from "./errors.ts"; +import type { RouteMethod } from "./route.ts"; export type RelayAdapter = { - send(input: RelayRequestInput): Promise; + readonly url: string; + fetch(input: RelayRESTInput): Promise; + send(input: RelayProcedureInput): Promise; }; -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; diff --git a/libraries/api.ts b/libraries/api.ts index 0895f5b..48514ef 100644 --- a/libraries/api.ts +++ b/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 { + readonly #index = { + rest: new Map(), + rpc: new Map(), + }; -export class RelayApi { /** - * 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(); + 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(); /** * Instantiate a new Server instance. * * @param routes - Routes to register with the instance. */ - constructor({ procedures }: Config) { - 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 { } /** - * 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 { + 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 = {}; + 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 = {}; + + 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 { - const procedure = this.#index.get(method); + async rpc({ method, params, id }: RelayRequest): Promise { + 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 { } 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 { |-------------------------------------------------------------------------------- */ +/** + * 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 = {}; + 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 = { - procedures: TProcedures; +type Routes = { + POST: Route[]; + GET: Route[]; + PUT: Route[]; + PATCH: Route[]; + DELETE: Route[]; +}; + +type ResolvedRoute = { + route: Route; + params: any; }; diff --git a/libraries/client.ts b/libraries/client.ts index 0fccce1..3700eb7 100644 --- a/libraries/client.ts +++ b/libraries/client.ts @@ -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(config: RelayClientConfig, procedures: TProcedures): RelayClient { - return mapProcedures(procedures, config.adapter); +export function makeRelayClient(config: RelayClientConfig, relays: TRelays): RelayClient { + return mapRelays(relays, config.adapter); } /* @@ -19,23 +21,59 @@ export function makeRelayClient(config: RelayCli |-------------------------------------------------------------------------------- */ -function mapProcedures(procedures: TProcedures, adapter: RelayAdapter): RelayClient { +function mapRelays(relays: TRelays, adapter: RelayAdapter): RelayClient { 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(procedures: TProcedures, |-------------------------------------------------------------------------------- */ -export type RelayClient = { - [TKey in keyof TProcedures]: TProcedures[TKey] extends Procedure +export type RelayClient = { + [TKey in keyof TRelays]: TRelays[TKey] extends Procedure ? TState["params"] extends ZodType ? (params: z.infer) => Promise : void> : () => Promise : void> - : TProcedures[TKey] extends Procedures - ? RelayClient - : never; + : TRelays[TKey] extends Route + ? (...args: TRelays[TKey]["args"]) => Promise> + : TRelays[TKey] extends Relays + ? RelayClient + : never; }; +type RelayRouteResponse = TRoute["state"]["output"] extends ZodType ? z.infer : void; + export type RelayClientConfig = { adapter: RelayAdapter; }; diff --git a/libraries/procedure.ts b/libraries/procedure.ts index 9763a50..268fd36 100644 --- a/libraries/procedure.ts +++ b/libraries/procedure.ts @@ -3,7 +3,9 @@ import z, { ZodObject, ZodType } from "zod"; import { Action } from "./action.ts"; import { RelayError } from "./errors.ts"; -export class Procedure { +export class Procedure { + readonly type = "rpc" as const; + declare readonly args: Args; constructor(readonly state: TState) {} @@ -110,11 +112,11 @@ export class Procedure { |-------------------------------------------------------------------------------- */ -export const procedure: { - method(method: TMethod): Procedure<{ method: TMethod }>; +export const rpc: { + method(method: TMethod): Procedure<{ type: "rpc"; method: TMethod }>; } = { - method(method: TMethod): Procedure<{ method: TMethod }> { - return new Procedure({ method }); + method(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; diff --git a/libraries/relay.ts b/libraries/relay.ts index a2650a6..d94bb95 100644 --- a/libraries/relay.ts +++ b/libraries/relay.ts @@ -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> { - readonly #index = new Map(); +export class Relay< + TRelays extends Relays, + TRPCIndex = RPCIndex, + TPostIndex = RouteIndex<"POST", TRelays>, + TGetIndex = RouteIndex<"GET", TRelays>, + TPutIndex = RouteIndex<"PUT", TRelays>, + TPatchIndex = RouteIndex<"PATCH", TRelays>, + TDeleteIndex = RouteIndex<"DELETE", TRelays>, +> { + readonly #index = new Map(); - declare readonly $inferClient: RelayClient; - declare readonly $inferIndex: TProcedureIndex; + declare readonly $inferClient: RelayClient; /** * 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(method: TMethod): TProcedureIndex[TMethod] { - return this.#index.get(method) as TProcedureIndex[TMethod]; + method(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(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(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(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(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(path: TPath): TDeleteIndex[TPath] { + return this.#index.get(`DELETE ${path as string}`) as TDeleteIndex[TPath]; } } /* |-------------------------------------------------------------------------------- - | Types + | Helpers |-------------------------------------------------------------------------------- */ -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; +function indexRelays(relays: Relays, index: Map) { + 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 = MergeUnion>; +export type Relays = { + [key: string]: Relays | Procedure | Route; +}; -type FlattenProcedures = { - [TKey in keyof TProcedures]: TProcedures[TKey] extends Procedure - ? Record - : TProcedures[TKey] extends Procedures - ? FlattenProcedures +type RPCIndex = MergeUnion>; + +type RouteIndex = MergeUnion>; + +type FlattenRPCRelays = { + [TKey in keyof TRelays]: TRelays[TKey] extends Procedure + ? Record + : TRelays[TKey] extends Relays + ? FlattenRPCRelays : never; -}[keyof TProcedures]; +}[keyof TRelays]; + +type FlattenRouteRelays = { + [TKey in keyof TRelays]: TRelays[TKey] extends { state: { method: TMethod; path: infer TPath extends string } } + ? Record + : TRelays[TKey] extends Relays + ? FlattenRouteRelays + : never; +}[keyof TRelays]; 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 new file mode 100644 index 0000000..d307cc5 --- /dev/null +++ b/libraries/route.ts @@ -0,0 +1,348 @@ +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. + * + * @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 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; diff --git a/tests/mocks/relay.ts b/tests/mocks/relay.ts index 94de2a6..dfef1c4 100644 --- a/tests/mocks/relay.ts +++ b/tests/mocks/relay.ts @@ -1,31 +1,59 @@ import z from "zod"; -import { procedure } from "../../libraries/procedure.ts"; +import { rpc } 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({ - 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())), + rpc: { + user: { + create: rpc + .method("user:create") + .params(UserSchema.omit({ id: true, createdAt: true })) + .result(z.string()), + get: rpc.method("user:get").params(z.string().check(z.uuid())).result(UserSchema), + update: rpc.method("user:update").params( + z.tuple([ + z.string(), + z.object({ + name: z.string().optional(), + email: z.string().check(z.email()).optional(), + }), + ]), + ), + delete: rpc.method("user:delete").params(z.string().check(z.uuid())), + }, + numbers: { + add: rpc + .method("number:add") + .params(z.tuple([z.number(), z.number()])) + .result(z.number()), + }, }, - numbers: { - add: procedure - .method("number:add") - .params(z.tuple([z.number(), z.number()])) - .result(z.number()), + rest: { + user: { + create: route + .post("/users") + .body(UserSchema.omit({ id: true, createdAt: true })) + .response(z.string()), + get: route.get("/users/:userId").params({ userId: z.string() }).response(UserSchema), + update: route + .put("/users/:userId") + .params({ userId: z.string() }) + .body( + z.object({ + name: z.string().optional(), + email: z.string().check(z.email()).optional(), + }), + ), + delete: route.delete("/users/:userId").params({ userId: z.string().check(z.uuid()) }), + }, + numbers: { + add: route + .post("/numbers/add") + .body(z.tuple([z.number(), z.number()])) + .response(z.number()), + }, }, }); diff --git a/tests/mocks/server.ts b/tests/mocks/server.ts index c14c0f9..cbee7c3 100644 --- a/tests/mocks/server.ts +++ b/tests/mocks/server.ts @@ -6,37 +6,65 @@ import { User } from "./user.ts"; export let users: User[] = []; -export const api = new RelayApi({ - procedures: [ - relay.method("user:create").handle(async ({ name, email }) => { - const id = crypto.randomUUID(); - users.push({ id, name, email, createdAt: new Date() }); - return id; - }), - relay.method("user:get").handle(async (userId) => { - const user = users.find((user) => user.id === userId); - if (user === undefined) { - return new NotFoundError(); +export const api = new RelayApi([ + relay.method("user:create").handle(async ({ name, email }) => { + const id = crypto.randomUUID(); + users.push({ id, name, email, createdAt: new Date() }); + return id; + }), + relay.method("user:get").handle(async (userId) => { + const user = users.find((user) => user.id === userId); + if (user === undefined) { + return new NotFoundError(); + } + return user; + }), + relay.method("user:update").handle(async ([userId, { name, email }]) => { + for (const user of users) { + if (user.id === userId) { + user.name = name ?? user.name; + user.email = email ?? user.email; + break; } - return user; + } + }), + relay.method("user:delete").handle(async (userId) => { + users = users.filter((user) => user.id !== userId); + }), + relay + .method("number:add") + .actions([[addNumbers, (params) => params]]) + .handle(async (_, { sum }) => { + return sum; }), - relay.method("user:update").handle(async ([userId, { name, email }]) => { - for (const user of users) { - if (user.id === userId) { - user.name = name ?? user.name; - user.email = email ?? user.email; - break; - } + relay.post("/users").handle(async ({ name, email }) => { + const id = crypto.randomUUID(); + users.push({ id, name, email, createdAt: new Date() }); + return id; + }), + relay.get("/users/:userId").handle(async ({ userId }) => { + const user = users.find((user) => user.id === userId); + if (user === undefined) { + return new NotFoundError(); + } + return user; + }), + relay.put("/users/:userId").handle(async ({ userId }, { name, email }) => { + for (const user of users) { + if (user.id === userId) { + user.name = name ?? user.name; + user.email = email ?? user.email; + break; } + } + }), + relay.delete("/users/:userId").handle(async ({ userId }) => { + users = users.filter((user) => user.id !== userId); + }), + relay + .post("/numbers/add") + .actions([[addNumbers, (body) => body]]) + .handle(async (_, { sum }) => { + return sum; }), - relay.method("user:delete").handle(async (userId) => { - users = users.filter((user) => user.id !== userId); - }), - relay - .method("number:add") - .actions([[addNumbers, (params) => params]]) - .handle(async (_, { sum }) => { - return sum; - }), - ], -}); +]); diff --git a/tests/procedure.test.ts b/tests/procedure.test.ts index af0dfdb..8338398 100644 --- a/tests/procedure.test.ts +++ b/tests/procedure.test.ts @@ -19,7 +19,15 @@ describe("Procedure", () => { }, }, async (request) => { - return api.call(await api.parse(request)); + switch (request.headers.get("x-relay-type")) { + case "rest": { + return api.rest(request); + } + case "rpc": { + return api.rpc(await api.parse(request)); + } + } + return new Response(null, { status: 404 }); }, ); client = relay.client({ @@ -31,34 +39,69 @@ describe("Procedure", () => { await server.shutdown(); }); - it("should successfully relay users", async () => { - const userId = await client.user.create({ name: "John Doe", email: "john.doe@fixture.none" }); + describe("RPC", () => { + it("should successfully relay users", async () => { + const userId = await client.rpc.user.create({ name: "John Doe", email: "john.doe@fixture.none" }); - assertEquals(typeof userId, "string"); - assertEquals(users.length, 1); + assertEquals(typeof userId, "string"); + assertEquals(users.length, 1); - const user = await client.user.get(userId); + const user = await client.rpc.user.get(userId); - assertEquals(user.createdAt instanceof Date, true); + assertEquals(user.createdAt instanceof Date, true); - await client.user.update([userId, { name: "Jane Doe", email: "jane.doe@fixture.none" }]); + await client.rpc.user.update([userId, { name: "Jane Doe", email: "jane.doe@fixture.none" }]); - assertEquals(users.length, 1); - assertObjectMatch(users[0], { - name: "Jane Doe", - email: "jane.doe@fixture.none", + assertEquals(users.length, 1); + assertObjectMatch(users[0], { + name: "Jane Doe", + email: "jane.doe@fixture.none", + }); + + await client.rpc.user.delete(userId); + + assertEquals(users.length, 0); }); - await client.user.delete(userId); + it("should successfully run .actions", async () => { + assertEquals(await client.rpc.numbers.add([1, 1]), 2); + }); - assertEquals(users.length, 0); + it("should reject .actions with error", async () => { + await assertRejects(() => client.rpc.numbers.add([-1, 1]), "Invalid input numbers added"); + }); }); - it("should successfully run .actions", async () => { - assertEquals(await client.numbers.add([1, 1]), 2); - }); + describe("REST", () => { + it("should successfully relay users", async () => { + const userId = await client.rest.user.create({ name: "John Doe", email: "john.doe@fixture.none" }); - it("should reject .actions with error", async () => { - await assertRejects(() => client.numbers.add([-1, 1]), "Invalid input numbers added"); + assertEquals(typeof userId, "string"); + assertEquals(users.length, 1); + + const user = await client.rest.user.get({ userId }); + + assertEquals(user.createdAt instanceof Date, true); + + await client.rest.user.update({ userId }, { name: "Jane Doe", email: "jane.doe@fixture.none" }); + + assertEquals(users.length, 1); + assertObjectMatch(users[0], { + name: "Jane Doe", + email: "jane.doe@fixture.none", + }); + + await client.rest.user.delete({ userId }); + + assertEquals(users.length, 0); + }); + + it("should successfully run .actions", async () => { + assertEquals(await client.rest.numbers.add([1, 1]), 2); + }); + + it("should reject .actions with error", async () => { + await assertRejects(() => client.rest.numbers.add([-1, 1]), "Invalid input numbers added"); + }); }); });