UNPKG

fm-odata-client

Version:

FileMaker OData client developed by Soliant Consulting

717 lines (712 loc) 22.2 kB
// src/BatchRequest.ts import * as Crypto from "node:crypto"; var BatchRequest = class _BatchRequest { constructor(serviceEndpoint, authorizationHeader, operations) { this.serviceEndpoint = serviceEndpoint; this.authorizationHeader = authorizationHeader; for (const operation of operations) { this.addRequest(operation); } } operations = []; lastChangeset = null; async toRequest() { const boundary = `batch_${Crypto.randomBytes(16).toString("hex")}`; const headers = new Headers({ Authorization: this.authorizationHeader, "OData-Version": "4.0", "Content-Type": `multipart/mixed; boundary=${boundary}` }); const body = (await Promise.all( this.operations.map(async (request) => { if (Array.isArray(request)) { const changesetBoundary = `changeset_${Crypto.randomBytes(16).toString("hex")}`; return [ `--${boundary}`, `Content-Type: multipart/mixed; boundary=${changesetBoundary}`, "", ...await Promise.all( request.map( async (request2) => `--${changesetBoundary}\r ${await _BatchRequest.formatRequest(request2)}` ) ), `--${changesetBoundary}--` ].join("\r\n"); } return `--${boundary}\r ${await _BatchRequest.formatRequest(request)}`; }) )).join("\r\n"); return new Request(`${this.serviceEndpoint}/$batch`, { method: "POST", headers, body: `${body}\r --${boundary}--` }); } static parseMultipartResponse(body, headers) { const boundary = _BatchRequest.getBoundary(headers); const endBoundaryIndex = body.indexOf(`--${boundary}--`); let trimmedBody; if (endBoundaryIndex >= 0) { trimmedBody = body.slice(0, endBoundaryIndex); } else { trimmedBody = body; } const parts = trimmedBody.split(`--${boundary}\r `).slice(1).map(_BatchRequest.splitPart); const responses = []; for (const [headers2, body2] of parts) { const contentType = headers2.get("Content-Type"); if (!contentType) { throw new Error("Multipart part is missing content-type header"); } if (contentType === "application/http") { responses.push(_BatchRequest.parseHttpResponse(body2)); continue; } if (contentType.startsWith("multipart/mixed")) { const changesetResponses = _BatchRequest.parseMultipartResponse(body2, headers2); responses.push(...changesetResponses); continue; } throw new Error(`Unknown content-type: ${contentType}`); } return responses; } static parseHttpResponse(rawResponse) { const firstBreakIndex = rawResponse.indexOf("\r\n"); const statusLine = rawResponse.slice(0, firstBreakIndex); const rawResponseBody = rawResponse.slice(firstBreakIndex + 2); const [, statusCode, ...statusText] = statusLine.split(" "); const [headers, body] = _BatchRequest.splitPart(rawResponseBody); return new Response(body.trim(), { headers, status: Number.parseInt(statusCode, 10), statusText: statusText.join(" ") }); } static splitPart(part) { if (part.startsWith("\r\n")) { return [new Headers(), part.replace(/^\r\n/, "")]; } const breakIndex = part.indexOf("\r\n\r\n"); const rawHeaders = part.slice(0, breakIndex); const rawBody = part.slice(breakIndex + 4); const headers = new Headers(); for (const rawHeader of rawHeaders.split("\r\n")) { const separatorIndex = rawHeader.indexOf(":"); headers.append( rawHeader.slice(0, separatorIndex), rawHeader.slice(separatorIndex + 1).trim() ); } return [headers, rawBody]; } static getBoundary(headers) { const contentType = headers.get("Content-Type"); if (!contentType) { throw new Error("Response is missing Content-Type header"); } const boundaryPart = contentType.split(";").map((part) => part.trim()).find((part) => part.startsWith("boundary=")); if (!boundaryPart) { throw new Error("Content-Type header is missing boundary"); } const [, boundary] = boundaryPart.split("="); return boundary; } static async formatRequest(request) { return [ "Content-Type: application/http", "Content-Transfer-Encoding: binary", "", `${request.method} ${request.url} HTTP/1.1`, ..._BatchRequest.formatRequestHeaders(request.headers), "", request.body ? await _BatchRequest.streamToString(request.body) : "" ].join("\r\n"); } static async streamToString(stream) { const chunks = []; for await (const chunk of stream) { chunks.push(Buffer.from(chunk)); } return Buffer.concat(chunks).toString("utf-8"); } static formatRequestHeaders(headers) { const result = []; for (const [key, value] of headers.entries()) { if (key.toLowerCase() === "authorization") { continue; } result.push(`${key}: ${value}`); } return result; } addRequest(request) { if (request.method === "GET") { this.lastChangeset = null; this.operations.push(request); return; } if (!this.lastChangeset) { this.lastChangeset = []; this.operations.push(this.lastChangeset); } this.lastChangeset.push(request); } }; var BatchRequest_default = BatchRequest; // src/SchemaManager.ts var SchemaManager = class _SchemaManager { constructor(database) { this.database = database; } async createTable(tableName, fields) { return this.database.fetchJson("/FileMaker_Tables", { method: "POST", body: JSON.stringify({ tableName, fields: fields.map(_SchemaManager.compileFieldDefinition) }) }); } async addFields(tableName, fields) { return this.database.fetchJson(`/FileMaker_Tables/${tableName}`, { method: "PATCH", body: JSON.stringify({ fields: fields.map(_SchemaManager.compileFieldDefinition) }) }); } async deleteTable(tableName) { return this.database.fetchNone(`/FileMaker_Tables/${tableName}`, { method: "DELETE" }); } async deleteField(tableName, fieldName) { return this.database.fetchNone(`/FileMaker_Tables/${tableName}/${fieldName}`, { method: "DELETE" }); } async createIndex(tableName, fieldName) { return this.database.fetchJson(`/FileMaker_Indexes/${tableName}`, { method: "POST", body: JSON.stringify({ indexName: fieldName }) }); } async deleteIndex(tableName, fieldName) { return this.database.fetchNone(`/FileMaker_Indexes/${tableName}/${fieldName}`, { method: "DELETE" }); } static compileFieldDefinition(field) { const fieldCopy = { ...field }; let type = fieldCopy.type; if (fieldCopy.type === "string") { type = "varchar"; if (fieldCopy.maxLength !== void 0) { type += `(${fieldCopy.maxLength})`; fieldCopy.maxLength = void 0; } } if (field.repetitions !== void 0) { type += `[${field.repetitions}]`; fieldCopy.repetitions = void 0; } return { ...fieldCopy, type }; } }; var SchemaManager_default = SchemaManager; // src/Table.ts import { URLSearchParams } from "node:url"; var allowedFileTypes = [ "image/gif", "image/png", "image/jpeg", "image/tiff", "application/pdf" ]; var Table = class _Table { constructor(database, name, batched = false) { this.database = database; this.name = name; this.batched = batched; } create(data) { const path = ""; const params = (async () => ({ method: "POST", body: JSON.stringify(await _Table.compileRowData(data)) }))(); if (this.batched) { void this.fetchNone(path, params); return; } return this.fetchJson(path, params); } update(id, data) { const path = `(${_Table.compilePrimaryKey(id)})`; const params = (async () => ({ method: "PATCH", body: JSON.stringify(await _Table.compileRowData(data)) }))(); if (this.batched) { void this.fetchNone(path, params); return; } return this.fetchJson(path, params); } updateMany(filter, data) { const path = ""; const params = (async () => ({ method: "PATCH", search: new URLSearchParams({ $filter: filter }), body: JSON.stringify(await _Table.compileRowData(data)) }))(); if (this.batched) { void this.fetchNone(path, params); return; } return this.fetchJson(path, params); } delete(id) { return this.fetchNone(`(${_Table.compilePrimaryKey(id)})`, { method: "DELETE" }); } deleteMany(filter) { return this.fetchNone("", { method: "DELETE", search: new URLSearchParams({ $filter: filter }) }); } uploadBinary(id, fieldName, data) { return this.fetchNone( `(${_Table.compilePrimaryKey(id)})/${fieldName}`, (async () => ({ method: "PATCH", body: data, contentType: await _Table.getMimeType(data) }))() ); } async count(filter) { return this.fetchJson("/$count", { search: !filter ? void 0 : new URLSearchParams({ $filter: filter }) }); } async fetchById(id) { try { return await this.fetchJson(`(${_Table.compilePrimaryKey(id)})`); } catch (e) { if (e instanceof FetchError && e.statusCode === 404 && e.errorCode === "-1023") { return null; } throw e; } } async fetchFieldValue(id, fieldName) { const fieldData = await this.fetchJson( `(${_Table.compilePrimaryKey(id)})/${fieldName}` ); return fieldData.value; } async fetchFieldBlob(id, fieldName) { return this.fetchBlob(`(${_Table.compilePrimaryKey(id)})/${fieldName}/$value`); } /** * @deprecated Use `fetchFieldBlob` instead. */ async fetchField(id, fieldName) { return this.fetchFieldBlob(id, fieldName); } async fetchOne(params) { const result = await this.query({ ...params, top: 1 }); if (result.length === 0) { return null; } return result[0]; } async query(params) { const searchParams = params ? _Table.compileQuerySearch(params) : new URLSearchParams(); const path = params?.relatedTable ? _Table.compileRelatedTablePath(params.relatedTable) : ""; const response = await this.fetchJson(path, { search: searchParams }); if (params?.count) { return { count: response["@odata.count"], rows: response.value }; } return response.value; } async crossJoin(tables, params) { const searchParams = params ? _Table.compileQuerySearch({ ...params, select: void 0 }) : new URLSearchParams(); const tableNames = [this.name, ...Array.isArray(tables) ? tables : [tables]]; if (params?.select) { searchParams.set( "$expand", Object.entries(params.select).map(([table, fields]) => `${table}($select=${fields.join(",")})`).join(",") ); } const response = await this.database.fetchJson(`/$crossjoin(${tableNames.join(",")})`, { search: searchParams }); if (params?.count) { return { count: response["@odata.count"], rows: response.value }; } return response.value; } async fetchNone(path, params) { return this.database.fetchNone(`/${this.name}${path}`, params); } async fetchJson(path, params = {}) { return this.database.fetchJson(`/${this.name}${path}`, params); } async fetchBlob(path, params = {}) { return this.database.fetchBlob(`/${this.name}${path}`, params); } static async compileRowData(data) { const result = {}; for (let [key, value] of Object.entries(data)) { if (value !== null && typeof value === "object" && !(value instanceof Buffer)) { key = `${key}[${value.repetition}]`; value = value.value; } if (value instanceof Buffer) { await _Table.getMimeType(value); value = value.toString("base64"); } result[key] = value; } return result; } static async getMimeType(data) { const { fileTypeFromBuffer } = await import("file-type"); const fileType = await fileTypeFromBuffer(data); if (!(fileType && allowedFileTypes.includes(fileType.mime))) { throw new Error( `Invalid data, must be one of the following types: ${allowedFileTypes.join(", ")}` ); } return fileType.mime; } static compileQuerySearch(params) { const searchParams = new URLSearchParams(); if (params.filter) { searchParams.set("$filter", params.filter); } if (params.orderBy) { searchParams.set("$orderby", _Table.compileOrderBy(params.orderBy)); } if (params.top) { searchParams.set("$top", params.top.toString()); } if (params.skip) { searchParams.set("$skip", params.skip.toString()); } if (params.count) { searchParams.set("$count", "true"); } if (params.select) { searchParams.set("$select", params.select.join(",")); } return searchParams; } static compileOrderBy(orderBy) { if (typeof orderBy === "string") { return orderBy; } if (Array.isArray(orderBy)) { return orderBy.map(_Table.compileOrderBy).join(","); } if (!orderBy.direction) { return orderBy.field; } return `${orderBy.field} ${orderBy.direction}`; } static compilePrimaryKey(id) { if (Array.isArray(id)) { return id.map(_Table.compilePrimaryKey).join(","); } if (typeof id === "string") { return `'${id}'`; } return id.toString(); } static compileRelatedTablePath(relatedTable) { if (Array.isArray(relatedTable)) { return `/${relatedTable.map(encodeURIComponent).join("/")}`; } if (typeof relatedTable === "string") { return `/${encodeURIComponent(relatedTable)}`; } return `(${_Table.compilePrimaryKey( relatedTable.primaryKey )})${_Table.compileRelatedTablePath(relatedTable.table)}`; } }; var Table_default = Table; // src/Database.ts var Database = class _Database { constructor(connection, name, batched = false) { this.connection = connection; this.name = name; this.batched = batched; } async batch(executor) { const batchConnection = this.connection.batchConnection(this.name); const database = new _Database(batchConnection, this.name, true); const promises = executor(database); await batchConnection.executeBatch(); if (!promises) { return []; } return Promise.all(promises); } table(tableName) { return new Table_default(this, tableName, this.batched); } schemaManager() { if (this.batched) { throw new Error("Schema alterations are not allowed in a batch operation"); } return new SchemaManager_default(this); } async listTables() { if (this.batched) { throw new Error("Tables cannot be listed in a batch operation"); } const response = await this.fetchJson(""); return response.value; } async getMetadata() { if (this.batched) { throw new Error("Metadata cannot be retrieved in a batch operation"); } const response = await this.fetchJson("/$metadata"); if (!(this.name in response)) { throw new Error("Response did not include any table information"); } return response[this.name]; } async runScript(scriptName, scriptParam) { if (this.batched) { throw new Error("Script execution is not allowed in a batch operation"); } const response = await this.fetchJson( `/Script.${scriptName}`, { method: "POST", body: JSON.stringify({ scriptParameterValue: scriptParam }) } ); return response.scriptResult; } /** * @internal */ async fetchNone(path, params = {}) { return this.connection.fetchNone(`/${this.name}${path}`, params); } /** * @internal */ async fetchJson(path, params = {}) { return this.connection.fetchJson(`/${this.name}${path}`, params); } /** * @internal */ async fetchBlob(path, params = {}) { return this.connection.fetchBlob(`/${this.name}${path}`, params); } }; var Database_default = Database; // src/Connection.ts var FetchError = class extends Error { constructor(message, errorCode, statusCode) { super(message); this.errorCode = errorCode; this.statusCode = statusCode; } }; var Connection = class _Connection { constructor(hostname, authentication, options = {}) { this.hostname = hostname; this.authentication = authentication; let finalOptions; if (typeof options === "boolean") { console.info("Passing laxParsing directly is deprecated, use options object instead."); finalOptions = { laxParsing: options }; } else { finalOptions = options; } this.options = finalOptions; } batch = null; options; async listDatabases() { if (this.batch) { throw new Error("Databases cannot be listed from a batched connection"); } const response = await this.fetchJson(""); return response.value; } database(name) { if (this.batch) { throw new Error("Database objects cannot be created from a batched connection"); } return new Database_default(this, name); } /** * @internal */ batchConnection(databaseName) { const connection = new _Connection(this.hostname, this.authentication); connection.batch = { databaseName, operations: [] }; return connection; } /** * @internal */ async executeBatch() { if (!this.batch) { throw new Error("A batch has not been started"); } if (this.batch.operations.length === 0) { return; } const batchRequest = new BatchRequest_default( `${this.options.disableSsl ? "http" : "https"}://${this.hostname}/fmi/odata/v4/${this.batch.databaseName}`, await this.authentication.getAuthorizationHeader(), await Promise.all( this.batch.operations.map( async (operation) => this.createRequest(operation.path, operation.params) ) ) ); const response = await fetch(await batchRequest.toRequest()); if (!response.ok) { throw new Error("Batch request failed"); } const body = await response.text(); const responses = BatchRequest_default.parseMultipartResponse(body, response.headers); for (let i = 0; i < this.batch.operations.length; ++i) { if (!responses[i]) { this.batch.operations[i].reject( new Error("No matching response in batch response") ); continue; } this.batch.operations[i].resolve(responses[i]); } } /** * @internal */ async fetchNone(path, params = {}) { await this.fetch(path, params); } /** * @internal */ async fetchJson(path, params = {}) { const response = await this.fetch(path, params); if (response.status === 204) { throw new Error("Response included no content"); } return await this.parseResponseJson(response); } /** * @internal */ async fetchBlob(path, params = {}) { const response = await this.fetch(path, params); const contentType = response.headers.get("Content-Type"); const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); return { buffer, type: contentType ?? "application/octet-stream" }; } async fetch(path, params) { let response; if (this.batch) { const batch = this.batch; response = await new Promise((resolve, reject) => { batch.operations.push({ path, params, resolve, reject }); }); } else { const request = await this.createRequest(path, params); response = await fetch(request); } if (!response.ok) { let errorCode = "unknown"; let errorMessage = "An unknown error occurred"; try { const data = await this.parseResponseJson(response); errorCode = data.error.code; errorMessage = data.error.message; } catch { } throw new FetchError(errorMessage, errorCode, response.status); } return response; } async createRequest(path, params) { const resolvedParams = await params; let url = `${this.options.disableSsl ? "http" : "https"}://${this.hostname}/fmi/odata/v4${path}`; if (resolvedParams.search) { url += `?${_Connection.stringifySearch(resolvedParams.search)}`; } const headers = new Headers({ Authorization: await this.authentication.getAuthorizationHeader(), Accept: "application/json" }); if (resolvedParams.contentType) { headers.set("Content-Type", resolvedParams.contentType); } else if (resolvedParams.method === "POST" || resolvedParams.method === "PATCH") { headers.set("Content-Type", "application/json"); } return new Request(url, { keepalive: true, method: resolvedParams.method, body: resolvedParams.body, headers }); } async parseResponseJson(response) { if (!this.options.laxParsing) { return await response.json(); } const json = await response.text(); const cleanedJson = json.replace(/[\u0000-\u0009\u000b-\u001f]/g, "").replace(/"(?:(?=(\\?))\1.)*?"/gs, (substring) => { return substring.replace(/(?<!\\)((?:\\\\)*)\n/g, "$1\\n"); }); return await JSON.parse(cleanedJson); } static stringifySearch(search) { const specialTokens = { "%24": "$", "+": "%20", "%2F": "/", "%3D": "=", "%2C": "," }; return search.toString().replace( /(%24|\+|%2F|%3D|%2C)/g, (match) => specialTokens[match] ); } }; var Connection_default = Connection; // src/BasicAuth.ts var BasicAuth = class { authorizationHeader; constructor(username, password) { this.authorizationHeader = Promise.resolve( `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` ); } async getAuthorizationHeader() { return this.authorizationHeader; } }; var BasicAuth_default = BasicAuth; export { BasicAuth_default as BasicAuth, Connection_default as Connection, Database_default as Database, FetchError, SchemaManager_default as SchemaManager, Table_default as Table, allowedFileTypes }; //# sourceMappingURL=index.js.map