UNPKG

@jd-data-limited/easy-fm

Version:

easy-fm is a Node.js module that allows you to interact with a [FileMaker database stored](https://www.claris.com/filemaker/) on a [FileMaker server](https://www.claris.com/filemaker/server/). This module interacts with your server using the [FileMaker

202 lines (201 loc) 7.84 kB
/* * Copyright (c) 2023-2024. See LICENSE file for more information */ import { EventEmitter } from 'events'; import { generateAuthorizationHeaders } from './generateAuthorizationHeaders.js'; import { FMError } from '../FMError.js'; import { Layout } from '../layouts/layout.js'; import fetch from 'node-fetch'; // @ts-expect-error - fetchWithCookies does not have available typescript types import fetchWithCookies, { CookieJar } from 'node-fetch-cookies'; /** * Represents a database connection. * @template T - The structure of the database. */ export class Database extends EventEmitter { _token = ''; host; connection_details; cookies = new CookieJar(); name; debug; #layoutCache = new Map(); constructor(host, conn) { super(); this.host = host; this.name = conn.database; this.connection_details = conn; this.debug = conn.debug ?? false; } // eslint-disable-next-line @typescript-eslint/explicit-function-return-type generateExternalSourceLogin(data) { if (data.credentials.method === 'filemaker') { const _data = data.credentials; return { database: data.database, username: _data.username, password: _data.password }; } else { throw new Error('Not yet supported login method'); } } /** * Logs out the user by deleting the current session token. * Throws an error if the user is not logged in. * * @returns {Promise<void>} A promise that resolves with no value once the logout is successful. * @throws {Error} Throws an error if the user is not logged in. */ async logout() { if (this.token === '') throw new Error('Not logged in'); const _fetch = await fetch(`${this.endpoint}/sessions/${this.token}`, { method: 'DELETE', headers: { 'content-type': 'application/json' } }); await _fetch.json(); this._token = ''; } /** * Logs in to the database. Not required, as this is often done automatically * * @param {boolean} [forceLogin=false] - Whether to force login even if already logged in. * @throws {Error} - Throws an error if already logged in and forceLogin is false. * @throws {FMError} - Throws an FMError if login fails. * @return {Promise<string>} - Returns a promise that resolves to the access token upon successful login. */ async login(forceLogin = false) { if (this.token !== '' && !forceLogin) return; // Reset cookies this.cookies = new CookieJar(); await this.host.getMetadata(); if (this.connection_details.credentials.method === 'token') { this._token = (this.connection_details.credentials).token; return this.token; } const url = new URL(`${this.endpoint}/sessions`); url.hostname = this.host.hostname; const res = await fetch(url, { method: 'POST', headers: generateAuthorizationHeaders(this.connection_details.credentials), body: JSON.stringify({ fmDataSource: this.connection_details.externalSources.map(i => { const _i = i; return this.generateExternalSourceLogin(_i); }) }) }); const _res = (await res.json()); if (res.status === 200) { this._token = res.headers.get('x-fm-data-access-token') ?? ''; return this._token; } else { throw new FMError(_res.messages[0].code, _res.status, res); } } get token() { return this._token; } /** * Returns the endpoint URL for the database connection. * * @returns {string} The endpoint URL. */ get endpoint() { return `${this.host.protocol}//${this.host.hostname}/fmi/data/v2/databases/${this.name}`; } async _apiRequestRaw(url, options = {}, autoRelogin = true) { if (this.debug) { console.log(`EASYFM DEBUG: ${JSON.stringify(options)} ${url instanceof URL ? url.toString() : typeof url === 'string' ? url : url.url}`); } const urlParsed = (url instanceof URL ? url : typeof url === 'string' ? new URL(url) : new URL(url.url)); const reqIsToDBHost = urlParsed.hostname === this.host.hostname && urlParsed.pathname.startsWith("/fmi/data"); if (reqIsToDBHost && this.token === '') await this.login(true); if (!options.headers) options.headers = {}; if (reqIsToDBHost) options.headers.authorization = 'Bearer ' + this._token; const _fetch = options.useCookieJar ? await fetchWithCookies(this.cookies, url, options) : await fetch(url, options); if (!_fetch.ok && (!options.retries || options.retries > 0)) { if (this.debug) { console.log(`EASYFM DEBUG: RE-ATTEMPTING REQUEST (${_fetch.status}) ${url instanceof URL ? url.toString() : typeof url === 'string' ? url : url.url}`); } return await this._apiRequestRaw(url, { ...options, retries: (options?.retries ?? 1) - 1 }); } else if (_fetch.status === 401 && reqIsToDBHost && autoRelogin) { await this.login(true); return await this._apiRequestRaw(url, options, false); } else return _fetch; } async _apiRequestJSON(url, options = {}) { if (!options.headers) options.headers = {}; options.headers['content-type'] = options.headers['content-type'] ? options.headers['content-type'] : 'application/json'; const _fetch = await this._apiRequestRaw(url, options); const data = await _fetch.json(); // Remove response if it is empty. This makes checking for an empty response easier if (data.response && Object.keys(data.response).length === 0) delete data.response; // console.log(data.messages[0]) if (data.messages[0].code !== '0') { throw new FMError(data.messages[0].code, _fetch.status, data); } data.httpStatus = _fetch.status; return data; } /** * Retrieves a list of layouts in the current FileMaker database. * * @returns {Promise<Layout[]>} A promise that resolves to an array of Layout objects. * @throws {FMError} If there was an error retrieving the layouts. */ async listLayouts(page = 0) { const req = await this._apiRequestJSON(`${this.endpoint}/layouts?page=${encodeURIComponent(page)}`); if (!req.response) throw new FMError(req.messages[0].code, req.httpStatus, req.messages[0].message); const cycleLayoutNames = (layouts) => { let names = []; for (const layout of layouts) { if (layout.folderLayoutNames) names = names.concat(cycleLayoutNames(layout.folderLayoutNames)); else names.push(layout.name); } return names; }; return cycleLayoutNames(req.response.layouts).map(layout => new Layout(this, layout)); } layout(name) { let layout = this.#layoutCache.get(name); if (layout) return layout; layout = new Layout(this, name); this.#layoutCache.set(name, layout); return layout; } clearLayoutCache() { this.#layoutCache.clear(); } script(name, parameter = '') { return { name, parameter }; } }