Template
1
0

feat: split client to separate class

This commit is contained in:
2025-04-20 11:56:40 +00:00
parent 2e8d6b76d7
commit 221452893e
11 changed files with 220 additions and 195 deletions

View File

@@ -1,5 +1,4 @@
import { RequestInput } from "../libraries/relay.ts"; import type { RelayAdapter, RequestInput } from "../libraries/adapter.ts";
import { RelayAdapter } from "../mod.ts";
export const adapter: RelayAdapter = { export const adapter: RelayAdapter = {
async fetch({ method, url, search, body }: RequestInput) { async fetch({ method, url, search, body }: RequestInput) {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@valkyr/relay", "name": "@valkyr/relay",
"version": "0.1.2", "version": "0.2.0",
"exports": { "exports": {
".": "./mod.ts", ".": "./mod.ts",
"./http": "./adapters/http.ts" "./http": "./adapters/http.ts"

View File

@@ -1,6 +1,6 @@
import z, { ZodObject, ZodRawShape } from "zod"; import z, { ZodObject, ZodRawShape } from "zod";
import { RelayError } from "./errors.ts"; import type { RelayError } from "./errors.ts";
export class Action<TActionState extends ActionState = ActionState> { export class Action<TActionState extends ActionState = ActionState> {
constructor(readonly state: TActionState) {} constructor(readonly state: TActionState) {}

12
libraries/adapter.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { RouteMethod } from "./route.ts";
export type RelayAdapter = {
fetch(input: RequestInput): Promise<unknown>;
};
export type RequestInput = {
method: RouteMethod;
url: string;
search: string;
body?: string;
};

View File

@@ -1,11 +1,11 @@
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 { Route, RouteMethod } from "./route.ts"; import type { Route, RouteMethod } from "./route.ts";
const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; const SUPPORTED_MEHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
export class Api<TRoutes extends Route[]> { export class RelayAPI<TRoutes extends Route[]> {
/** /**
* Route maps funneling registered routes to the specific methods supported by * Route maps funneling registered routes to the specific methods supported by
* the relay instance. * the relay instance.
@@ -35,7 +35,7 @@ export class Api<TRoutes extends Route[]> {
* *
* @param routes - Routes to register with the instance. * @param routes - Routes to register with the instance.
*/ */
constructor(routes: TRoutes) { constructor({ routes }: Config<TRoutes>) {
const methods: (keyof typeof this.routes)[] = []; const methods: (keyof typeof this.routes)[] = [];
for (const route of routes) { for (const route of routes) {
this.#validateRoutePath(route); this.#validateRoutePath(route);
@@ -264,6 +264,10 @@ function toResponse(result: object | RelayError | Response | void): Response {
|-------------------------------------------------------------------------------- |--------------------------------------------------------------------------------
*/ */
type Config<TRoutes extends Route[]> = {
routes: TRoutes;
};
type Routes = { type Routes = {
POST: Route[]; POST: Route[];
GET: Route[]; GET: Route[];

146
libraries/client.ts Normal file
View File

@@ -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<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.
*
* @param config - Relay configuration to apply to the instance.
* @param routes - Routes to register with the instance.
*/
constructor(readonly config: RelayConfig<TRoutes>) {
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<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>;
}
/**
* 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<TRoutes[number], { state: { method: "GET" } }>["state"]["path"],
TRoute extends Extract<TRoutes[number], { state: { method: "GET"; path: TPath } }>,
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
return this.#send("GET", path, args) as RelayResponse<TRoute>;
}
/**
* 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>;
}
/**
* 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<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]);
}
}
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 extends Route> = TRoute["state"]["output"] extends ZodType ? z.infer<TRoute["state"]["output"]> : void;
type RelayConfig<TRoutes extends Route[]> = {
url: string;
adapter: RelayAdapter;
routes: TRoutes;
};

View File

@@ -1,6 +1,4 @@
import z, { ZodType } from "zod"; import type { Route, RouteMethod } from "./route.ts";
import { Route, RouteMethod } from "./route.ts";
export class Relay<TRoutes extends Route[]> { export class Relay<TRoutes extends Route[]> {
/** /**
@@ -9,28 +7,20 @@ export class Relay<TRoutes extends Route[]> {
*/ */
readonly #index = new Map<string, Route>(); readonly #index = new Map<string, Route>();
declare readonly $inferRoutes: TRoutes;
/** /**
* Instantiate a new Relay instance. * Instantiate a new Relay instance.
* *
* @param config - Relay configuration to apply to the instance. * @param config - Relay configuration to apply to the instance.
* @param routes - Routes to register with the instance. * @param routes - Routes to register with the instance.
*/ */
constructor( constructor(readonly routes: TRoutes) {
readonly config: RelayConfig,
routes: TRoutes,
) {
for (const route of routes) { for (const route of routes) {
this.#index.set(`${route.method} ${route.path}`, route); 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 * 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.
@@ -71,139 +61,4 @@ export class Relay<TRoutes extends Route[]> {
} }
return route as TRoute; 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<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>;
}
/**
* 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<TRoutes[number], { state: { method: "GET" } }>["state"]["path"],
TRoute extends Extract<TRoutes[number], { state: { method: "GET"; path: TPath } }>,
>(path: TPath, ...args: TRoute["args"]): Promise<RelayResponse<TRoute>> {
return this.#send("GET", path, args) as RelayResponse<TRoute>;
}
/**
* 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>;
}
/**
* 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<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.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 extends Route> = TRoute["state"]["output"] extends ZodType ? z.infer<TRoute["state"]["output"]> : void;
type RelayConfig = {
url: string;
adapter: RelayAdapter;
};
export type RelayAdapter = {
fetch(input: RequestInput): Promise<unknown>;
};
export type RequestInput = {
method: RouteMethod;
url: string;
search: string;
body?: string;
};

2
mod.ts
View File

@@ -1,5 +1,7 @@
export * from "./libraries/action.ts"; export * from "./libraries/action.ts";
export * from "./libraries/adapter.ts";
export * from "./libraries/api.ts"; export * from "./libraries/api.ts";
export * from "./libraries/client.ts";
export * from "./libraries/errors.ts"; export * from "./libraries/errors.ts";
export * from "./libraries/relay.ts"; export * from "./libraries/relay.ts";
export * from "./libraries/route.ts"; export * from "./libraries/route.ts";

View File

@@ -1,11 +1,10 @@
import z from "zod"; import z from "zod";
import { adapter } from "../../adapters/http.ts";
import { Relay } from "../../libraries/relay.ts"; 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({ url: "http://localhost:36573", adapter }, [ export const relay = new Relay([
route route
.post("/users") .post("/users")
.body(UserSchema.omit({ id: true, createdAt: true })) .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.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()), route.get("/add-two").search({ a: z.coerce.number(), b: z.coerce.number() }).response(z.number()),
]); ]);
export type RelayRoutes = typeof relay.$inferRoutes;

View File

@@ -1,4 +1,4 @@
import { Api } 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 { addTwoNumbers } from "./actions.ts";
import { relay } from "./relay.ts"; import { relay } from "./relay.ts";
@@ -6,7 +6,8 @@ import { User } from "./user.ts";
export let users: User[] = []; export let users: User[] = [];
export const api = new Api([ export const api = new RelayAPI({
routes: [
relay.route("POST", "/users").handle(async ({ name, email }) => { relay.route("POST", "/users").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() });
@@ -35,4 +36,5 @@ export const api = new Api([
.route("GET", "/add-two") .route("GET", "/add-two")
.actions([addTwoNumbers]) .actions([addTwoNumbers])
.handle(async ({ added }) => added), .handle(async ({ added }) => added),
]); ],
});

View File

@@ -3,11 +3,14 @@ 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 { 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"; import { api, users } from "./mocks/server.ts";
describe("Relay", () => { describe("Relay", () => {
let server: Deno.HttpServer<Deno.NetAddr>; let server: Deno.HttpServer<Deno.NetAddr>;
let client: RelayClient<RelayRoutes>;
beforeAll(() => { beforeAll(() => {
server = Deno.serve( server = Deno.serve(
@@ -20,6 +23,7 @@ describe("Relay", () => {
}, },
async (request) => api.handle(request), async (request) => api.handle(request),
); );
client = new RelayClient({ url: "http://localhost:36573", adapter, routes: relay.routes });
}); });
afterAll(async () => { afterAll(async () => {
@@ -27,16 +31,16 @@ describe("Relay", () => {
}); });
it("should successfully relay users", async () => { 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(typeof userId, "string");
assertEquals(users.length, 1); 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); 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); assertEquals(users.length, 1);
assertObjectMatch(users[0], { assertObjectMatch(users[0], {
@@ -44,16 +48,16 @@ describe("Relay", () => {
email: "jane.doe@fixture.none", email: "jane.doe@fixture.none",
}); });
await relay.delete("/users/:userId", { userId }); await client.delete("/users/:userId", { userId });
assertEquals(users.length, 0); assertEquals(users.length, 0);
}); });
it("should successfully run .actions", async () => { 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 () => { 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");
}); });
}); });