diff --git a/app/__test__/api/ModuleApi.spec.ts b/app/__test__/api/ModuleApi.spec.ts new file mode 100644 index 0000000..caa42d0 --- /dev/null +++ b/app/__test__/api/ModuleApi.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test"; +import { Hono } from "hono"; +import { secureRandomString } from "../../src/core/utils"; +import { ModuleApi } from "../../src/modules"; + +class Api extends ModuleApi { + _getUrl(path: string) { + return this.getUrl(path); + } +} + +const host = "http://localhost"; + +describe("ModuleApi", () => { + it("resolves options correctly", () => { + const api = new Api({ host }); + expect(api.options).toEqual({ host }); + }); + + it("returns correct url from path", () => { + const api = new Api({ host }); + expect(api._getUrl("/test")).toEqual("http://localhost/test"); + expect(api._getUrl("test")).toEqual("http://localhost/test"); + expect(api._getUrl("test/")).toEqual("http://localhost/test"); + expect(api._getUrl("//test?foo=1")).toEqual("http://localhost/test?foo=1"); + }); + + it("fetches endpoint", async () => { + const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); + const api = new Api({ host }); + api.fetcher = app.request as typeof fetch; + + const res = await api.get("/endpoint"); + expect(res.res.ok).toEqual(true); + expect(res.res.status).toEqual(200); + expect(res.data).toEqual({ foo: "bar" }); + expect(res.body).toEqual({ foo: "bar" }); + }); + + it("has accessible request", async () => { + const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); + const api = new Api({ host }); + api.fetcher = app.request as typeof fetch; + + const promise = api.get("/endpoint"); + expect(promise.request).toBeDefined(); + expect(promise.request.url).toEqual("http://localhost/endpoint"); + + expect((await promise).body).toEqual({ foo: "bar" }); + }); + + it("adds token to headers when given in options", () => { + const token = secureRandomString(20); + const api = new Api({ host, token, token_transport: "header" }); + + expect(api.get("/").request.headers.get("Authorization")).toEqual(`Bearer ${token}`); + }); + + it("sets header to accept json", () => { + const api = new Api({ host }); + expect(api.get("/").request.headers.get("Accept")).toEqual("application/json"); + }); + + it("adds additional headers from options", () => { + const headers = new Headers({ + "X-Test": "123" + }); + const api = new Api({ host, headers }); + expect(api.get("/").request.headers.get("X-Test")).toEqual("123"); + }); + + it("uses basepath & removes trailing slash", () => { + const api = new Api({ host, basepath: "/api" }); + expect(api.get("/").request.url).toEqual("http://localhost/api"); + }); + + it("uses search params", () => { + const api = new Api({ host }); + const search = new URLSearchParams({ + foo: "bar" + }); + expect(api.get("/", search).request.url).toEqual("http://localhost/?" + search.toString()); + }); + + it("resolves method shortcut fns correctly", () => { + const api = new Api({ host }); + expect(api.get("/").request.method).toEqual("GET"); + expect(api.post("/").request.method).toEqual("POST"); + expect(api.put("/").request.method).toEqual("PUT"); + expect(api.patch("/").request.method).toEqual("PATCH"); + expect(api.delete("/").request.method).toEqual("DELETE"); + }); + + // @todo: test error response + // @todo: test method shortcut functions +}); diff --git a/app/__test__/api/api.spec.ts b/app/__test__/api/api.spec.ts deleted file mode 100644 index 9c2a61b..0000000 --- a/app/__test__/api/api.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, test } from "bun:test"; -import { DataApi } from "../../src/modules/data/api/DataApi"; - -describe("Api", async () => { - test("...", async () => { - /*const dataApi = new DataApi({ - host: "https://dev-config-soma.bknd.run" - }); - - const one = await dataApi.readOne("users", 1); - const many = await dataApi.readMany("users", { limit: 2 }); - console.log("one", one); - console.log("many", many);*/ - }); -}); diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index 541505b..68f0627 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -18,7 +18,11 @@ export type ApiResponse = { res: Response; }; -export abstract class ModuleApi { +export type TInput = string | (string | number | PrimaryFieldType)[]; + +export abstract class ModuleApi { + fetcher = fetch; + constructor(protected readonly _options: Partial = {}) {} protected getDefaultOptions(): Partial { @@ -35,14 +39,15 @@ export abstract class ModuleApi { } protected getUrl(path: string) { - return this.options.host + (this.options.basepath + "/" + path).replace(/\/\//g, "/"); + const basepath = this.options.basepath ?? ""; + return this.options.host + (basepath + "/" + path).replace(/\/{2,}/g, "/").replace(/\/$/, ""); } - protected async request( - _input: string | (string | number | PrimaryFieldType)[], + protected request( + _input: TInput, _query?: Record | URLSearchParams, _init?: RequestInit - ): Promise> { + ): FetchPromise> { const method = _init?.method ?? "GET"; const input = Array.isArray(_input) ? _input.join("/") : _input; let url = this.getUrl(input); @@ -78,14 +83,70 @@ export abstract class ModuleApi { } } - //console.log("url", url); - const res = await fetch(url, { + const request = new Request(url, { ..._init, method, body, headers }); + return new FetchPromise(request, this.fetcher); + } + + get( + _input: TInput, + _query?: Record | URLSearchParams, + _init?: RequestInit + ) { + return this.request(_input, _query, { + ..._init, + method: "GET" + }); + } + + post(_input: TInput, body?: any, _init?: RequestInit) { + return this.request(_input, undefined, { + ..._init, + body, + method: "POST" + }); + } + + patch(_input: TInput, body?: any, _init?: RequestInit) { + return this.request(_input, undefined, { + ..._init, + body, + method: "PATCH" + }); + } + + put(_input: TInput, body?: any, _init?: RequestInit) { + return this.request(_input, undefined, { + ..._init, + body, + method: "PUT" + }); + } + + delete(_input: TInput, _init?: RequestInit) { + return this.request(_input, undefined, { + ..._init, + method: "DELETE" + }); + } +} + +class FetchPromise implements Promise { + // @ts-ignore + [Symbol.toStringTag]: "FetchPromise"; + + constructor( + public request: Request, + protected fetcher = fetch + ) {} + + async execute() { + const res = await this.fetcher(this.request); let resBody: any; let resData: any; @@ -108,60 +169,30 @@ export abstract class ModuleApi { }; } - protected async get( - _input: string | (string | number | PrimaryFieldType)[], - _query?: Record | URLSearchParams, - _init?: RequestInit - ) { - return this.request(_input, _query, { - ..._init, - method: "GET" - }); + // biome-ignore lint/suspicious/noThenProperty: it's a promise :) + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null | undefined, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null | undefined + ): Promise { + return this.execute().then(onfulfilled as any, onrejected); } - protected async post( - _input: string | (string | number | PrimaryFieldType)[], - body?: any, - _init?: RequestInit - ) { - return this.request(_input, undefined, { - ..._init, - body, - method: "POST" - }); + catch( + onrejected?: ((reason: any) => TResult | PromiseLike) | null | undefined + ): Promise { + return this.then(undefined, onrejected); } - protected async patch( - _input: string | (string | number | PrimaryFieldType)[], - body?: any, - _init?: RequestInit - ) { - return this.request(_input, undefined, { - ..._init, - body, - method: "PATCH" - }); - } - - protected async put( - _input: string | (string | number | PrimaryFieldType)[], - body?: any, - _init?: RequestInit - ) { - return this.request(_input, undefined, { - ..._init, - body, - method: "PUT" - }); - } - - protected async delete( - _input: string | (string | number | PrimaryFieldType)[], - _init?: RequestInit - ) { - return this.request(_input, undefined, { - ..._init, - method: "DELETE" - }); + finally(onfinally?: (() => void) | null | undefined): Promise { + return this.then( + (value) => { + onfinally?.(); + return value; + }, + (reason) => { + onfinally?.(); + throw reason; + } + ); } }