import type { PrimaryFieldType } from "core"; import { isDebug } from "core/env"; import { encodeSearch } from "core/utils/reqres"; export type { PrimaryFieldType }; export type BaseModuleApiOptions = { host: string; basepath?: string; token?: string; headers?: Headers; token_transport?: "header" | "cookie" | "none"; verbose?: boolean; }; /** @deprecated */ export type ApiResponse = { success: boolean; status: number; body: Data; data?: Data extends { data: infer R } ? R : any; res: Response; }; export type TInput = string | (string | number | PrimaryFieldType)[]; export abstract class ModuleApi { protected fetcher: typeof fetch; constructor( protected readonly _options: Partial = {}, fetcher?: typeof fetch, ) { this.fetcher = fetcher ?? fetch; } protected getDefaultOptions(): Partial { return {}; } get options(): Options { return { host: "http://localhost", token: undefined, ...this.getDefaultOptions(), ...this._options, } as Options; } /** * used for SWR invalidation of basepath */ key(): string { return this.options.basepath ?? ""; } protected getUrl(path: string) { const basepath = this.options.basepath ?? ""; return this.options.host + (basepath + "/" + path).replace(/\/{2,}/g, "/").replace(/\/$/, ""); } protected request( _input: TInput, _query?: Record | URLSearchParams, _init?: RequestInit, ): FetchPromise> { const method = _init?.method ?? "GET"; const input = Array.isArray(_input) ? _input.join("/") : _input; let url = this.getUrl(input); if (_query instanceof URLSearchParams) { url += "?" + _query.toString(); } else if (typeof _query === "object") { if (Object.keys(_query).length > 0) { url += "?" + encodeSearch(_query); } } const headers = new Headers(this.options.headers ?? {}); // add init headers for (const [key, value] of Object.entries(_init?.headers ?? {})) { headers.set(key, value as string); } if (!headers.has("Accept")) { headers.set("Accept", "application/json"); } // only add token if initial headers not provided if (this.options.token && this.options.token_transport === "header") { //console.log("setting token", this.options.token); headers.set("Authorization", `Bearer ${this.options.token}`); } let body: any = _init?.body; if (_init && "body" in _init && ["POST", "PATCH", "PUT"].includes(method)) { const requestContentType = (headers.get("Content-Type") as string) ?? undefined; if (!requestContentType || requestContentType.startsWith("application/json")) { body = JSON.stringify(_init.body); headers.set("Content-Type", "application/json"); } } const request = new Request(url, { ..._init, method, body, headers, }); return new FetchPromise(request, { fetcher: this.fetcher, verbose: this.options.verbose, }); } 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", }); } } export type ResponseObject = Data & { raw: Response; res: Response; data: Data; body: Body; ok: boolean; status: number; toJSON(): Data; }; export function createResponseProxy( raw: Response, body: Body, data?: Data, ): ResponseObject { let actualData: any = data ?? body; const _props = ["raw", "body", "ok", "status", "res", "data", "toJSON"]; // that's okay, since you have to check res.ok anyway if (typeof actualData !== "object") { actualData = {}; } return new Proxy(actualData, { get(target, prop, receiver) { if (prop === "raw" || prop === "res") return raw; if (prop === "body") return body; if (prop === "data") return data; if (prop === "ok") return raw.ok; if (prop === "status") return raw.status; if (prop === "toJSON") { return () => target; } return Reflect.get(target, prop, receiver); }, has(target, prop) { if (_props.includes(prop as string)) { return true; } return Reflect.has(target, prop); }, ownKeys(target) { return Array.from(new Set([...Reflect.ownKeys(target), ..._props])); }, getOwnPropertyDescriptor(target, prop) { if (_props.includes(prop as string)) { return { configurable: true, enumerable: true, value: Reflect.get({ raw, body, ok: raw.ok, status: raw.status }, prop), }; } return Reflect.getOwnPropertyDescriptor(target, prop); }, }) as ResponseObject; } export class FetchPromise> implements Promise { // @ts-ignore [Symbol.toStringTag]: "FetchPromise"; constructor( public request: Request, protected options?: { fetcher?: typeof fetch; verbose?: boolean; }, ) {} get verbose() { return this.options?.verbose ?? false; } async execute(): Promise> { // delay in dev environment isDebug() && (await new Promise((resolve) => setTimeout(resolve, 200))); const fetcher = this.options?.fetcher ?? fetch; if (this.verbose) { console.log("[FetchPromise] Request", { method: this.request.method, url: this.request.url, }); } const res = await fetcher(this.request); if (this.verbose) { console.log("[FetchPromise] Response", { res: res, ok: res.ok, status: res.status, }); } let resBody: any; let resData: any; const contentType = res.headers.get("Content-Type") ?? ""; if (contentType.startsWith("application/json")) { resBody = await res.json(); if (typeof resBody === "object") { resData = "data" in resBody ? resBody.data : resBody; } } else if (contentType.startsWith("text")) { resBody = await res.text(); } else { resBody = res.body; } return createResponseProxy(res, resBody, resData); } // 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); } catch( onrejected?: ((reason: any) => TResult | PromiseLike) | null | undefined, ): Promise { return this.then(undefined, onrejected); } finally(onfinally?: (() => void) | null | undefined): Promise { return this.then( (value) => { onfinally?.(); return value; }, (reason) => { onfinally?.(); throw reason; }, ); } path(): string { const url = new URL(this.request.url); return url.pathname; } key(options?: { search: boolean }): string { const url = new URL(this.request.url); return options?.search !== false ? this.path() + url.search : this.path(); } keyArray(options?: { search: boolean }): string[] { const url = new URL(this.request.url); const path = this.path().split("/"); return (options?.search !== false ? [...path, url.searchParams.toString()] : path).filter( Boolean, ); } }