fm-data-api-client
Version:
FileMaker Data API Client
363 lines (357 loc) • 12.9 kB
JavaScript
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { DateTimeFormatter, LocalDate, LocalTime, LocalDateTime } from '@js-joda/core';
class Layout {
layout;
client;
constructor(layout, client) {
this.layout = layout;
this.client = client;
}
async create(fieldData, params = {}) {
const request = { fieldData };
for (const [key, value] of Object.entries(params)) {
request[key] = value;
}
return this.client.request(`layouts/${this.layout}/records`, {
method: 'POST',
body: JSON.stringify(request),
});
}
async update(recordId, fieldData, params = {}) {
const request = { fieldData };
for (const [key, value] of Object.entries(params)) {
request[key] = value;
}
return this.client.request(`layouts/${this.layout}/records/${recordId}`, {
method: 'PATCH',
body: JSON.stringify(request),
});
}
async delete(recordId, params = {}) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
searchParams.set(key, value);
}
return this.client.request(`layouts/${this.layout}/records/${recordId}?${searchParams.toString()}`, { method: 'DELETE' });
}
async upload(file, recordId, fieldName, fieldRepetition = 1) {
const form = new FormData();
if (typeof file === 'string') {
const filename = path.basename(file);
const buffer = await readFile(file);
form.append('upload', new Blob([buffer]), filename);
}
else {
form.append('upload', new Blob([file.buffer]), file.name);
}
await this.client.request(`layouts/${this.layout}/records/${recordId}/containers/${fieldName}/${fieldRepetition}`, {
method: 'POST',
body: form,
});
}
async get(recordId, params = {}) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
switch (key) {
case 'portalRanges':
this.addPortalRangesToRequest(value, searchParams);
break;
default:
searchParams.set(key, value);
}
}
return this.client.request(`layouts/${this.layout}/records/${recordId}?${searchParams.toString()}`);
}
async range(params = {}) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
switch (key) {
case 'offset':
case 'limit':
searchParams.set(`_${key}`, value.toString());
break;
case 'sort':
searchParams.set('_sort', JSON.stringify(Array.isArray(value) ? value : [value]));
break;
case 'portalRanges':
this.addPortalRangesToRequest(value, searchParams);
break;
default:
searchParams.set(key, value);
}
}
return this.client.request(`layouts/${this.layout}/records?${searchParams.toString()}`);
}
async find(query, params = {}, ignoreEmptyResult = false) {
const request = {
query: (Array.isArray(query) ? query : [query]).map(query => ({
...query,
omit: query.omit?.toString(),
})),
};
for (const [key, value] of Object.entries(params)) {
switch (key) {
case 'offset':
case 'limit':
request[key] = value;
break;
case 'sort':
request.sort = Array.isArray(value) ? value : [value];
break;
case 'portalRanges':
this.addPortalRangesToRequest(value, request);
break;
default:
request[key] = value;
}
}
try {
return await this.client.request(`layouts/${this.layout}/_find`, {
method: 'POST',
body: JSON.stringify(request),
});
}
catch (e) {
if (ignoreEmptyResult && e instanceof FileMakerError && e.code === '401') {
return {
data: [],
dataInfo: {
foundCount: 0,
returnedCount: 0,
totalRecordCount: 0,
},
};
}
throw e;
}
}
/**
* The script parameter will be in the query parameter so do not send sensitive information in it.
*/
async executeScript(scriptName, scriptParam) {
const searchParams = new URLSearchParams();
if (scriptParam) {
searchParams.set('script.param', scriptParam);
}
return await this.client.request(`layouts/${this.layout}/script/${encodeURI(scriptName)}?${searchParams.toString()}`);
}
addPortalRangesToRequest(portalRanges, request) {
for (const [portalName, range] of Object.entries(portalRanges)) {
if (!range) {
continue;
}
if (range.offset !== undefined) {
if (request instanceof URLSearchParams) {
request.set(`_offset.${portalName}`, range.offset.toString());
}
else {
request[`offset.${portalName}`] = range.offset;
}
}
if (range.limit !== undefined) {
if (request instanceof URLSearchParams) {
request.set(`_limit.${portalName}`, range.limit.toString());
}
else {
request[`limit.${portalName}`] = range.limit;
}
}
}
}
}
class FileMakerError extends Error {
code;
constructor(code, message) {
super(message);
this.code = code;
}
}
class Client {
uri;
database;
username;
password;
token = null;
lastCall = 0;
constructor(uri, database, username, password) {
this.uri = uri;
this.database = database;
this.username = username;
this.password = password;
}
layout(layout) {
return new Layout(layout, this);
}
async request(path, request, retryOnInvalidToken = true) {
const authorizedRequest = Client.injectHeaders(new Headers({
'Content-Type': 'application/json',
'Authorization': `Bearer ${await this.getToken()}`,
}), request);
const response = await fetch(`${this.uri}/fmi/data/v1/databases/${this.database}/${path}`, authorizedRequest);
if (!response.ok) {
const data = await response.json();
if (data.messages[0].code === '952' && retryOnInvalidToken) {
this.token = null;
return this.request(path, request, false);
}
throw new FileMakerError(data.messages[0].code, data.messages[0].message);
}
this.lastCall = Date.now();
return (await response.json()).response;
}
async requestContainer(containerUrl, request) {
if (!containerUrl.toLowerCase().startsWith(this.uri.toLowerCase())) {
throw new Error('Container url must start with the same url as the FM host');
}
const token = await this.getToken();
const authorizedRequest = Client.injectHeaders(new Headers({
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
}), request);
authorizedRequest.redirect = 'manual';
const response = await fetch(containerUrl, authorizedRequest);
if (response.status === 302 && response.headers.has('set-cookie')) {
const redirectRequest = Client.injectHeaders(new Headers({
'cookie': response.headers.get('set-cookie') ?? '',
}), request);
return this.requestContainer(containerUrl, redirectRequest);
}
if (!response.ok) {
throw new Error(`Failed to download container ${response.status}`);
}
return {
contentType: response.headers.get('Content-Type'),
buffer: await response.blob(),
};
}
async clearToken() {
if (!this.token) {
return;
}
await fetch(`${this.uri}/fmi/data/v1/databases/${this.database}/sessions/${this.token}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
this.token = null;
this.lastCall = 0;
}
async getToken() {
if (this.token !== null && Date.now() - this.lastCall < 14 * 60 * 1000) {
return this.token;
}
const headers = {
'Content-Type': 'application/json',
'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`,
};
const response = await fetch(`${this.uri}/fmi/data/v1/databases/${this.database}/sessions`, {
method: 'POST',
body: '{}',
headers,
});
if (!response.ok) {
const data = await response.json();
throw new FileMakerError(data.messages[0].code, data.messages[0].message);
}
this.token = response.headers.get('X-FM-Data-Access-Token');
if (!this.token) {
throw new Error('Could not get token');
}
this.lastCall = Date.now();
return this.token;
}
static injectHeaders(headers, request) {
if (!request) {
request = {};
}
request.headers = new Headers(request.headers);
for (const header of headers) {
// If form data is set, skip setting a content-type header in order to let fetch
// generate one with a boundary instead.
if (header[0] === 'content-type' && request.body instanceof FormData) {
continue;
}
if (!request.headers.has(header[0])) {
request.headers.append(header[0], header[1]);
}
}
return request;
}
}
/**
* Quotes a string for use in queries.
*/
const quote = (value) => value.replace(/([\\=!<≤>≥…?@#*"~]|\/\/)/g, '\\$1');
/**
* Parses a FileMaker value as a number.
*
* This utility function works the same way as FileMaker when it comes to interpret string values as numbers. An empty
* string will be interpreted as <pre>null</pre>.
*/
const parseNumber = (value) => {
if (typeof value === 'number') {
return value;
}
value = value.replace(/^[^\d\-.]*(-?)([^.]*)(\.?)(.*)$/g, (substring, ...args) => `${args[0]}${args[1].replace(/[^\d]+/g, '')}`
+ `${args[2]}${args[3].replace(/[^\d]+/g, '')}`);
if (value === '') {
return null;
}
if (value === '-') {
return 0;
}
if (value.startsWith('.')) {
value = `0${value}`;
}
return parseFloat(value);
};
/**
* Parses a FileMaker value as a boolean.
*
* This function will interpret any non-zero and non-empty value as true.
*/
const parseBoolean = (value) => value !== 0 && value !== '0' && value !== '';
/**
* Date utility for working with dates, times and time stamps.
*
* @deprecated Use <pre>js-joda</pre> or another datetime library directly.
*/
class DateUtil {
dateFormatter;
timeFormatter;
timeStampFormatter;
constructor(dateFormat = 'MM/dd/yyyy', timeFormat = 'HH:mm:ss', timeStampFormat = 'MM/dd/yyyy HH:mm:ss') {
this.dateFormatter = DateTimeFormatter.ofPattern(dateFormat);
this.timeFormatter = DateTimeFormatter.ofPattern(timeFormat);
this.timeStampFormatter = DateTimeFormatter.ofPattern(timeStampFormat);
}
parseDate(value) {
return LocalDate.parse(value, this.dateFormatter);
}
parseTime(value) {
return LocalTime.parse(value, this.timeFormatter);
}
parseTimeStamp(value) {
return LocalDateTime.parse(value, this.timeStampFormatter);
}
formatDate(value) {
return value.format(this.dateFormatter);
}
formatTime(value) {
return value.format(this.timeFormatter);
}
formatTimeStamp(value) {
return value.format(this.timeStampFormatter);
}
}
var Utils = /*#__PURE__*/Object.freeze({
__proto__: null,
quote: quote,
parseNumber: parseNumber,
parseBoolean: parseBoolean,
DateUtil: DateUtil
});
export { Client, Layout, Utils as utils };
//# sourceMappingURL=index.esm.js.map