UNPKG

@proofgeist/fmdapi

Version:
338 lines (305 loc) 9.2 kB
import type { AllLayoutsMetadataResponse, CreateResponse, DeleteResponse, GetResponse, LayoutMetadataResponse, PortalRanges, RawFMResponse, ScriptResponse, ScriptsMetadataResponse, UpdateResponse, } from "../client-types.js"; import { FileMakerError } from "../index.js"; import type { Adapter, BaseRequest, ContainerUploadOptions, CreateOptions, DeleteOptions, FindOptions, GetOptions, LayoutMetadataOptions, ListOptions, UpdateOptions, } from "./core.js"; import type { BaseFetchAdapterOptions, GetTokenArguments, } from "./fetch-base-types.js"; export type ExecuteScriptOptions = BaseRequest & { script: string; scriptParam?: string; }; export class BaseFetchAdapter implements Adapter { protected server: string; protected db: string; private refreshToken: boolean; baseUrl: URL; constructor(options: BaseFetchAdapterOptions & { refreshToken?: boolean }) { this.server = options.server; this.db = options.db; this.refreshToken = options.refreshToken ?? false; this.baseUrl = new URL( `${this.server}/fmi/data/vLatest/databases/${this.db}`, ); if (this.db === "") throw new Error("Database name is required"); } // eslint-disable-next-line @typescript-eslint/no-unused-vars protected getToken = async (args?: GetTokenArguments): Promise<string> => { // method must be implemented in subclass throw new Error("getToken method not implemented by Fetch Adapter"); }; protected request = async (params: { url: string; body?: object | FormData; query?: Record<string, string>; method?: string; retry?: boolean; portalRanges?: PortalRanges; timeout?: number; fetchOptions?: RequestInit; }): Promise<unknown> => { const { query, body, method = "GET", retry = false, fetchOptions = {}, } = params; const url = new URL(`${this.baseUrl}${params.url}`); if (query) { const { _sort, ...rest } = query; const searchParams = new URLSearchParams(rest); if (query.portalRanges && typeof query.portalRanges === "object") { for (const [portalName, value] of Object.entries( query.portalRanges as PortalRanges, )) { if (value) { value.offset && value.offset > 0 && searchParams.set( `_offset.${portalName}`, value.offset.toString(), ); value.limit && searchParams.set(`_limit.${portalName}`, value.limit.toString()); } } } if (_sort) { searchParams.set("_sort", JSON.stringify(_sort)); } searchParams.delete("portalRanges"); url.search = searchParams.toString(); } if (body && "portalRanges" in body) { for (const [portalName, value] of Object.entries( body.portalRanges as PortalRanges, )) { if (value) { value.offset && value.offset > 0 && url.searchParams.set( `_offset.${portalName}`, value.offset.toString(), ); value.limit && url.searchParams.set( `_limit.${portalName}`, value.limit.toString(), ); } } delete body.portalRanges; } const controller = new AbortController(); let timeout: NodeJS.Timeout | null = null; if (params.timeout) timeout = setTimeout(() => controller.abort(), params.timeout); const token = await this.getToken({ refresh: retry }); const headers = new Headers(fetchOptions?.headers); headers.set("Authorization", `Bearer ${token}`); // Only set Content-Type for JSON bodies if (!(body instanceof FormData)) { headers.set("Content-Type", "application/json"); } const res = await fetch(url.toString(), { ...fetchOptions, method, body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined, headers, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore signal: controller.signal, }); if (timeout) clearTimeout(timeout); let respData: RawFMResponse; try { respData = await res.json(); } catch { respData = {}; } if (!res.ok) { if ( respData?.messages?.[0].code === "952" && !retry && this.refreshToken ) { // token expired, get new token and retry once return this.request({ ...params, retry: true }); } else { throw new FileMakerError( respData?.messages?.[0].code ?? "500", `Filemaker Data API failed with (${res.status}): ${JSON.stringify( respData, null, 2, )}`, ); } } return respData.response; }; public list = async (opts: ListOptions): Promise<GetResponse> => { const { data, layout } = opts; const resp = await this.request({ url: `/layouts/${layout}/records`, query: data as Record<string, string>, fetchOptions: opts.fetch, timeout: opts.timeout, }); return resp as GetResponse; }; public get = async (opts: GetOptions): Promise<GetResponse> => { const { data, layout } = opts; const resp = await this.request({ url: `/layouts/${layout}/records/${data.recordId}`, fetchOptions: opts.fetch, timeout: opts.timeout, }); return resp as GetResponse; }; public find = async (opts: FindOptions): Promise<GetResponse> => { const { data, layout } = opts; const resp = await this.request({ url: `/layouts/${layout}/_find`, body: data, method: "POST", fetchOptions: opts.fetch, timeout: opts.timeout, }); return resp as GetResponse; }; public create = async (opts: CreateOptions): Promise<CreateResponse> => { const { data, layout } = opts; const resp = await this.request({ url: `/layouts/${layout}/records`, body: data, method: "POST", fetchOptions: opts.fetch, timeout: opts.timeout, }); return resp as CreateResponse; }; public update = async (opts: UpdateOptions): Promise<UpdateResponse> => { const { data: { recordId, ...data }, layout, } = opts; const resp = await this.request({ url: `/layouts/${layout}/records/${recordId}`, body: data, method: "PATCH", fetchOptions: opts.fetch, timeout: opts.timeout, }); return resp as UpdateResponse; }; public delete = async (opts: DeleteOptions): Promise<DeleteResponse> => { const { data, layout } = opts; const resp = await this.request({ url: `/layouts/${layout}/records/${data.recordId}`, method: "DELETE", fetchOptions: opts.fetch, timeout: opts.timeout, }); return resp as DeleteResponse; }; public layoutMetadata = async ( opts: LayoutMetadataOptions, ): Promise<LayoutMetadataResponse> => { return (await this.request({ url: `/layouts/${opts.layout}`, fetchOptions: opts.fetch, timeout: opts.timeout, })) as LayoutMetadataResponse; }; /** * Execute a script within the database */ public executeScript = async (opts: ExecuteScriptOptions) => { const { script, scriptParam, layout } = opts; const resp = await this.request({ url: `/layouts/${layout}/script/${script}`, query: scriptParam ? { "script.param": scriptParam } : undefined, fetchOptions: opts.fetch, timeout: opts.timeout, }); return resp as ScriptResponse; }; /** * Returns a list of available layouts on the database. */ public layouts = async (opts?: Omit<BaseRequest, "layout">) => { return (await this.request({ url: "/layouts", fetchOptions: opts?.fetch, timeout: opts?.timeout, })) as AllLayoutsMetadataResponse; }; /** * Returns a list of available scripts on the database. */ public scripts = async (opts?: Omit<BaseRequest, "layout">) => { return (await this.request({ url: "/scripts", fetchOptions: opts?.fetch, timeout: opts?.timeout, })) as ScriptsMetadataResponse; }; public containerUpload = async (opts: ContainerUploadOptions) => { let url = `/layouts/${opts.layout}/records/${opts.data.recordId}/containers/${opts.data.containerFieldName}`; if (opts.data.repetition) url += `/${opts.data.repetition}`; const formData = new FormData(); formData.append("upload", opts.data.file); await this.request({ url, method: "POST", body: formData, timeout: opts.timeout, fetchOptions: opts.fetch, }); }; /** * Set global fields for the current session */ public globals = async ( opts: Omit<BaseRequest, "layout"> & { globalFields: Record<string, string | number>; }, ) => { return (await this.request({ url: "/globals", method: "PATCH", body: { globalFields: opts.globalFields }, fetchOptions: opts?.fetch, timeout: opts?.timeout, })) as Record<string, never>; }; }