UNPKG

@proofkit/fmdapi

Version:

FileMaker Data API client

311 lines (278 loc) • 9.02 kB
import type { AllLayoutsMetadataResponse, CreateResponse, DeleteResponse, GetResponse, LayoutMetadataResponse, PortalRanges, RawFMResponse, ScriptResponse, ScriptsMetadataResponse, UpdateResponse, } from "../client-types.js"; import { FileMakerError } from "../client-types.js"; import type { Adapter, BaseRequest, ContainerUploadOptions, CreateOptions, DeleteOptions, ExecuteScriptOptions, FindOptions, GetOptions, LayoutMetadataOptions, ListOptions, UpdateOptions, } from "./core.js"; import type { BaseFetchAdapterOptions, GetTokenArguments } from "./fetch-base-types.js"; export class BaseFetchAdapter implements Adapter { protected server: string; protected db: string; private readonly 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"); } } protected getToken = (_args?: GetTokenArguments): Promise<string> => { // method must be implemented in subclass return Promise.reject(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; // Filter out undefined/null values before creating URLSearchParams const filteredRest = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined && v !== null)); const searchParams = new URLSearchParams(filteredRest as Record<string, string>); if (query.portalRanges && typeof query.portalRanges === "object") { for (const [portalName, value] of Object.entries(query.portalRanges as PortalRanges)) { if (value) { if (value.offset && value.offset > 0) { searchParams.set(`_offset.${portalName}`, value.offset.toString()); } if (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) { if (value.offset && value.offset > 0) { url.searchParams.set(`_offset.${portalName}`, value.offset.toString()); } if (value.limit) { url.searchParams.set(`_limit.${portalName}`, value.limit.toString()); } } } body.portalRanges = undefined; } 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"); } let requestBody: string | FormData | undefined; if (body instanceof FormData) { requestBody = body; } else if (body) { requestBody = JSON.stringify(body); } else { requestBody = undefined; } const res = await fetch(url.toString(), { ...fetchOptions, method, body: requestBody, headers, 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 }); } throw new FileMakerError( respData?.messages?.[0].code ?? "500", `Filemaker Data API failed with (${res.status}): ${JSON.stringify(respData, null, 2)}`, ); } return respData.response; }; 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; }; 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; }; 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; }; 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; }; 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; }; 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; }; 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 */ 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. */ 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. */ scripts = async (opts?: Omit<BaseRequest, "layout">) => { return (await this.request({ url: "/scripts", fetchOptions: opts?.fetch, timeout: opts?.timeout, })) as ScriptsMetadataResponse; }; 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 */ 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>; }; }