UNPKG

fm-data-api-client

Version:
363 lines (357 loc) 12.9 kB
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