feat: update tests
This commit is contained in:
@@ -3,7 +3,7 @@ import { RelayAdapter } from "../mod.ts";
|
|||||||
|
|
||||||
export const http: RelayAdapter = {
|
export const http: RelayAdapter = {
|
||||||
async fetch({ method, url, search, body }: RequestInput) {
|
async fetch({ method, url, search, body }: RequestInput) {
|
||||||
const res = await fetch(`${url}${search}`, { method, body });
|
const res = await fetch(`${url}${search}`, { method, headers: { "content-type": "application/json" }, body });
|
||||||
const data = await res.text();
|
const data = await res.text();
|
||||||
if (res.status >= 400) {
|
if (res.status >= 400) {
|
||||||
throw new Error(data);
|
throw new Error(data);
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export class Action<TActionState extends ActionState = ActionState> {
|
|||||||
|--------------------------------------------------------------------------------
|
|--------------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const action = {
|
export const action: {
|
||||||
|
make(name: string): Action;
|
||||||
|
} = {
|
||||||
make(name: string) {
|
make(name: string) {
|
||||||
return new Action({ name });
|
return new Action({ name });
|
||||||
},
|
},
|
||||||
|
|||||||
272
libraries/api.ts
Normal file
272
libraries/api.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
import { BadRequestError, InternalServerError, NotFoundError, RelayError } from "./errors.ts";
|
||||||
|
import { Route, RouteMethod } from "./route.ts";
|
||||||
|
|
||||||
|
const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||||
|
|
||||||
|
export class Api<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>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route index in the '${method} ${path}' format allowing for quick access to
|
||||||
|
* a specific route.
|
||||||
|
*/
|
||||||
|
readonly #index = new Map<string, Route>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate a new Server instance.
|
||||||
|
*
|
||||||
|
* @param routes - Routes to register with the instance.
|
||||||
|
*/
|
||||||
|
constructor(routes: TRoutes) {
|
||||||
|
const methods: (keyof typeof this.routes)[] = [];
|
||||||
|
for (const route of routes) {
|
||||||
|
this.#validateRoutePath(route);
|
||||||
|
this.routes[route.method].push(route);
|
||||||
|
methods.push(route.method);
|
||||||
|
this.#index.set(`${route.method} ${route.path}`, route);
|
||||||
|
}
|
||||||
|
for (const method of methods) {
|
||||||
|
this.routes[method].sort(byStaticPriority);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a incoming fetch request.
|
||||||
|
*
|
||||||
|
* @param request - Fetch request to pass to a route handler.
|
||||||
|
*/
|
||||||
|
async handle(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
const matched = this.#resolve(request.method, request.url);
|
||||||
|
if (matched === undefined) {
|
||||||
|
return toResponse(
|
||||||
|
new NotFoundError(`Invalid routing path provided for ${request.url}`, {
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { route, params } = matched;
|
||||||
|
|
||||||
|
// ### Context
|
||||||
|
// Context is passed to every route handler and provides a suite of functionality
|
||||||
|
// and request data.
|
||||||
|
|
||||||
|
const context: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// ### Params
|
||||||
|
// If the route has params we want to coerce the values to the expected types.
|
||||||
|
|
||||||
|
if (route.state.params !== undefined) {
|
||||||
|
const result = await route.state.params.safeParseAsync(params);
|
||||||
|
if (result.success === false) {
|
||||||
|
return toResponse(new BadRequestError("Invalid request params", z.prettifyError(result.error)));
|
||||||
|
}
|
||||||
|
for (const key in result.data) {
|
||||||
|
context[key] = (result.data as any)[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ### Query
|
||||||
|
// If the route has a query schema we need to validate and parse the query.
|
||||||
|
|
||||||
|
if (route.state.search !== undefined) {
|
||||||
|
const result = await route.state.search.safeParseAsync(toSearch(url.searchParams) ?? {});
|
||||||
|
if (result.success === false) {
|
||||||
|
return toResponse(new BadRequestError("Invalid request query", z.prettifyError(result.error)));
|
||||||
|
}
|
||||||
|
for (const key in result.data) {
|
||||||
|
context[key] = (result.data as any)[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ### Body
|
||||||
|
// If the route has a body schema we need to validate and parse the body.
|
||||||
|
|
||||||
|
if (route.state.body !== undefined) {
|
||||||
|
let body: Record<string, unknown> = {};
|
||||||
|
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 toResponse(new BadRequestError("Invalid request body", z.prettifyError(result.error)));
|
||||||
|
}
|
||||||
|
for (const key in result.data) {
|
||||||
|
context[key] = (result.data as any)[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ### Actions
|
||||||
|
// Run through all assigned actions for the route.
|
||||||
|
|
||||||
|
if (route.state.actions !== undefined) {
|
||||||
|
for (const action of route.state.actions) {
|
||||||
|
const result = (await action.state.input?.safeParseAsync(context)) ?? { success: true, data: {} };
|
||||||
|
if (result.success === false) {
|
||||||
|
return toResponse(new BadRequestError("Invalid action input", z.prettifyError(result.error)));
|
||||||
|
}
|
||||||
|
const output = (await action.state.handle?.(result.data)) ?? {};
|
||||||
|
for (const key in output) {
|
||||||
|
context[key] = output[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ### Handler
|
||||||
|
// Execute the route handler and apply the result.
|
||||||
|
|
||||||
|
if (route.state.handle === undefined) {
|
||||||
|
return toResponse(new InternalServerError(`Path '${route.method} ${route.path}' is missing request handler.`));
|
||||||
|
}
|
||||||
|
return toResponse(await route.state.handle(context).catch((error) => error));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to resolve a route based on the given method and pathname.
|
||||||
|
*
|
||||||
|
* @param method - HTTP method.
|
||||||
|
* @param url - HTTP request url.
|
||||||
|
*/
|
||||||
|
#resolve(method: string, url: string): ResolvedRoute | undefined {
|
||||||
|
this.#assertMethod(method);
|
||||||
|
for (const route of this.routes[method]) {
|
||||||
|
if (route.match(url) === true) {
|
||||||
|
return { route, params: route.getParsedParams(url) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#validateRoutePath(route: Route): void {
|
||||||
|
const path = `${route.method} ${route.path}`;
|
||||||
|
if (this.#paths.has(path)) {
|
||||||
|
throw new Error(`Router > Path ${path} already exists`);
|
||||||
|
}
|
||||||
|
this.#paths.add(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#assertMethod(method: string): asserts method is RouteMethod {
|
||||||
|
if (!SUPPORTED_MEHODS.includes(method)) {
|
||||||
|
throw new Error(`Router > Unsupported method '${method}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------------
|
||||||
|
| Helpers
|
||||||
|
|--------------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* @param result - Result to send back as a Response.
|
||||||
|
*/
|
||||||
|
function toResponse(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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------------
|
||||||
|
| Types
|
||||||
|
|--------------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Routes = {
|
||||||
|
POST: Route[];
|
||||||
|
GET: Route[];
|
||||||
|
PUT: Route[];
|
||||||
|
PATCH: Route[];
|
||||||
|
DELETE: Route[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolvedRoute = {
|
||||||
|
route: Route;
|
||||||
|
params: any;
|
||||||
|
};
|
||||||
@@ -7,7 +7,11 @@ export abstract class RelayError<D = unknown> extends Error {
|
|||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON() {
|
toJSON(): {
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
data: any;
|
||||||
|
} {
|
||||||
return {
|
return {
|
||||||
status: this.status,
|
status: this.status,
|
||||||
message: this.message,
|
message: this.message,
|
||||||
|
|||||||
@@ -1,29 +1,8 @@
|
|||||||
import z, { ZodType } from "zod";
|
import z, { ZodType } from "zod";
|
||||||
|
|
||||||
import { BadRequestError, NotFoundError, RelayError } from "./errors.ts";
|
|
||||||
import { Route, RouteMethod } from "./route.ts";
|
import { Route, RouteMethod } from "./route.ts";
|
||||||
|
|
||||||
const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
||||||
|
|
||||||
export class Relay<TRoutes extends Route[]> {
|
export class Relay<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>();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
@@ -40,24 +19,11 @@ export class Relay<TRoutes extends Route[]> {
|
|||||||
readonly config: RelayConfig,
|
readonly config: RelayConfig,
|
||||||
routes: TRoutes,
|
routes: TRoutes,
|
||||||
) {
|
) {
|
||||||
const methods: (keyof typeof this.routes)[] = [];
|
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
this.#validateRoutePath(route);
|
|
||||||
this.routes[route.method].push(route);
|
|
||||||
methods.push(route.method);
|
|
||||||
this.#index.set(`${route.method} ${route.path}`, route);
|
this.#index.set(`${route.method} ${route.path}`, route);
|
||||||
}
|
}
|
||||||
for (const method of methods) {
|
|
||||||
this.routes[method].sort(byStaticPriority);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------------
|
|
||||||
| Agnostic
|
|
||||||
|--------------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve a route for the given method/path combination which can be further extended
|
* Retrieve a route for the given method/path combination which can be further extended
|
||||||
* for serving incoming third party requests.
|
* for serving incoming third party requests.
|
||||||
@@ -170,127 +136,12 @@ export class Relay<TRoutes extends Route[]> {
|
|||||||
return this.#send("DELETE", path, args) as RelayResponse<TRoute>;
|
return this.#send("DELETE", path, args) as RelayResponse<TRoute>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------------
|
|
||||||
| Server
|
|
||||||
|--------------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a incoming fetch request.
|
|
||||||
*
|
|
||||||
* @param request - Fetch request to pass to a route handler.
|
|
||||||
*/
|
|
||||||
async handle(request: Request) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
const matched = this.#resolve(request.method, request.url);
|
|
||||||
if (matched === undefined) {
|
|
||||||
return toResponse(
|
|
||||||
new NotFoundError(`Invalid routing path provided for ${request.url}`, {
|
|
||||||
method: request.method,
|
|
||||||
url: request.url,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { route, params } = matched;
|
|
||||||
|
|
||||||
// ### Context
|
|
||||||
// Context is passed to every route handler and provides a suite of functionality
|
|
||||||
// and request data.
|
|
||||||
|
|
||||||
const context = {
|
|
||||||
...params,
|
|
||||||
...toSearch(url.searchParams),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ### 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(context.params);
|
|
||||||
if (result.success === false) {
|
|
||||||
return toResponse(new BadRequestError("Invalid request params", z.prettifyError(result.error)));
|
|
||||||
}
|
|
||||||
context.params = result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### 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(context.query ?? {});
|
|
||||||
if (result.success === false) {
|
|
||||||
return toResponse(new BadRequestError("Invalid request query", z.prettifyError(result.error)));
|
|
||||||
}
|
|
||||||
context.query = result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### Body
|
|
||||||
// If the route has a body schema we need to validate and parse the body.
|
|
||||||
|
|
||||||
const body: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
if (route.state.body !== undefined) {
|
|
||||||
const result = await route.state.body.safeParseAsync(body);
|
|
||||||
if (result.success === false) {
|
|
||||||
return toResponse(new BadRequestError("Invalid request body", z.prettifyError(result.error)));
|
|
||||||
}
|
|
||||||
context.body = result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### Actions
|
|
||||||
// Run through all assigned actions for the route.
|
|
||||||
|
|
||||||
if (route.state.actions !== undefined) {
|
|
||||||
for (const action of route.state.actions) {
|
|
||||||
const result = (await action.state.input?.safeParseAsync(context)) ?? { success: true, data: {} };
|
|
||||||
if (result.success === false) {
|
|
||||||
return toResponse(new BadRequestError("Invalid action input", z.prettifyError(result.error)));
|
|
||||||
}
|
|
||||||
const output = (await action.state.handle?.(result.data)) ?? {};
|
|
||||||
for (const key in output) {
|
|
||||||
context[key] = output[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ### Handler
|
|
||||||
// Execute the route handler and apply the result.
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
async #send(method: RouteMethod, url: string, args: any[]) {
|
async #send(method: RouteMethod, url: string, args: any[]) {
|
||||||
const route = this.route(method, url);
|
const route = this.route(method, url);
|
||||||
|
|
||||||
// ### Input
|
// ### Input
|
||||||
|
|
||||||
const input: RequestInput = { method, url, search: "" };
|
const input: RequestInput = { method, url: `${this.config.url}${url}`, search: "" };
|
||||||
|
|
||||||
let index = 0; // argument incrementor
|
let index = 0; // argument incrementor
|
||||||
|
|
||||||
@@ -324,92 +175,6 @@ export class Relay<TRoutes extends Route[]> {
|
|||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
#assertMethod(method: string): asserts method is RouteMethod {
|
|
||||||
if (!SUPPORTED_MEHODS.includes(method)) {
|
|
||||||
throw new Error(`Router > Unsupported method '${method}'`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------------
|
|
||||||
| Helpers
|
|
||||||
|--------------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* @param result - Result to send back as a Response.
|
|
||||||
*/
|
|
||||||
function toResponse(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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -418,22 +183,10 @@ function toResponse(result: object | RelayError | Response | void): Response {
|
|||||||
|--------------------------------------------------------------------------------
|
|--------------------------------------------------------------------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type Routes = {
|
|
||||||
POST: Route[];
|
|
||||||
GET: Route[];
|
|
||||||
PUT: Route[];
|
|
||||||
PATCH: Route[];
|
|
||||||
DELETE: Route[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ResolvedRoute = {
|
|
||||||
route: Route;
|
|
||||||
params: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RelayResponse<TRoute extends Route> = TRoute["state"]["output"] extends ZodType ? z.infer<TRoute["state"]["output"]> : void;
|
type RelayResponse<TRoute extends Route> = TRoute["state"]["output"] extends ZodType ? z.infer<TRoute["state"]["output"]> : void;
|
||||||
|
|
||||||
type RelayConfig = {
|
type RelayConfig = {
|
||||||
|
url: string;
|
||||||
adapter: RelayAdapter;
|
adapter: RelayAdapter;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import z, { ZodObject, ZodRawShape, ZodType } from "zod";
|
import z, { ZodObject, ZodRawShape, ZodType } from "zod";
|
||||||
|
|
||||||
import { Action } from "./action.ts";
|
import { Action } from "./action.ts";
|
||||||
|
import { RelayError } from "./errors.ts";
|
||||||
|
|
||||||
export class Route<TRouteState extends RouteState = RouteState> {
|
export class Route<TRouteState extends RouteState = RouteState> {
|
||||||
#pattern?: URLPattern;
|
#pattern?: URLPattern;
|
||||||
@@ -48,12 +49,12 @@ export class Route<TRouteState extends RouteState = RouteState> {
|
|||||||
*
|
*
|
||||||
* @param url - HTTP request.url
|
* @param url - HTTP request.url
|
||||||
*/
|
*/
|
||||||
getParsedParams(url: string): TRouteState["params"] extends ZodObject ? z.infer<TRouteState["params"]> : object {
|
getParsedParams(url: string): object {
|
||||||
const params = this.pattern.exec(url)?.pathname.groups;
|
const params = this.pattern.exec(url)?.pathname.groups;
|
||||||
if (params === undefined) {
|
if (params === undefined) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return this.state.params?.parse(params) ?? params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,7 +77,7 @@ export class Route<TRouteState extends RouteState = RouteState> {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
params<TParams extends ZodRawShape>(params: TParams): Route<Omit<TRouteState, "params"> & { params: ZodObject<TParams> }> {
|
params<TParams extends ZodRawShape>(params: TParams): Route<Omit<TRouteState, "params"> & { params: ZodObject<TParams> }> {
|
||||||
return new Route({ ...this.state, params }) as any;
|
return new Route({ ...this.state, params: z.object(params) as any });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,7 +100,7 @@ export class Route<TRouteState extends RouteState = RouteState> {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
search<TSearch extends ZodRawShape>(search: TSearch): Route<Omit<TRouteState, "search"> & { search: ZodObject<TSearch> }> {
|
search<TSearch extends ZodRawShape>(search: TSearch): Route<Omit<TRouteState, "search"> & { search: ZodObject<TSearch> }> {
|
||||||
return new Route({ ...this.state, search }) as any;
|
return new Route({ ...this.state, search: z.object(search) as any });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -203,7 +204,13 @@ export class Route<TRouteState extends RouteState = RouteState> {
|
|||||||
/**
|
/**
|
||||||
* Route factories allowing for easy generation of relay compliant routes.
|
* Route factories allowing for easy generation of relay compliant routes.
|
||||||
*/
|
*/
|
||||||
export const route = {
|
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.
|
* Create a new "POST" route for the given path.
|
||||||
*
|
*
|
||||||
@@ -311,7 +318,9 @@ type RouteState = {
|
|||||||
|
|
||||||
export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
|
export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
|
||||||
export type HandleFn<TContext = any, TResponse = any> = (context: TContext) => TResponse extends ZodType ? Promise<z.infer<TResponse>> : Promise<void>;
|
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) &
|
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["search"] extends ZodObject ? z.infer<TRouteState["search"]> : object) &
|
||||||
|
|||||||
@@ -5,19 +5,18 @@ import { Relay } from "../../libraries/relay.ts";
|
|||||||
import { route } from "../../libraries/route.ts";
|
import { route } from "../../libraries/route.ts";
|
||||||
import { UserSchema } from "./user.ts";
|
import { UserSchema } from "./user.ts";
|
||||||
|
|
||||||
export const relay = new Relay({ adapter: http }, [
|
export const relay = new Relay({ url: "http://localhost:36573", adapter: http }, [
|
||||||
route
|
route
|
||||||
.post("/users")
|
.post("/users")
|
||||||
.body(UserSchema.omit({ id: true }))
|
.body(UserSchema.omit({ id: true, createdAt: true }))
|
||||||
.response(z.string()),
|
.response(z.string()),
|
||||||
route.get("/users").response(z.array(UserSchema)),
|
|
||||||
route
|
route
|
||||||
.get("/users/:userId")
|
.get("/users/:userId")
|
||||||
.params({ userId: z.string().check(z.uuid()) })
|
.params({ userId: z.string().check(z.uuid()) })
|
||||||
.response(UserSchema.or(z.undefined())),
|
.response(UserSchema),
|
||||||
route
|
route
|
||||||
.put("/users/:userId")
|
.put("/users/:userId")
|
||||||
.params({ userId: z.string().check(z.uuid()) })
|
.params({ userId: z.string().check(z.uuid()) })
|
||||||
.body(UserSchema.omit({ id: true })),
|
.body(UserSchema.omit({ id: true, createdAt: true })),
|
||||||
route.delete("/users/:userId").params({ userId: z.string().check(z.uuid()) }),
|
route.delete("/users/:userId").params({ userId: z.string().check(z.uuid()) }),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,32 +1,33 @@
|
|||||||
|
import { Api } from "../../libraries/api.ts";
|
||||||
|
import { NotFoundError } from "../../mod.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[] = [];
|
||||||
|
|
||||||
relay.route("POST", "/users").handle(async ({ name, email }) => {
|
export const api = new Api([
|
||||||
const id = crypto.randomUUID();
|
relay.route("POST", "/users").handle(async ({ name, email }) => {
|
||||||
users.push({ id, name, email });
|
const id = crypto.randomUUID();
|
||||||
return id;
|
users.push({ id, name, email, createdAt: new Date() });
|
||||||
});
|
return id;
|
||||||
|
}),
|
||||||
relay.route("GET", "/users").handle(async () => {
|
relay.route("GET", "/users/:userId").handle(async ({ userId }) => {
|
||||||
return users;
|
const user = users.find((user) => user.id === userId);
|
||||||
});
|
if (user === undefined) {
|
||||||
|
return new NotFoundError();
|
||||||
relay.route("GET", "/users/:userId").handle(async ({ userId }) => {
|
|
||||||
return users.find((user) => user.id === userId);
|
|
||||||
});
|
|
||||||
|
|
||||||
relay.route("PUT", "/users/:userId").handle(async ({ userId, name, email }) => {
|
|
||||||
for (const user of users) {
|
|
||||||
if (user.id === userId) {
|
|
||||||
user.name = name;
|
|
||||||
user.email = email;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
return user;
|
||||||
});
|
}),
|
||||||
|
relay.route("PUT", "/users/:userId").handle(async ({ userId, name, email }) => {
|
||||||
relay.route("DELETE", "/users/:userId").handle(async ({ userId }) => {
|
for (const user of users) {
|
||||||
users = users.filter((user) => user.id === userId);
|
if (user.id === userId) {
|
||||||
});
|
user.name = name;
|
||||||
|
user.email = email;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
relay.route("DELETE", "/users/:userId").handle(async ({ userId }) => {
|
||||||
|
users = users.filter((user) => user.id !== userId);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const UserSchema = z.object({
|
|||||||
id: z.string().check(z.uuid()),
|
id: z.string().check(z.uuid()),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
email: z.string().check(z.email()),
|
email: z.string().check(z.email()),
|
||||||
|
createdAt: z.coerce.date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type User = z.infer<typeof UserSchema>;
|
export type User = z.infer<typeof UserSchema>;
|
||||||
|
|||||||
@@ -1,14 +1,51 @@
|
|||||||
import { assertEquals } from "@std/assert";
|
import "./mocks/server.ts";
|
||||||
import { describe, it } from "@std/testing/bdd";
|
|
||||||
|
import { assertEquals, assertObjectMatch } from "@std/assert";
|
||||||
|
import { afterAll, beforeAll, describe, it } from "@std/testing/bdd";
|
||||||
|
|
||||||
import { relay } from "./mocks/relay.ts";
|
import { relay } from "./mocks/relay.ts";
|
||||||
|
import { api, users } from "./mocks/server.ts";
|
||||||
|
|
||||||
describe("Relay", () => {
|
describe("Relay", () => {
|
||||||
it("should create a new user", async () => {
|
let server: Deno.HttpServer<Deno.NetAddr>;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
server = Deno.serve(
|
||||||
|
{
|
||||||
|
port: 36573,
|
||||||
|
hostname: "localhost",
|
||||||
|
onListen({ port, hostname }) {
|
||||||
|
console.log(`Listening at http://${hostname}:${port}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => api.handle(request),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should successfully relay users", async () => {
|
||||||
const userId = await relay.post("/users", { name: "John Doe", email: "john.doe@fixture.none" });
|
const userId = await relay.post("/users", { name: "John Doe", email: "john.doe@fixture.none" });
|
||||||
|
|
||||||
console.log({ userId });
|
|
||||||
|
|
||||||
assertEquals(typeof userId, "string");
|
assertEquals(typeof userId, "string");
|
||||||
|
assertEquals(users.length, 1);
|
||||||
|
|
||||||
|
const user = await relay.get("/users/:userId", { userId });
|
||||||
|
|
||||||
|
assertEquals(user.createdAt instanceof Date, true);
|
||||||
|
|
||||||
|
await relay.put("/users/:userId", { 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 relay.delete("/users/:userId", { userId });
|
||||||
|
|
||||||
|
assertEquals(users.length, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user