UNPKG

@proofkit/fmodata

Version:

FileMaker OData API client

321 lines (320 loc) 12.9 kB
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