@proofkit/fmodata
Version:
FileMaker OData API client
321 lines (320 loc) • 12.9 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import createClient, { TimeoutError, AbortError, NetworkError, RetryLimitError, CircuitOpenError } from "@fetchkit/ffetch";
import { Layer, Effect } from "effect";
import { get } from "es-toolkit/compat";
import { withRetryPolicy, withSpan, runAsResult } from "../effect.js";
import { ResponseParseError, SchemaLockedError, ODataError, HTTPError } from "../errors.js";
import { createLogger } from "../logger.js";
import { HttpClient, ODataConfig, ODataLogger } from "../services.js";
import { getAcceptHeader } from "../types.js";
import { mergePreferHeaderValues } from "./builders/mutation-helpers.js";
import { ClarisIdAuthManager } from "./claris-id.js";
import { Database } from "./database.js";
import { normalizeDatabasePath } from "./database-name.js";
import { safeJsonParse } from "./sanitize-json.js";
const TRAILING_SLASH_REGEX = /\/+$/;
class FMServerConnection {
constructor(config) {
__publicField(this, "fetchClient");
__publicField(this, "serverUrl");
__publicField(this, "auth");
__publicField(this, "normalizeDatabaseName", true);
__publicField(this, "useEntityIds", false);
__publicField(this, "includeSpecialColumns", false);
__publicField(this, "logger");
__publicField(this, "clarisIdAuthManager");
__publicField(this, "hasWarnedAboutOttoDatabaseNormalization", false);
/** @internal Stored so credential-override flows can inherit non-auth config. */
__publicField(this, "_fetchClientOptions");
this.logger = createLogger(config.logger);
this._fetchClientOptions = config.fetchClientOptions;
this.fetchClient = createClient({
retries: 0,
...config.fetchClientOptions
});
const url = new URL(config.serverUrl);
if (url.protocol !== "https:") {
url.protocol = "https:";
}
url.pathname = url.pathname.replace(TRAILING_SLASH_REGEX, "");
this.serverUrl = url.toString().replace(TRAILING_SLASH_REGEX, "");
this.auth = config.auth;
this.clarisIdAuthManager = "clarisId" in config.auth ? new ClarisIdAuthManager({
username: config.auth.clarisId.username,
password: config.auth.clarisId.password
}) : null;
}
/**
* @internal
* Sets whether to use FileMaker entity IDs (FMFID/FMTID) in requests
*/
_setUseEntityIds(useEntityIds) {
this.useEntityIds = useEntityIds;
}
/**
* @internal
* Gets whether to use FileMaker entity IDs (FMFID/FMTID) in requests
*/
_getUseEntityIds() {
return this.useEntityIds;
}
/**
* @internal
* Sets whether to include special columns (ROWID and ROWMODID) in requests
*/
_setIncludeSpecialColumns(includeSpecialColumns) {
this.includeSpecialColumns = includeSpecialColumns;
}
/**
* @internal
* Gets whether to include special columns (ROWID and ROWMODID) in requests
*/
_getIncludeSpecialColumns() {
return this.includeSpecialColumns;
}
/**
* @internal
* Gets the base URL for OData requests
*/
_getBaseUrl() {
return `${this.serverUrl}${"apiKey" in this.auth ? "/otto" : ""}/fmi/odata/v4`;
}
_getAuthorizationHeader(fetchHandler) {
if ("apiKey" in this.auth) {
return Promise.resolve(`Bearer ${this.auth.apiKey}`);
}
if ("clarisId" in this.auth) {
if (!this.clarisIdAuthManager) {
throw new Error("Claris ID auth manager was not initialized");
}
return this.clarisIdAuthManager.getAuthorizationHeader(fetchHandler);
}
return Promise.resolve(`Basic ${btoa(`${this.auth.username}:${this.auth.password}`)}`);
}
/**
* @internal
* Gets the logger instance
*/
_getLogger() {
return this.logger;
}
/**
* @internal
* Returns the Effect Layer for this connection, composing HttpClient, ODataConfig, and ODataLogger services.
*/
_getLayer() {
const httpLayer = Layer.succeed(HttpClient, {
request: (url, options) => this._makeRequestEffect(url, options)
});
const configLayer = Layer.succeed(ODataConfig, {
baseUrl: this._getBaseUrl(),
databaseName: "",
normalizeDatabaseName: this.normalizeDatabaseName,
useEntityIds: this.useEntityIds,
includeSpecialColumns: this.includeSpecialColumns
});
const loggerLayer = Layer.succeed(ODataLogger, {
logger: this.logger
});
return Layer.mergeAll(httpLayer, configLayer, loggerLayer);
}
/**
* @internal
* Classifies a caught error into a typed FMODataErrorType.
*/
_classifyError(err, fullUrl) {
if (err instanceof TimeoutError || err instanceof AbortError || err instanceof NetworkError || err instanceof RetryLimitError || err instanceof CircuitOpenError) {
return err;
}
if (err instanceof ResponseParseError) {
return err;
}
return new NetworkError(fullUrl, err);
}
/**
* @internal
* Parses an HTTP error response into a typed FMODataErrorType.
*/
_parseHttpError(resp, fullUrl, errorBody) {
if (errorBody == null ? void 0 : errorBody.error) {
const errorCode = errorBody.error.code;
const errorMessage = errorBody.error.message || resp.statusText;
if (errorCode === "303" || errorCode === 303) {
return new SchemaLockedError(fullUrl, errorMessage, errorBody.error);
}
return new ODataError(fullUrl, errorMessage, String(errorCode), errorBody.error);
}
return new HTTPError(fullUrl, resp.status, resp.statusText, errorBody);
}
/**
* @internal
* Checks parsed JSON data for embedded OData errors.
*/
_checkEmbeddedODataError(data, fullUrl) {
if (get(data, "error", null)) {
const errorCode = get(data, "error.code", null);
const errorMessage = String(get(data, "error.message", "Unknown OData error"));
if (errorCode === "303" || errorCode === 303) {
return new SchemaLockedError(fullUrl, errorMessage, data.error);
}
return new ODataError(fullUrl, errorMessage, String(errorCode), data.error);
}
return void 0;
}
/**
* @internal
* Builds the Effect pipeline for an HTTP request.
* Each step in the pipeline is a discrete Effect, enabling composable error handling.
*/
_makeRequestEffect(url, options) {
var _a;
const logger = this._getLogger();
const baseUrl = `${this.serverUrl}${"apiKey" in this.auth ? "/otto" : ""}/fmi/odata/v4`;
const normalizeDatabaseName = (options == null ? void 0 : options.normalizeDatabaseName) ?? this.normalizeDatabaseName;
if ("apiKey" in this.auth && normalizeDatabaseName === false && !this.hasWarnedAboutOttoDatabaseNormalization) {
logger.warn(
"normalizeDatabaseName=false cannot disable filename normalization with Otto auth; FileMaker Server normalizes it automatically."
);
this.hasWarnedAboutOttoDatabaseNormalization = true;
}
const normalizedUrl = normalizeDatabasePath(url, {
normalizeDatabaseName,
mode: options == null ? void 0 : options.databaseNameNormalizationMode
});
const fullUrl = baseUrl + normalizedUrl;
const useEntityIds = (options == null ? void 0 : options.useEntityIds) ?? this.useEntityIds;
const includeSpecialColumns = (options == null ? void 0 : options.includeSpecialColumns) ?? this.includeSpecialColumns;
const includeODataAnnotations = options == null ? void 0 : options.includeODataAnnotations;
const preferValues = [];
if (useEntityIds) {
preferValues.push("fmodata.entity-ids");
}
if (includeSpecialColumns) {
preferValues.push("fmodata.include-specialcolumns");
}
const fetchHandler = (options == null ? void 0 : options.fetchHandler) ?? ((_a = this._fetchClientOptions) == null ? void 0 : _a.fetchHandler);
const { headers: _headers, fetchHandler: _fetchHandler, ...restOptions } = options || {};
const buildHeaders = async () => {
const headers = new Headers(options == null ? void 0 : options.headers);
headers.set("Authorization", await this._getAuthorizationHeader(fetchHandler));
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (!headers.has("Accept")) {
headers.set("Accept", getAcceptHeader(includeODataAnnotations));
}
const mergedPrefer = mergePreferHeaderValues(
preferValues.length > 0 ? preferValues.join(", ") : void 0,
headers.get("Prefer") ?? void 0
);
if (mergedPrefer) {
headers.set("Prefer", mergedPrefer);
} else {
headers.delete("Prefer");
}
return headers;
};
const clientToUse = fetchHandler ? createClient({ retries: 0, fetchHandler }) : this.fetchClient;
const fetchEffect = Effect.tryPromise({
try: async () => {
const headers = await buildHeaders();
const { authorization: _authorization, ...loggableHeaders } = Object.fromEntries(headers.entries());
logger.debug("Request headers:", loggableHeaders);
const finalOptions = {
...restOptions,
headers
};
return clientToUse(fullUrl, finalOptions);
},
catch: (err) => this._classifyError(err, fullUrl)
});
const pipeline = fetchEffect.pipe(
Effect.tap((resp) => Effect.sync(() => logger.debug(`${restOptions.method ?? "GET"} ${resp.status} ${fullUrl}`))),
Effect.flatMap((resp) => {
var _a2, _b, _c, _d, _e;
if (!resp.ok) {
return Effect.tryPromise({
try: async () => {
var _a3;
let errorBody;
try {
if ((_a3 = resp.headers.get("content-type")) == null ? void 0 : _a3.includes("application/json")) {
errorBody = await safeJsonParse(resp);
}
} catch {
}
return errorBody;
},
catch: () => new HTTPError(fullUrl, resp.status, resp.statusText)
}).pipe(Effect.flatMap((errorBody) => Effect.fail(this._parseHttpError(resp, fullUrl, errorBody))));
}
const affectedRows = resp.headers.get("fmodata.affected_rows");
if (affectedRows !== null) {
return Effect.succeed(Number.parseInt(affectedRows, 10));
}
if (resp.status === 204) {
const locationHeader = ((_b = (_a2 = resp.headers) == null ? void 0 : _a2.get) == null ? void 0 : _b.call(_a2, "Location")) || ((_d = (_c = resp.headers) == null ? void 0 : _c.get) == null ? void 0 : _d.call(_c, "location"));
if (locationHeader) {
return Effect.succeed({ _location: locationHeader });
}
return Effect.succeed(0);
}
if ((_e = resp.headers.get("content-type")) == null ? void 0 : _e.includes("application/json")) {
return Effect.tryPromise({
try: () => safeJsonParse(resp),
catch: (err) => this._classifyError(err, fullUrl)
}).pipe(
Effect.flatMap((data) => {
const embeddedError = this._checkEmbeddedODataError(data, fullUrl);
if (embeddedError) {
return Effect.fail(embeddedError);
}
return Effect.succeed(data);
})
);
}
return Effect.tryPromise({
try: () => resp.text(),
catch: (err) => this._classifyError(err, fullUrl)
}).pipe(Effect.map((text) => text));
})
);
const retryPolicy = options == null ? void 0 : options.retryPolicy;
const method = (restOptions.method ?? "GET").toUpperCase();
const isRetrySafeMethod = method === "GET" || method === "HEAD" || method === "OPTIONS" || method === "PUT";
const requestEffect = retryPolicy && isRetrySafeMethod ? withRetryPolicy(pipeline, retryPolicy) : pipeline;
return withSpan(requestEffect, "fmodata.request", {
"fmodata.url": normalizedUrl,
"fmodata.method": method
});
}
/**
* @internal
*/
_makeRequest(url, options) {
return runAsResult(this._makeRequestEffect(url, options));
}
database(name, config) {
return new Database(name, this, config);
}
/**
* Lists all available databases from the FileMaker OData service.
* @returns Promise resolving to an array of database names
*/
async listDatabaseNames() {
const result = await this._makeRequest("/$metadata", { headers: { Accept: "application/json" } });
if (result.error) {
throw result.error;
}
if (result.data.value && Array.isArray(result.data.value)) {
return result.data.value.map((item) => item.name);
}
return [];
}
}
export {
FMServerConnection
};
//# sourceMappingURL=filemaker-odata.js.map