feat: convert to rpc pattern
This commit is contained in:
@@ -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 = {
|
export class HttpAdapter implements RelayAdapter {
|
||||||
async fetch({ method, url, search, body }: RequestInput) {
|
#id: number = 0;
|
||||||
const res = await fetch(`${url}${search}`, { method, headers: { "content-type": "application/json" }, body });
|
|
||||||
const data = await res.text();
|
constructor(readonly url: string) {}
|
||||||
if (res.status >= 400) {
|
|
||||||
throw new Error(data);
|
async send({ method, params }: RelayRequestInput): Promise<RelayResponse> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
}
|
}
|
||||||
if (res.headers.get("content-type")?.includes("json")) {
|
|
||||||
return JSON.parse(data);
|
|
||||||
}
|
}
|
||||||
return data;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@valkyr/relay",
|
"name": "@valkyr/relay",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./mod.ts",
|
".": "./mod.ts",
|
||||||
"./http": "./adapters/http.ts"
|
"./http": "./adapters/http.ts"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import z, { ZodObject, ZodRawShape } from "zod";
|
import z, { ZodObject, ZodRawShape, ZodType } from "zod";
|
||||||
|
|
||||||
import type { RelayError } from "./errors.ts";
|
import type { RelayError } from "./errors.ts";
|
||||||
|
|
||||||
@@ -10,8 +10,8 @@ export class Action<TActionState extends ActionState = ActionState> {
|
|||||||
*
|
*
|
||||||
* @param input - Schema defining the input requirements of the action.
|
* @param input - Schema defining the input requirements of the action.
|
||||||
*/
|
*/
|
||||||
input<TInput extends ZodRawShape>(input: TInput): Action<Omit<TActionState, "input"> & { input: ZodObject<TInput> }> {
|
input<TInput extends ZodType>(input: TInput): Action<Omit<TActionState, "input"> & { input: TInput }> {
|
||||||
return new Action({ ...this.state, input: z.object(input) as any });
|
return new Action({ ...this.state, input });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,11 +57,15 @@ export const action: {
|
|||||||
|
|
||||||
type ActionState = {
|
type ActionState = {
|
||||||
name: string;
|
name: string;
|
||||||
input?: ZodObject;
|
input?: ZodType;
|
||||||
output?: ZodObject;
|
output?: ZodObject;
|
||||||
handle?: ActionHandlerFn;
|
handle?: ActionHandlerFn;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActionHandlerFn<TInput = any, TOutput = any> = TInput extends ZodObject
|
export type ActionPrepareFn<TAction extends Action, TParams extends ZodType> = (
|
||||||
|
params: z.infer<TParams>,
|
||||||
|
) => TAction["state"]["input"] extends ZodType ? z.infer<TAction["state"]["input"]> : void;
|
||||||
|
|
||||||
|
type ActionHandlerFn<TInput = any, TOutput = any> = TInput extends ZodType
|
||||||
? (input: z.infer<TInput>) => TOutput extends ZodObject ? Promise<z.infer<TOutput> | RelayError> : Promise<void | RelayError>
|
? (input: z.infer<TInput>) => TOutput extends ZodObject ? Promise<z.infer<TOutput> | RelayError> : Promise<void | RelayError>
|
||||||
: () => TOutput extends ZodObject ? Promise<z.infer<TOutput> | RelayError> : Promise<void | RelayError>;
|
: () => TOutput extends ZodObject ? Promise<z.infer<TOutput> | RelayError> : Promise<void | RelayError>;
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import type { RouteMethod } from "./route.ts";
|
|
||||||
|
|
||||||
export type RelayAdapter = {
|
export type RelayAdapter = {
|
||||||
fetch(input: RequestInput): Promise<unknown>;
|
send(input: RelayRequestInput): Promise<RelayResponse>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RequestInput = {
|
export type RelayRequestInput = {
|
||||||
method: RouteMethod;
|
method: string;
|
||||||
url: string;
|
params: any;
|
||||||
search: string;
|
};
|
||||||
body?: string;
|
|
||||||
|
export type RelayResponse =
|
||||||
|
| {
|
||||||
|
result: unknown;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
error: {
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
id: string;
|
||||||
};
|
};
|
||||||
|
|||||||
285
libraries/api.ts
285
libraries/api.ts
@@ -1,180 +1,102 @@
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
import { BadRequestError, InternalServerError, NotFoundError, RelayError } from "./errors.ts";
|
import { BadRequestError, InternalServerError, NotFoundError, RelayError } from "./errors.ts";
|
||||||
import type { Route, RouteMethod } from "./route.ts";
|
import { Procedure } from "./procedure.ts";
|
||||||
|
|
||||||
const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
||||||
|
|
||||||
export class RelayAPI<TRoutes extends Route[]> {
|
|
||||||
/**
|
|
||||||
* 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<string>();
|
|
||||||
|
|
||||||
|
export class RelayApi<TProcedures extends Procedure[]> {
|
||||||
/**
|
/**
|
||||||
* Route index in the '${method} ${path}' format allowing for quick access to
|
* Route index in the '${method} ${path}' format allowing for quick access to
|
||||||
* a specific route.
|
* a specific route.
|
||||||
*/
|
*/
|
||||||
readonly #index = new Map<string, Route>();
|
readonly #index = new Map<string, Procedure>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instantiate a new Server instance.
|
* Instantiate a new Server instance.
|
||||||
*
|
*
|
||||||
* @param routes - Routes to register with the instance.
|
* @param routes - Routes to register with the instance.
|
||||||
*/
|
*/
|
||||||
constructor({ routes }: Config<TRoutes>) {
|
constructor({ procedures }: Config<TProcedures>) {
|
||||||
const methods: (keyof typeof this.routes)[] = [];
|
for (const procedure of procedures) {
|
||||||
for (const route of routes) {
|
this.#index.set(procedure.method, procedure);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a incoming fetch request.
|
* 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<Response> {
|
async call(method: string, params: unknown, id: string): Promise<Response> {
|
||||||
const url = new URL(request.url);
|
const procedure = this.#index.get(method);
|
||||||
|
if (procedure === undefined) {
|
||||||
const matched = this.#resolve(request.method, request.url);
|
return toResponse(new NotFoundError(`Method '' does not exist`), id);
|
||||||
if (matched === undefined) {
|
|
||||||
return toResponse(
|
|
||||||
new NotFoundError(`Invalid routing path provided for ${request.url}`, {
|
|
||||||
method: request.method,
|
|
||||||
url: request.url,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { route, params } = matched;
|
|
||||||
|
|
||||||
// ### Context
|
// ### Context
|
||||||
// Context is passed to every route handler and provides a suite of functionality
|
// Context is passed to every route handler and provides a suite of functionality
|
||||||
// and request data.
|
// and request data.
|
||||||
|
|
||||||
const context: Record<string, unknown> = {};
|
const args: any[] = [];
|
||||||
|
|
||||||
// ### Params
|
// ### 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 the route has a body schema we need to validate and parse the body.
|
||||||
|
|
||||||
if (route.state.body !== undefined) {
|
if (procedure.state.params !== undefined) {
|
||||||
let body: Record<string, unknown> = {};
|
if (params === undefined) {
|
||||||
if (request.headers.get("content-type")?.includes("json")) {
|
return toResponse(new BadRequestError("Procedure expected 'params' but got 'undefined'."), id);
|
||||||
body = await request.json();
|
|
||||||
}
|
}
|
||||||
const result = await route.state.body.safeParseAsync(body);
|
const result = await procedure.state.params.safeParseAsync(params);
|
||||||
if (result.success === false) {
|
if (result.success === false) {
|
||||||
return toResponse(new BadRequestError("Invalid request body", z.prettifyError(result.error)));
|
return toResponse(new BadRequestError("Invalid request body", z.prettifyError(result.error)), id);
|
||||||
}
|
|
||||||
for (const key in result.data) {
|
|
||||||
context[key] = (result.data as any)[key];
|
|
||||||
}
|
}
|
||||||
|
args.push(result.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ### Actions
|
// ### Actions
|
||||||
// Run through all assigned actions for the route.
|
// Run through all assigned actions for the route.
|
||||||
|
|
||||||
if (route.state.actions !== undefined) {
|
const data: Record<string, unknown> = {};
|
||||||
for (const action of route.state.actions) {
|
|
||||||
const result = (await action.state.input?.safeParseAsync(context)) ?? { success: true, data: {} };
|
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) {
|
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) {
|
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);
|
const output = await action.state.handle(result.data);
|
||||||
if (output instanceof RelayError) {
|
if (output instanceof RelayError) {
|
||||||
return toResponse(output);
|
return toResponse(output, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in output) {
|
for (const key in output) {
|
||||||
context[key] = output[key];
|
data[key] = output[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
args.push(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ### Handler
|
// ### Handler
|
||||||
// Execute the route handler and apply the result.
|
// Execute the route handler and apply the result.
|
||||||
|
|
||||||
if (route.state.handle === undefined) {
|
if (procedure.state.handle === undefined) {
|
||||||
return toResponse(new InternalServerError(`Path '${route.method} ${route.path}' is missing request handler.`));
|
return toResponse(new InternalServerError(`Path '${procedure.method}' is missing request handler.`), id);
|
||||||
}
|
|
||||||
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}'`);
|
|
||||||
}
|
}
|
||||||
|
return toResponse(await procedure.state.handle(...args).catch((error) => error), id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,78 +106,56 @@ export class RelayAPI<TRoutes extends Route[]> {
|
|||||||
|--------------------------------------------------------------------------------
|
|--------------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<string, string> = {};
|
|
||||||
for (const [key, value] of searchParams.entries()) {
|
|
||||||
result[key] = value;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Takes a server side request result and returns a fetch Response.
|
* Takes a server side request result and returns a fetch Response.
|
||||||
*
|
*
|
||||||
* @param result - Result to send back as a 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 instanceof Response) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
if (result instanceof RelayError) {
|
|
||||||
return new Response(result.message, {
|
|
||||||
status: result.status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
return new Response(null, { status: 204 });
|
return new Response(
|
||||||
}
|
JSON.stringify({
|
||||||
return new Response(JSON.stringify(result), {
|
result: null,
|
||||||
|
id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result instanceof Response) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (result instanceof RelayError) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: result,
|
||||||
|
id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: result.status,
|
||||||
|
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<TRoutes extends Route[]> = {
|
type Config<TProcedures extends Procedure[]> = {
|
||||||
routes: TRoutes;
|
procedures: TProcedures;
|
||||||
};
|
|
||||||
|
|
||||||
type Routes = {
|
|
||||||
POST: Route[];
|
|
||||||
GET: Route[];
|
|
||||||
PUT: Route[];
|
|
||||||
PATCH: Route[];
|
|
||||||
DELETE: Route[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ResolvedRoute = {
|
|
||||||
route: Route;
|
|
||||||
params: any;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,134 +1,44 @@
|
|||||||
import z, { ZodType } from "zod";
|
import z, { ZodType } from "zod";
|
||||||
|
|
||||||
import type { RelayAdapter, RequestInput } from "./adapter.ts";
|
import type { RelayAdapter } from "./adapter.ts";
|
||||||
import type { Route, RouteMethod } from "./route.ts";
|
import { Procedure, type Procedures } from "./procedure.ts";
|
||||||
|
|
||||||
export class RelayClient<TRoutes extends Route[]> {
|
|
||||||
/**
|
|
||||||
* Route index in the '${method} ${path}' format allowing for quick access to
|
|
||||||
* a specific route.
|
|
||||||
*/
|
|
||||||
readonly #index = new Map<string, Route>();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instantiate a new Relay instance.
|
* Make a new relay client instance.
|
||||||
*
|
*
|
||||||
* @param config - Relay configuration to apply to the instance.
|
* @param config - Client configuration.
|
||||||
* @param routes - Routes to register with the instance.
|
* @param procedures - Map of procedures to make available to the client.
|
||||||
*/
|
*/
|
||||||
constructor(readonly config: RelayConfig<TRoutes>) {
|
export function makeRelayClient<TProcedures extends Procedures>(config: RelayClientConfig, procedures: TProcedures): RelayClient<TProcedures> {
|
||||||
for (const route of config.routes) {
|
return mapProcedures(procedures, config.adapter);
|
||||||
this.#index.set(`${route.method} ${route.path}`, route);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* Send a "POST" request through the relay `fetch` adapter.
|
|--------------------------------------------------------------------------------
|
||||||
*
|
| Helpers
|
||||||
* @param path - Path to send request to.
|
|--------------------------------------------------------------------------------
|
||||||
* @param args - List of request arguments.
|
|
||||||
*/
|
*/
|
||||||
async post<
|
|
||||||
TPath extends Extract<TRoutes[number], { state: { method: "POST" } }>["state"]["path"],
|
|
||||||
TRoute extends Extract<TRoutes[number], { state: { method: "POST"; path: TPath } }>,
|
|
||||||
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
|
|
||||||
return this.#send("POST", path, args) as RelayResponse<TRoute>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
function mapProcedures<TProcedures extends Procedures>(procedures: TProcedures, adapter: RelayAdapter): RelayClient<TProcedures> {
|
||||||
* Send a "GET" request through the relay `fetch` adapter.
|
const client: any = {};
|
||||||
*
|
for (const key in procedures) {
|
||||||
* @param path - Path to send request to.
|
const entry = procedures[key];
|
||||||
* @param args - List of request arguments.
|
if (entry instanceof Procedure) {
|
||||||
*/
|
client[key] = async (params: unknown) => {
|
||||||
async get<
|
const response = await adapter.send({ method: entry.method, params });
|
||||||
TPath extends Extract<TRoutes[number], { state: { method: "GET" } }>["state"]["path"],
|
if ("error" in response) {
|
||||||
TRoute extends Extract<TRoutes[number], { state: { method: "GET"; path: TPath } }>,
|
throw new Error(response.error.message);
|
||||||
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
|
|
||||||
return this.#send("GET", path, args) as RelayResponse<TRoute>;
|
|
||||||
}
|
}
|
||||||
|
if ("result" in response && entry.state.result !== undefined) {
|
||||||
/**
|
return entry.state.result.parseAsync(response.result);
|
||||||
* 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<TRoutes[number], { state: { method: "PUT" } }>["state"]["path"],
|
|
||||||
TRoute extends Extract<TRoutes[number], { state: { method: "PUT"; path: TPath } }>,
|
|
||||||
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
|
|
||||||
return this.#send("PUT", path, args) as RelayResponse<TRoute>;
|
|
||||||
}
|
}
|
||||||
|
return response.result;
|
||||||
/**
|
};
|
||||||
* Send a "PATCH" request through the relay `fetch` adapter.
|
} else {
|
||||||
*
|
client[key] = mapProcedures(entry, adapter);
|
||||||
* @param path - Path to send request to.
|
|
||||||
* @param args - List of request arguments.
|
|
||||||
*/
|
|
||||||
async patch<
|
|
||||||
TPath extends Extract<TRoutes[number], { state: { method: "PATCH" } }>["state"]["path"],
|
|
||||||
TRoute extends Extract<TRoutes[number], { state: { method: "PATCH"; path: TPath } }>,
|
|
||||||
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
|
|
||||||
return this.#send("PATCH", path, args) as RelayResponse<TRoute>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<TRoutes[number], { state: { method: "DELETE" } }>["state"]["path"],
|
|
||||||
TRoute extends Extract<TRoutes[number], { state: { method: "DELETE"; path: TPath } }>,
|
|
||||||
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
|
|
||||||
return this.#send("DELETE", path, args) as RelayResponse<TRoute>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return client;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -137,10 +47,16 @@ export class RelayClient<TRoutes extends Route[]> {
|
|||||||
|--------------------------------------------------------------------------------
|
|--------------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type RelayResponse<TRoute extends Route> = TRoute["state"]["output"] extends ZodType ? z.infer<TRoute["state"]["output"]> : void;
|
export type RelayClient<TProcedures extends Procedures> = {
|
||||||
|
[TKey in keyof TProcedures]: TProcedures[TKey] extends Procedure<infer TState>
|
||||||
type RelayConfig<TRoutes extends Route[]> = {
|
? TState["params"] extends ZodType
|
||||||
url: string;
|
? (params: z.infer<TState["params"]>) => Promise<TState["result"] extends ZodType ? z.infer<TState["result"]> : void>
|
||||||
adapter: RelayAdapter;
|
: () => Promise<TState["result"] extends ZodType ? z.infer<TState["result"]> : void>
|
||||||
routes: TRoutes;
|
: TProcedures[TKey] extends Procedures
|
||||||
|
? RelayClient<TProcedures[TKey]>
|
||||||
|
: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RelayClientConfig = {
|
||||||
|
adapter: RelayAdapter;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -104,6 +104,23 @@ export class NotFoundError<D = unknown> extends RelayError<D> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MethodNotAllowedError<D = unknown> extends RelayError<D> {
|
||||||
|
/**
|
||||||
|
* 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<D = unknown> extends RelayError<D> {
|
export class NotAcceptableError<D = unknown> extends RelayError<D> {
|
||||||
/**
|
/**
|
||||||
* Instantiate a new NotAcceptableError.
|
* Instantiate a new NotAcceptableError.
|
||||||
@@ -165,6 +182,23 @@ export class GoneError<D = unknown> extends RelayError<D> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UnsupportedMediaTypeError<D = unknown> extends RelayError<D> {
|
||||||
|
/**
|
||||||
|
* 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<D = unknown> extends RelayError<D> {
|
export class UnprocessableContentError<D = unknown> extends RelayError<D> {
|
||||||
/**
|
/**
|
||||||
* Instantiate a new UnprocessableContentError.
|
* Instantiate a new UnprocessableContentError.
|
||||||
|
|||||||
153
libraries/procedure.ts
Normal file
153
libraries/procedure.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import z, { ZodObject, ZodType } from "zod";
|
||||||
|
|
||||||
|
import { Action } from "./action.ts";
|
||||||
|
import { RelayError } from "./errors.ts";
|
||||||
|
|
||||||
|
export class Procedure<TState extends State = State> {
|
||||||
|
declare readonly args: Args<TState>;
|
||||||
|
|
||||||
|
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<TParams extends ZodType>(params: TParams): Procedure<Omit<TState, "params"> & { 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<TAction extends Action, TActionFn extends ActionFn<TAction, this["state"]>>(
|
||||||
|
actions: (TAction | [TAction, TActionFn])[],
|
||||||
|
): Procedure<Omit<TState, "actions"> & { 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<TResult extends ZodType>(result: TResult): Procedure<Omit<TState, "result"> & { result: TResult }> {
|
||||||
|
return new Procedure({ ...this.state, result });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server handler callback method.
|
||||||
|
*
|
||||||
|
* @param handle - Handle function to trigger when the route is executed.
|
||||||
|
*/
|
||||||
|
handle<THandleFn extends HandleFn<this["args"], this["state"]["result"]>>(handle: THandleFn): Procedure<Omit<TState, "handle"> & { handle: THandleFn }> {
|
||||||
|
return new Procedure({ ...this.state, handle });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------------
|
||||||
|
| Factories
|
||||||
|
|--------------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const procedure: {
|
||||||
|
method<const TMethod extends string>(method: TMethod): Procedure<{ method: TMethod }>;
|
||||||
|
} = {
|
||||||
|
method<const TMethod extends string>(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<Action>;
|
||||||
|
result?: ZodType;
|
||||||
|
handle?: HandleFn;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActionFn<TAction extends Action, TState extends State> = TState["params"] extends ZodType
|
||||||
|
? (params: z.infer<TState["params"]>) => TAction["state"]["input"] extends ZodType ? z.infer<TAction["state"]["input"]> : void
|
||||||
|
: () => TAction["state"]["input"] extends ZodType ? z.infer<TAction["state"]["input"]> : void;
|
||||||
|
|
||||||
|
type HandleFn<TArgs extends Array<any> = any[], TResponse = any> = (
|
||||||
|
...args: TArgs
|
||||||
|
) => TResponse extends ZodType ? Promise<z.infer<TResponse> | Response | RelayError> : Promise<Response | RelayError | void>;
|
||||||
|
|
||||||
|
type Args<TState extends State = State> = [
|
||||||
|
...(TState["params"] extends ZodType ? [z.infer<TState["params"]>] : []),
|
||||||
|
...(TState["actions"] extends Array<Action> ? [UnionToIntersection<MergeAction<TState["actions"]>>] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
type MergeAction<TActions extends Array<Action>> =
|
||||||
|
TActions[number] extends Action<infer TState> ? (TState["output"] extends ZodObject ? z.infer<TState["output"]> : object) : object;
|
||||||
|
|
||||||
|
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
||||||
@@ -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<TRoutes extends Route[]> {
|
export class Relay<TProcedures extends Procedures, TProcedureIndex = ProcedureIndex<TProcedures>> {
|
||||||
/**
|
readonly #index = new Map<keyof TProcedureIndex, Procedure>();
|
||||||
* Route index in the '${method} ${path}' format allowing for quick access to
|
|
||||||
* a specific route.
|
|
||||||
*/
|
|
||||||
readonly #index = new Map<string, Route>();
|
|
||||||
|
|
||||||
declare readonly $inferRoutes: TRoutes;
|
declare readonly $inferClient: RelayClient<TProcedures>;
|
||||||
|
declare readonly $inferIndex: TProcedureIndex;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instantiate a new Relay instance.
|
* Instantiate a new Relay instance.
|
||||||
*
|
*
|
||||||
* @param config - Relay configuration to apply to the instance.
|
* @param procedures - Procedures to register with the instance.
|
||||||
* @param routes - Routes to register with the instance.
|
|
||||||
*/
|
*/
|
||||||
constructor(readonly routes: TRoutes) {
|
constructor(readonly procedures: TProcedures) {
|
||||||
for (const route of routes) {
|
indexProcedures(procedures, this.#index);
|
||||||
this.#index.set(`${route.method} ${route.path}`, route);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve a route for the given method/path combination which can be further extended
|
* Retrieve a registered procedure registered with the relay instance.
|
||||||
* for serving incoming third party requests.
|
|
||||||
*
|
*
|
||||||
* @param method - Method the route is registered for.
|
* @param method - Method name assigned to the procedure.
|
||||||
* @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 });
|
|
||||||
* })
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
route<
|
procedure<TMethod extends keyof TProcedureIndex>(method: TMethod): TProcedureIndex[TMethod] {
|
||||||
TMethod extends RouteMethod,
|
return this.#index.get(method) as TProcedureIndex[TMethod];
|
||||||
TPath extends Extract<TRoutes[number], { state: { method: TMethod } }>["state"]["path"],
|
|
||||||
TRoute extends Extract<TRoutes[number], { state: { method: TMethod; path: TPath } }>,
|
|
||||||
>(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;
|
|
||||||
|
/**
|
||||||
|
* 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<TProcedures extends Procedures, TProcedureIndex = ProcedureIndex<TProcedures>, TProcedureKey = keyof TProcedureIndex>(
|
||||||
|
procedures: TProcedures,
|
||||||
|
index: Map<TProcedureKey, Procedure>,
|
||||||
|
) {
|
||||||
|
for (const key in procedures) {
|
||||||
|
if (procedures[key] instanceof Procedure) {
|
||||||
|
const method = procedures[key].method as TProcedureKey;
|
||||||
|
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<TProcedures extends Procedures> = MergeUnion<FlattenProcedures<TProcedures>>;
|
||||||
|
|
||||||
|
type FlattenProcedures<TProcedures extends Procedures> = {
|
||||||
|
[TKey in keyof TProcedures]: TProcedures[TKey] extends Procedure<infer TState>
|
||||||
|
? Record<TState["method"], TProcedures[TKey]>
|
||||||
|
: TProcedures[TKey] extends Procedures
|
||||||
|
? FlattenProcedures<TProcedures[TKey]>
|
||||||
|
: never;
|
||||||
|
}[keyof TProcedures];
|
||||||
|
|
||||||
|
type MergeUnion<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? { [K in keyof I]: I[K] } : never;
|
||||||
|
|||||||
@@ -1,341 +0,0 @@
|
|||||||
import z, { ZodObject, ZodRawShape, ZodType } from "zod";
|
|
||||||
|
|
||||||
import { Action } from "./action.ts";
|
|
||||||
import { RelayError } from "./errors.ts";
|
|
||||||
|
|
||||||
export class Route<TRouteState extends RouteState = RouteState> {
|
|
||||||
#pattern?: URLPattern;
|
|
||||||
|
|
||||||
declare readonly args: RouteArgs<TRouteState>;
|
|
||||||
declare readonly context: RouteContext<TRouteState>;
|
|
||||||
|
|
||||||
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<TParams extends ZodRawShape>(params: TParams): Route<Omit<TRouteState, "params"> & { params: ZodObject<TParams> }> {
|
|
||||||
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<TSearch extends ZodRawShape>(search: TSearch): Route<Omit<TRouteState, "search"> & { search: ZodObject<TSearch> }> {
|
|
||||||
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<TBody extends ZodObject>(body: TBody): Route<Omit<TRouteState, "body"> & { body: TBody }> {
|
|
||||||
return new Route({ ...this.state, body });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List of route level middleware action to execute before running the
|
|
||||||
* route handler.
|
|
||||||
*
|
|
||||||
* @param actions - Actions to execute on this route.
|
|
||||||
*
|
|
||||||
* @examples
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* const hasFooBar = action
|
|
||||||
* .make("hasFooBar")
|
|
||||||
* .response(z.object({ foobar: z.number() }))
|
|
||||||
* .handle(async () => {
|
|
||||||
* return {
|
|
||||||
* foobar: 1,
|
|
||||||
* };
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* route
|
|
||||||
* .post("/foo")
|
|
||||||
* .actions([hasFooBar])
|
|
||||||
* .handle(async ({ foobar }) => {
|
|
||||||
* console.log(typeof foobar); // => number
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
actions<TAction extends Action>(actions: TAction[]): Route<Omit<TRouteState, "actions"> & { 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<TResponse extends ZodType>(output: TResponse): Route<Omit<TRouteState, "output"> & { output: TResponse }> {
|
|
||||||
return new Route({ ...this.state, output });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server handler callback method.
|
|
||||||
*
|
|
||||||
* @param handle - Handle function to trigger when the route is executed.
|
|
||||||
*/
|
|
||||||
handle<THandleFn extends HandleFn<this["context"], this["state"]["output"]>>(handle: THandleFn): Route<Omit<TRouteState, "handle"> & { handle: THandleFn }> {
|
|
||||||
return new Route({ ...this.state, handle });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------------
|
|
||||||
| Factories
|
|
||||||
|--------------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route factories allowing for easy generation of relay compliant routes.
|
|
||||||
*/
|
|
||||||
export const route: {
|
|
||||||
post<TPath extends string>(path: TPath): Route<{ method: "POST"; path: TPath }>;
|
|
||||||
get<TPath extends string>(path: TPath): Route<{ method: "GET"; path: TPath }>;
|
|
||||||
put<TPath extends string>(path: TPath): Route<{ method: "PUT"; path: TPath }>;
|
|
||||||
patch<TPath extends string>(path: TPath): Route<{ method: "PATCH"; path: TPath }>;
|
|
||||||
delete<TPath extends string>(path: TPath): Route<{ method: "DELETE"; path: TPath }>;
|
|
||||||
} = {
|
|
||||||
/**
|
|
||||||
* Create a new "POST" route for the given path.
|
|
||||||
*
|
|
||||||
* @param path - Path to generate route for.
|
|
||||||
*
|
|
||||||
* @examples
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* route
|
|
||||||
* .post("/foo")
|
|
||||||
* .body(
|
|
||||||
* z.object({ bar: z.string() })
|
|
||||||
* );
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
post<TPath extends string>(path: TPath) {
|
|
||||||
return new Route({ method: "POST", path });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new "GET" route for the given path.
|
|
||||||
*
|
|
||||||
* @param path - Path to generate route for.
|
|
||||||
*
|
|
||||||
* @examples
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* route.get("/foo");
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
get<TPath extends string>(path: TPath) {
|
|
||||||
return new Route({ method: "GET", path });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new "PUT" route for the given path.
|
|
||||||
*
|
|
||||||
* @param path - Path to generate route for.
|
|
||||||
*
|
|
||||||
* @examples
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* route
|
|
||||||
* .put("/foo")
|
|
||||||
* .body(
|
|
||||||
* z.object({ bar: z.string() })
|
|
||||||
* );
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
put<TPath extends string>(path: TPath) {
|
|
||||||
return new Route({ method: "PUT", path });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new "PATCH" route for the given path.
|
|
||||||
*
|
|
||||||
* @param path - Path to generate route for.
|
|
||||||
*
|
|
||||||
* @examples
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* route
|
|
||||||
* .patch("/foo")
|
|
||||||
* .body(
|
|
||||||
* z.object({ bar: z.string() })
|
|
||||||
* );
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
patch<TPath extends string>(path: TPath) {
|
|
||||||
return new Route({ method: "PATCH", path });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new "DELETE" route for the given path.
|
|
||||||
*
|
|
||||||
* @param path - Path to generate route for.
|
|
||||||
*
|
|
||||||
* @examples
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* route.delete("/foo");
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
delete<TPath extends string>(path: TPath) {
|
|
||||||
return new Route({ method: "DELETE", path });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------------
|
|
||||||
| Types
|
|
||||||
|--------------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
type RouteState = {
|
|
||||||
method: RouteMethod;
|
|
||||||
path: string;
|
|
||||||
params?: ZodObject;
|
|
||||||
search?: ZodObject;
|
|
||||||
body?: ZodObject;
|
|
||||||
actions?: Array<Action>;
|
|
||||||
output?: ZodType;
|
|
||||||
handle?: HandleFn;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
|
|
||||||
|
|
||||||
export type HandleFn<TContext = any, TResponse = any> = (
|
|
||||||
context: TContext,
|
|
||||||
) => TResponse extends ZodType ? Promise<z.infer<TResponse> | Response | RelayError> : Promise<Response | RelayError | void>;
|
|
||||||
|
|
||||||
type RouteContext<TRouteState extends RouteState = RouteState> = (TRouteState["params"] extends ZodObject ? z.infer<TRouteState["params"]> : object) &
|
|
||||||
(TRouteState["search"] extends ZodObject ? z.infer<TRouteState["search"]> : object) &
|
|
||||||
(TRouteState["body"] extends ZodObject ? z.infer<TRouteState["body"]> : object) &
|
|
||||||
(TRouteState["actions"] extends Array<Action> ? UnionToIntersection<MergeAction<TRouteState["actions"]>> : object);
|
|
||||||
|
|
||||||
type RouteArgs<TRouteState extends RouteState = RouteState> = [
|
|
||||||
...TupleIfZod<TRouteState["params"]>,
|
|
||||||
...TupleIfZod<TRouteState["search"]>,
|
|
||||||
...TupleIfZod<TRouteState["body"]>,
|
|
||||||
];
|
|
||||||
|
|
||||||
type TupleIfZod<TState> = TState extends ZodObject ? [z.infer<TState>] : [];
|
|
||||||
|
|
||||||
type MergeAction<TActions extends Array<Action>> =
|
|
||||||
TActions[number] extends Action<infer TActionState> ? (TActionState["output"] extends ZodObject ? z.infer<TActionState["output"]> : object) : object;
|
|
||||||
|
|
||||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
|
||||||
2
mod.ts
2
mod.ts
@@ -3,5 +3,5 @@ export * from "./libraries/adapter.ts";
|
|||||||
export * from "./libraries/api.ts";
|
export * from "./libraries/api.ts";
|
||||||
export * from "./libraries/client.ts";
|
export * from "./libraries/client.ts";
|
||||||
export * from "./libraries/errors.ts";
|
export * from "./libraries/errors.ts";
|
||||||
|
export * from "./libraries/procedure.ts";
|
||||||
export * from "./libraries/relay.ts";
|
export * from "./libraries/relay.ts";
|
||||||
export * from "./libraries/route.ts";
|
|
||||||
|
|||||||
@@ -3,15 +3,13 @@ import z from "zod";
|
|||||||
import { action } from "../../libraries/action.ts";
|
import { action } from "../../libraries/action.ts";
|
||||||
import { BadRequestError } from "../../mod.ts";
|
import { BadRequestError } from "../../mod.ts";
|
||||||
|
|
||||||
export const addTwoNumbers = action
|
export const addNumbers = action
|
||||||
.make("addTwoNumbers")
|
.make("number:add")
|
||||||
.input({ a: z.number(), b: z.number() })
|
.input(z.tuple([z.number(), z.number()]))
|
||||||
.output({ added: z.number() })
|
.output({ sum: z.number() })
|
||||||
.handle(async ({ a, b }) => {
|
.handle(async ([a, b]) => {
|
||||||
if (a < 0 || b < 0) {
|
if (a < 0 || b < 0) {
|
||||||
return new BadRequestError("Invalid input numbers added");
|
return new BadRequestError("Invalid numbers provided");
|
||||||
}
|
}
|
||||||
return {
|
return { sum: a + b };
|
||||||
added: a + b,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
|
import { procedure } from "../../libraries/procedure.ts";
|
||||||
import { Relay } from "../../libraries/relay.ts";
|
import { Relay } from "../../libraries/relay.ts";
|
||||||
import { route } from "../../libraries/route.ts";
|
|
||||||
import { UserSchema } from "./user.ts";
|
import { UserSchema } from "./user.ts";
|
||||||
|
|
||||||
export const relay = new Relay([
|
export const relay = new Relay({
|
||||||
route
|
user: {
|
||||||
.post("/users")
|
create: procedure
|
||||||
.body(UserSchema.omit({ id: true, createdAt: true }))
|
.method("user:create")
|
||||||
.response(z.string()),
|
.params(UserSchema.omit({ id: true, createdAt: true }))
|
||||||
route
|
.result(z.string()),
|
||||||
.get("/users/:userId")
|
get: procedure.method("user:get").params(z.string().check(z.uuid())).result(UserSchema),
|
||||||
.params({ userId: z.string().check(z.uuid()) })
|
update: procedure.method("user:update").params(
|
||||||
.response(UserSchema),
|
z.tuple([
|
||||||
route
|
z.string(),
|
||||||
.put("/users/:userId")
|
z.object({
|
||||||
.params({ userId: z.string().check(z.uuid()) })
|
name: z.string().optional(),
|
||||||
.body(UserSchema.omit({ id: true, createdAt: true })),
|
email: z.string().check(z.email()).optional(),
|
||||||
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()),
|
]),
|
||||||
]);
|
),
|
||||||
|
delete: procedure.method("user:delete").params(z.string().check(z.uuid())),
|
||||||
export type RelayRoutes = typeof relay.$inferRoutes;
|
},
|
||||||
|
numbers: {
|
||||||
|
add: procedure
|
||||||
|
.method("number:add")
|
||||||
|
.params(z.tuple([z.number(), z.number()]))
|
||||||
|
.result(z.number()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,40 +1,42 @@
|
|||||||
import { RelayAPI } from "../../libraries/api.ts";
|
import { RelayApi } from "../../libraries/api.ts";
|
||||||
import { NotFoundError } from "../../mod.ts";
|
import { NotFoundError } from "../../mod.ts";
|
||||||
import { addTwoNumbers } from "./actions.ts";
|
import { addNumbers } from "./actions.ts";
|
||||||
import { relay } from "./relay.ts";
|
import { relay } from "./relay.ts";
|
||||||
import { User } from "./user.ts";
|
import { User } from "./user.ts";
|
||||||
|
|
||||||
export let users: User[] = [];
|
export let users: User[] = [];
|
||||||
|
|
||||||
export const api = new RelayAPI({
|
export const api = new RelayApi({
|
||||||
routes: [
|
procedures: [
|
||||||
relay.route("POST", "/users").handle(async ({ name, email }) => {
|
relay.procedure("user:create").handle(async ({ name, email }) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
users.push({ id, name, email, createdAt: new Date() });
|
users.push({ id, name, email, createdAt: new Date() });
|
||||||
return id;
|
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);
|
const user = users.find((user) => user.id === userId);
|
||||||
if (user === undefined) {
|
if (user === undefined) {
|
||||||
return new NotFoundError();
|
return new NotFoundError();
|
||||||
}
|
}
|
||||||
return user;
|
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) {
|
for (const user of users) {
|
||||||
if (user.id === userId) {
|
if (user.id === userId) {
|
||||||
user.name = name;
|
user.name = name ?? user.name;
|
||||||
user.email = email;
|
user.email = email ?? user.email;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
relay.route("DELETE", "/users/:userId").handle(async ({ userId }) => {
|
relay.procedure("user:delete").handle(async (userId) => {
|
||||||
users = users.filter((user) => user.id !== userId);
|
users = users.filter((user) => user.id !== userId);
|
||||||
}),
|
}),
|
||||||
relay
|
relay
|
||||||
.route("GET", "/add-two")
|
.procedure("number:add")
|
||||||
.actions([addTwoNumbers])
|
.actions([[addNumbers, (params) => params]])
|
||||||
.handle(async ({ added }) => added),
|
.handle(async (_, { sum }) => {
|
||||||
|
return sum;
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,29 +1,31 @@
|
|||||||
import "./mocks/server.ts";
|
|
||||||
|
|
||||||
import { assertEquals, assertObjectMatch, assertRejects } from "@std/assert";
|
import { assertEquals, assertObjectMatch, assertRejects } from "@std/assert";
|
||||||
import { afterAll, beforeAll, describe, it } from "@std/testing/bdd";
|
import { afterAll, beforeAll, describe, it } from "@std/testing/bdd";
|
||||||
|
|
||||||
import { adapter } from "../adapters/http.ts";
|
import { HttpAdapter } from "../adapters/http.ts";
|
||||||
import { RelayClient } from "../libraries/client.ts";
|
import { relay } from "./mocks/relay.ts";
|
||||||
import { relay, RelayRoutes } from "./mocks/relay.ts";
|
|
||||||
import { api, users } from "./mocks/server.ts";
|
import { api, users } from "./mocks/server.ts";
|
||||||
|
|
||||||
describe("Relay", () => {
|
describe("Procedure", () => {
|
||||||
let server: Deno.HttpServer<Deno.NetAddr>;
|
let server: Deno.HttpServer<Deno.NetAddr>;
|
||||||
let client: RelayClient<RelayRoutes>;
|
let client: typeof relay.$inferClient;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(async () => {
|
||||||
server = Deno.serve(
|
server = Deno.serve(
|
||||||
{
|
{
|
||||||
port: 36573,
|
port: 8080,
|
||||||
hostname: "localhost",
|
hostname: "localhost",
|
||||||
onListen({ port, hostname }) {
|
onListen({ port, hostname }) {
|
||||||
console.log(`Listening at http://${hostname}:${port}`);
|
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 () => {
|
afterAll(async () => {
|
||||||
@@ -31,16 +33,16 @@ describe("Relay", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should successfully relay users", async () => {
|
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(typeof userId, "string");
|
||||||
assertEquals(users.length, 1);
|
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);
|
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);
|
assertEquals(users.length, 1);
|
||||||
assertObjectMatch(users[0], {
|
assertObjectMatch(users[0], {
|
||||||
@@ -48,16 +50,16 @@ describe("Relay", () => {
|
|||||||
email: "jane.doe@fixture.none",
|
email: "jane.doe@fixture.none",
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.delete("/users/:userId", { userId });
|
await client.user.delete(userId);
|
||||||
|
|
||||||
assertEquals(users.length, 0);
|
assertEquals(users.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should successfully run .actions", async () => {
|
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 () => {
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user