fm-odata-client
Version:
FileMaker OData client developed by Soliant Consulting
705 lines (700 loc) • 21.9 kB
JavaScript
// 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 fetchField(id, fieldName) {
return this.fetchBlob(`(${_Table.compilePrimaryKey(id)})/${fieldName}/$value`);
}
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(/"(?:(?=(\\?))\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