diff --git a/adapters/http.ts b/adapters/http.ts index efa4ca9..5a13e87 100644 --- a/adapters/http.ts +++ b/adapters/http.ts @@ -1,5 +1,4 @@ -import { RequestInput } from "../libraries/relay.ts"; -import { RelayAdapter } from "../mod.ts"; +import type { RelayAdapter, RequestInput } from "../libraries/adapter.ts"; export const adapter: RelayAdapter = { async fetch({ method, url, search, body }: RequestInput) { diff --git a/deno.json b/deno.json index 50e76af..904010c 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@valkyr/relay", - "version": "0.1.2", + "version": "0.2.0", "exports": { ".": "./mod.ts", "./http": "./adapters/http.ts" diff --git a/libraries/action.ts b/libraries/action.ts index e31c26f..848dbec 100644 --- a/libraries/action.ts +++ b/libraries/action.ts @@ -1,6 +1,6 @@ import z, { ZodObject, ZodRawShape } from "zod"; -import { RelayError } from "./errors.ts"; +import type { RelayError } from "./errors.ts"; export class Action { constructor(readonly state: TActionState) {} diff --git a/libraries/adapter.ts b/libraries/adapter.ts new file mode 100644 index 0000000..2b4d810 --- /dev/null +++ b/libraries/adapter.ts @@ -0,0 +1,12 @@ +import type { RouteMethod } from "./route.ts"; + +export type RelayAdapter = { + fetch(input: RequestInput): Promise; +}; + +export type RequestInput = { + method: RouteMethod; + url: string; + search: string; + body?: string; +}; diff --git a/libraries/api.ts b/libraries/api.ts index df5000b..fd1cfa5 100644 --- a/libraries/api.ts +++ b/libraries/api.ts @@ -1,11 +1,11 @@ import z from "zod"; import { BadRequestError, InternalServerError, NotFoundError, RelayError } from "./errors.ts"; -import { Route, RouteMethod } from "./route.ts"; +import type { Route, RouteMethod } from "./route.ts"; const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; -export class Api { +export class RelayAPI { /** * Route maps funneling registered routes to the specific methods supported by * the relay instance. @@ -35,7 +35,7 @@ export class Api { * * @param routes - Routes to register with the instance. */ - constructor(routes: TRoutes) { + constructor({ routes }: Config) { const methods: (keyof typeof this.routes)[] = []; for (const route of routes) { this.#validateRoutePath(route); @@ -264,6 +264,10 @@ function toResponse(result: object | RelayError | Response | void): Response { |-------------------------------------------------------------------------------- */ +type Config = { + routes: TRoutes; +}; + type Routes = { POST: Route[]; GET: Route[]; diff --git a/libraries/client.ts b/libraries/client.ts new file mode 100644 index 0000000..1ab7d17 --- /dev/null +++ b/libraries/client.ts @@ -0,0 +1,146 @@ +import z, { ZodType } from "zod"; + +import type { RelayAdapter, RequestInput } from "./adapter.ts"; +import type { Route, RouteMethod } from "./route.ts"; + +export class RelayClient { + /** + * Route index in the '${method} ${path}' format allowing for quick access to + * a specific route. + */ + readonly #index = new Map(); + + /** + * Instantiate a new Relay instance. + * + * @param config - Relay configuration to apply to the instance. + * @param routes - Routes to register with the instance. + */ + constructor(readonly config: RelayConfig) { + for (const route of config.routes) { + this.#index.set(`${route.method} ${route.path}`, route); + } + } + + /** + * Send a "POST" request through the relay `fetch` adapter. + * + * @param path - Path to send request to. + * @param args - List of request arguments. + */ + async post< + TPath extends Extract["state"]["path"], + TRoute extends Extract, + >(path: TPath, ...args: TRoute["args"]): Promise> { + return this.#send("POST", path, args) as RelayResponse; + } + + /** + * Send a "GET" request through the relay `fetch` adapter. + * + * @param path - Path to send request to. + * @param args - List of request arguments. + */ + async get< + TPath extends Extract["state"]["path"], + TRoute extends Extract, + >(path: TPath, ...args: TRoute["args"]): Promise> { + return this.#send("GET", path, args) as RelayResponse; + } + + /** + * Send a "PUT" request through the relay `fetch` adapter. + * + * @param path - Path to send request to. + * @param args - List of request arguments. + */ + async put< + TPath extends Extract["state"]["path"], + TRoute extends Extract, + >(path: TPath, ...args: TRoute["args"]): Promise> { + return this.#send("PUT", path, args) as RelayResponse; + } + + /** + * Send a "PATCH" request through the relay `fetch` adapter. + * + * @param path - Path to send request to. + * @param args - List of request arguments. + */ + async patch< + TPath extends Extract["state"]["path"], + TRoute extends Extract, + >(path: TPath, ...args: TRoute["args"]): Promise> { + return this.#send("PATCH", path, args) as RelayResponse; + } + + /** + * Send a "DELETE" request through the relay `fetch` adapter. + * + * @param path - Path to send request to. + * @param args - List of request arguments. + */ + async delete< + TPath extends Extract["state"]["path"], + TRoute extends Extract, + >(path: TPath, ...args: TRoute["args"]): Promise> { + return this.#send("DELETE", path, args) as RelayResponse; + } + + async #send(method: RouteMethod, url: string, args: any[]) { + const route = this.#index.get(`${method} ${url}`); + if (route === undefined) { + throw new Error(`RelayClient > Failed to send request for '${method} ${url}' route, not found.`); + } + + // ### Input + + const input: RequestInput = { method, url: `${this.config.url}${url}`, search: "" }; + + let index = 0; // argument incrementor + + if (route.state.params !== undefined) { + const params = args[index++] as { [key: string]: string }; + for (const key in params) { + input.url = input.url.replace(`:${key}`, params[key]); + } + } + + if (route.state.search !== undefined) { + const search = args[index++] as { [key: string]: string }; + const pieces: string[] = []; + for (const key in search) { + pieces.push(`${key}=${search[key]}`); + } + if (pieces.length > 0) { + input.search = `?${pieces.join("&")}`; + } + } + + if (route.state.body !== undefined) { + input.body = JSON.stringify(args[index++]); + } + + // ### Fetch + + const data = await this.config.adapter.fetch(input); + if (route.state.output !== undefined) { + return route.state.output.parse(data); + } + return data; + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type RelayResponse = TRoute["state"]["output"] extends ZodType ? z.infer : void; + +type RelayConfig = { + url: string; + adapter: RelayAdapter; + routes: TRoutes; +}; diff --git a/libraries/relay.ts b/libraries/relay.ts index a4dd56f..b9cac82 100644 --- a/libraries/relay.ts +++ b/libraries/relay.ts @@ -1,6 +1,4 @@ -import z, { ZodType } from "zod"; - -import { Route, RouteMethod } from "./route.ts"; +import type { Route, RouteMethod } from "./route.ts"; export class Relay { /** @@ -9,28 +7,20 @@ export class Relay { */ readonly #index = new Map(); + declare readonly $inferRoutes: TRoutes; + /** * Instantiate a new Relay instance. * * @param config - Relay configuration to apply to the instance. * @param routes - Routes to register with the instance. */ - constructor( - readonly config: RelayConfig, - routes: TRoutes, - ) { + constructor(readonly routes: TRoutes) { for (const route of routes) { this.#index.set(`${route.method} ${route.path}`, route); } } - /** - * Override relay url configuration. - */ - set url(value: string) { - this.config.url = value; - } - /** * Retrieve a route for the given method/path combination which can be further extended * for serving incoming third party requests. @@ -71,139 +61,4 @@ export class Relay { } return route as TRoute; } - - /* - |-------------------------------------------------------------------------------- - | Client - |-------------------------------------------------------------------------------- - */ - - /** - * Send a "POST" request through the relay `fetch` adapter. - * - * @param path - Path to send request to. - * @param args - List of request arguments. - */ - async post< - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(path: TPath, ...args: TRoute["args"]): Promise> { - return this.#send("POST", path, args) as RelayResponse; - } - - /** - * Send a "GET" request through the relay `fetch` adapter. - * - * @param path - Path to send request to. - * @param args - List of request arguments. - */ - async get< - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(path: TPath, ...args: TRoute["args"]): Promise> { - return this.#send("GET", path, args) as RelayResponse; - } - - /** - * Send a "PUT" request through the relay `fetch` adapter. - * - * @param path - Path to send request to. - * @param args - List of request arguments. - */ - async put< - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(path: TPath, ...args: TRoute["args"]): Promise> { - return this.#send("PUT", path, args) as RelayResponse; - } - - /** - * Send a "PATCH" request through the relay `fetch` adapter. - * - * @param path - Path to send request to. - * @param args - List of request arguments. - */ - async patch< - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(path: TPath, ...args: TRoute["args"]): Promise> { - return this.#send("PATCH", path, args) as RelayResponse; - } - - /** - * Send a "DELETE" request through the relay `fetch` adapter. - * - * @param path - Path to send request to. - * @param args - List of request arguments. - */ - async delete< - TPath extends Extract["state"]["path"], - TRoute extends Extract, - >(path: TPath, ...args: TRoute["args"]): Promise> { - return this.#send("DELETE", path, args) as RelayResponse; - } - - async #send(method: RouteMethod, url: string, args: any[]) { - const route = this.route(method, url); - - // ### Input - - const input: RequestInput = { method, url: `${this.config.url}${url}`, search: "" }; - - let index = 0; // argument incrementor - - if (route.state.params !== undefined) { - const params = args[index++] as { [key: string]: string }; - for (const key in params) { - input.url = input.url.replace(`:${key}`, params[key]); - } - } - - if (route.state.search !== undefined) { - const search = args[index++] as { [key: string]: string }; - const pieces: string[] = []; - for (const key in search) { - pieces.push(`${key}=${search[key]}`); - } - if (pieces.length > 0) { - input.search = `?${pieces.join("&")}`; - } - } - - if (route.state.body !== undefined) { - input.body = JSON.stringify(args[index++]); - } - - // ### Fetch - - const data = await this.config.adapter.fetch(input); - if (route.state.output !== undefined) { - return route.state.output.parse(data); - } - return data; - } } - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -type RelayResponse = TRoute["state"]["output"] extends ZodType ? z.infer : void; - -type RelayConfig = { - url: string; - adapter: RelayAdapter; -}; - -export type RelayAdapter = { - fetch(input: RequestInput): Promise; -}; - -export type RequestInput = { - method: RouteMethod; - url: string; - search: string; - body?: string; -}; diff --git a/mod.ts b/mod.ts index 1a60fc4..2f76b4e 100644 --- a/mod.ts +++ b/mod.ts @@ -1,5 +1,7 @@ export * from "./libraries/action.ts"; +export * from "./libraries/adapter.ts"; export * from "./libraries/api.ts"; +export * from "./libraries/client.ts"; export * from "./libraries/errors.ts"; export * from "./libraries/relay.ts"; export * from "./libraries/route.ts"; diff --git a/tests/mocks/relay.ts b/tests/mocks/relay.ts index 31f8b56..d24605f 100644 --- a/tests/mocks/relay.ts +++ b/tests/mocks/relay.ts @@ -1,11 +1,10 @@ import z from "zod"; -import { adapter } from "../../adapters/http.ts"; import { Relay } from "../../libraries/relay.ts"; import { route } from "../../libraries/route.ts"; import { UserSchema } from "./user.ts"; -export const relay = new Relay({ url: "http://localhost:36573", adapter }, [ +export const relay = new Relay([ route .post("/users") .body(UserSchema.omit({ id: true, createdAt: true })) @@ -21,3 +20,5 @@ export const relay = new Relay({ url: "http://localhost:36573", adapter }, [ route.delete("/users/:userId").params({ userId: z.string().check(z.uuid()) }), route.get("/add-two").search({ a: z.coerce.number(), b: z.coerce.number() }).response(z.number()), ]); + +export type RelayRoutes = typeof relay.$inferRoutes; diff --git a/tests/mocks/server.ts b/tests/mocks/server.ts index 3d6a4d4..a5da003 100644 --- a/tests/mocks/server.ts +++ b/tests/mocks/server.ts @@ -1,4 +1,4 @@ -import { Api } from "../../libraries/api.ts"; +import { RelayAPI } from "../../libraries/api.ts"; import { NotFoundError } from "../../mod.ts"; import { addTwoNumbers } from "./actions.ts"; import { relay } from "./relay.ts"; @@ -6,33 +6,35 @@ import { User } from "./user.ts"; export let users: User[] = []; -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(); - } - 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; +export const api = new RelayAPI({ + routes: [ + 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); - }), - relay - .route("GET", "/add-two") - .actions([addTwoNumbers]) - .handle(async ({ added }) => added), -]); + 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); + }), + relay + .route("GET", "/add-two") + .actions([addTwoNumbers]) + .handle(async ({ added }) => added), + ], +}); diff --git a/tests/route.test.ts b/tests/route.test.ts index 74536be..01fb294 100644 --- a/tests/route.test.ts +++ b/tests/route.test.ts @@ -3,11 +3,14 @@ import "./mocks/server.ts"; import { assertEquals, assertObjectMatch, assertRejects } from "@std/assert"; import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; -import { relay } from "./mocks/relay.ts"; +import { adapter } from "../adapters/http.ts"; +import { RelayClient } from "../libraries/client.ts"; +import { relay, RelayRoutes } from "./mocks/relay.ts"; import { api, users } from "./mocks/server.ts"; describe("Relay", () => { let server: Deno.HttpServer; + let client: RelayClient; beforeAll(() => { server = Deno.serve( @@ -20,6 +23,7 @@ describe("Relay", () => { }, async (request) => api.handle(request), ); + client = new RelayClient({ url: "http://localhost:36573", adapter, routes: relay.routes }); }); afterAll(async () => { @@ -27,16 +31,16 @@ describe("Relay", () => { }); it("should successfully relay users", async () => { - const userId = await relay.post("/users", { name: "John Doe", email: "john.doe@fixture.none" }); + const userId = await client.post("/users", { name: "John Doe", email: "john.doe@fixture.none" }); assertEquals(typeof userId, "string"); assertEquals(users.length, 1); - const user = await relay.get("/users/:userId", { userId }); + const user = await client.get("/users/:userId", { userId }); assertEquals(user.createdAt instanceof Date, true); - await relay.put("/users/:userId", { userId }, { name: "Jane Doe", email: "jane.doe@fixture.none" }); + await client.put("/users/:userId", { userId }, { name: "Jane Doe", email: "jane.doe@fixture.none" }); assertEquals(users.length, 1); assertObjectMatch(users[0], { @@ -44,16 +48,16 @@ describe("Relay", () => { email: "jane.doe@fixture.none", }); - await relay.delete("/users/:userId", { userId }); + await client.delete("/users/:userId", { userId }); assertEquals(users.length, 0); }); it("should successfully run .actions", async () => { - assertEquals(await relay.get("/add-two", { a: 1, b: 1 }), 2); + assertEquals(await client.get("/add-two", { a: 1, b: 1 }), 2); }); it("should reject .actions with error", async () => { - await assertRejects(() => relay.get("/add-two", { a: -1, b: 1 }), "Invalid input numbers added"); + await assertRejects(() => client.get("/add-two", { a: -1, b: 1 }), "Invalid input numbers added"); }); });