@proofkit/fmdapi
Version:
FileMaker Data API client
311 lines (278 loc) • 9.02 kB
text/typescript
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>;
};
}