diff --git a/adapters/http.ts b/adapters/http.ts index 17a7a6c..d31812b 100644 --- a/adapters/http.ts +++ b/adapters/http.ts @@ -3,7 +3,7 @@ import { RelayAdapter } from "../mod.ts"; export const http: RelayAdapter = { 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(); if (res.status >= 400) { throw new Error(data); diff --git a/libraries/action.ts b/libraries/action.ts index 5a69064..eca7787 100644 --- a/libraries/action.ts +++ b/libraries/action.ts @@ -39,7 +39,9 @@ export class Action { |-------------------------------------------------------------------------------- */ -export const action = { +export const action: { + make(name: string): Action; +} = { make(name: string) { return new Action({ name }); }, diff --git a/libraries/api.ts b/libraries/api.ts new file mode 100644 index 0000000..ccf364d --- /dev/null +++ b/libraries/api.ts @@ -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 { + /** + * 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(); + + /** + * Route index in the '${method} ${path}' format allowing for quick access to + * a specific route. + */ + readonly #index = new Map(); + + /** + * 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 = {}; + + // ### Params + // If the route has params we want to coerce the values to the expected types. + + if (route.state.params !== undefined) { + const result = await route.state.params.safeParseAsync(params); + if (result.success === false) { + return toResponse(new BadRequestError("Invalid request params", z.prettifyError(result.error))); + } + for (const key in result.data) { + context[key] = (result.data as any)[key]; + } + } + + // ### Query + // If the route has a query schema we need to validate and parse the query. + + if (route.state.search !== undefined) { + const result = await route.state.search.safeParseAsync(toSearch(url.searchParams) ?? {}); + if (result.success === false) { + return toResponse(new BadRequestError("Invalid request query", z.prettifyError(result.error))); + } + for (const key in result.data) { + context[key] = (result.data as any)[key]; + } + } + + // ### Body + // If the route has a body schema we need to validate and parse the body. + + if (route.state.body !== undefined) { + let body: Record = {}; + if (request.headers.get("content-type")?.includes("json")) { + body = await request.json(); + } + 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 = {}; + 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; +}; diff --git a/libraries/errors.ts b/libraries/errors.ts index 9fd6683..fa8dffb 100644 --- a/libraries/errors.ts +++ b/libraries/errors.ts @@ -7,7 +7,11 @@ export abstract class RelayError extends Error { super(message); } - toJSON() { + toJSON(): { + status: number; + message: string; + data: any; + } { return { status: this.status, message: this.message, diff --git a/libraries/relay.ts b/libraries/relay.ts index 75b0526..67134ca 100644 --- a/libraries/relay.ts +++ b/libraries/relay.ts @@ -1,29 +1,8 @@ import z, { ZodType } from "zod"; -import { BadRequestError, NotFoundError, RelayError } from "./errors.ts"; import { Route, RouteMethod } from "./route.ts"; -const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; - export class Relay { - /** - * 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(); - /** * Route index in the '${method} ${path}' format allowing for quick access to * a specific route. @@ -40,24 +19,11 @@ export class Relay { readonly config: RelayConfig, 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); - } } - /* - |-------------------------------------------------------------------------------- - | Agnostic - |-------------------------------------------------------------------------------- - */ - /** * Retrieve a route for the given method/path combination which can be further extended * for serving incoming third party requests. @@ -170,127 +136,12 @@ export class Relay { return this.#send("DELETE", path, args) as RelayResponse; } - /* - |-------------------------------------------------------------------------------- - | 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 = {}; - - 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[]) { const route = this.route(method, url); // ### Input - const input: RequestInput = { method, url, search: "" }; + const input: RequestInput = { method, url: `${this.config.url}${url}`, search: "" }; let index = 0; // argument incrementor @@ -324,92 +175,6 @@ export class Relay { } 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 = {}; - 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["state"]["output"] extends ZodType ? z.infer : void; type RelayConfig = { + url: string; adapter: RelayAdapter; }; diff --git a/libraries/route.ts b/libraries/route.ts index dbbc0e7..419e68c 100644 --- a/libraries/route.ts +++ b/libraries/route.ts @@ -1,6 +1,7 @@ import z, { ZodObject, ZodRawShape, ZodType } from "zod"; import { Action } from "./action.ts"; +import { RelayError } from "./errors.ts"; export class Route { #pattern?: URLPattern; @@ -48,12 +49,12 @@ export class Route { * * @param url - HTTP request.url */ - getParsedParams(url: string): TRouteState["params"] extends ZodObject ? z.infer : object { + getParsedParams(url: string): object { const params = this.pattern.exec(url)?.pathname.groups; if (params === undefined) { return {}; } - return this.state.params?.parse(params) ?? params; + return params; } /** @@ -76,7 +77,7 @@ export class Route { * ``` */ params(params: TParams): Route & { params: ZodObject }> { - 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 { * ``` */ search(search: TSearch): Route & { search: ZodObject }> { - 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 { /** * Route factories allowing for easy generation of relay compliant routes. */ -export const route = { +export const route: { + post(path: TPath): Route<{ method: "POST"; path: TPath }>; + get(path: TPath): Route<{ method: "GET"; path: TPath }>; + put(path: TPath): Route<{ method: "PUT"; path: TPath }>; + patch(path: TPath): Route<{ method: "PATCH"; path: TPath }>; + delete(path: TPath): Route<{ method: "DELETE"; path: TPath }>; +} = { /** * Create a new "POST" route for the given path. * @@ -311,7 +318,9 @@ type RouteState = { export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE"; -export type HandleFn = (context: TContext) => TResponse extends ZodType ? Promise> : Promise; +export type HandleFn = ( + context: TContext, +) => TResponse extends ZodType ? Promise | Response | RelayError> : Promise; type RouteContext = (TRouteState["params"] extends ZodObject ? z.infer : object) & (TRouteState["search"] extends ZodObject ? z.infer : object) & diff --git a/tests/mocks/relay.ts b/tests/mocks/relay.ts index b660c0c..bc5c7e8 100644 --- a/tests/mocks/relay.ts +++ b/tests/mocks/relay.ts @@ -5,19 +5,18 @@ import { Relay } from "../../libraries/relay.ts"; import { route } from "../../libraries/route.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 .post("/users") - .body(UserSchema.omit({ id: true })) + .body(UserSchema.omit({ id: true, createdAt: true })) .response(z.string()), - route.get("/users").response(z.array(UserSchema)), route .get("/users/:userId") .params({ userId: z.string().check(z.uuid()) }) - .response(UserSchema.or(z.undefined())), + .response(UserSchema), route .put("/users/:userId") .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()) }), ]); diff --git a/tests/mocks/server.ts b/tests/mocks/server.ts index 564f15a..b1307f6 100644 --- a/tests/mocks/server.ts +++ b/tests/mocks/server.ts @@ -1,32 +1,33 @@ +import { Api } from "../../libraries/api.ts"; +import { NotFoundError } from "../../mod.ts"; import { relay } from "./relay.ts"; import { User } from "./user.ts"; export let users: User[] = []; -relay.route("POST", "/users").handle(async ({ name, email }) => { - const id = crypto.randomUUID(); - users.push({ id, name, email }); - return id; -}); - -relay.route("GET", "/users").handle(async () => { - return users; -}); - -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; +export const api = new Api([ + relay.route("POST", "/users").handle(async ({ name, email }) => { + const id = crypto.randomUUID(); + users.push({ id, name, email, createdAt: new Date() }); + return id; + }), + relay.route("GET", "/users/:userId").handle(async ({ userId }) => { + const user = users.find((user) => user.id === userId); + if (user === undefined) { + return new NotFoundError(); } - } -}); - -relay.route("DELETE", "/users/:userId").handle(async ({ userId }) => { - users = users.filter((user) => user.id === userId); -}); + return user; + }), + 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; + } + } + }), + relay.route("DELETE", "/users/:userId").handle(async ({ userId }) => { + users = users.filter((user) => user.id !== userId); + }), +]); diff --git a/tests/mocks/user.ts b/tests/mocks/user.ts index e9cc50f..1586833 100644 --- a/tests/mocks/user.ts +++ b/tests/mocks/user.ts @@ -4,6 +4,7 @@ export const UserSchema = z.object({ id: z.string().check(z.uuid()), name: z.string(), email: z.string().check(z.email()), + createdAt: z.coerce.date(), }); export type User = z.infer; diff --git a/tests/route.test.ts b/tests/route.test.ts index 800dbba..cf84d7f 100644 --- a/tests/route.test.ts +++ b/tests/route.test.ts @@ -1,14 +1,51 @@ -import { assertEquals } from "@std/assert"; -import { describe, it } from "@std/testing/bdd"; +import "./mocks/server.ts"; + +import { assertEquals, assertObjectMatch } from "@std/assert"; +import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; import { relay } from "./mocks/relay.ts"; +import { api, users } from "./mocks/server.ts"; describe("Relay", () => { - it("should create a new user", async () => { + let server: Deno.HttpServer; + + 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" }); - console.log({ userId }); - 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); }); });