UNPKG

@proofkit/fmodata

Version:

FileMaker OData API client

440 lines (399 loc) 15.8 kB
import createClient, { AbortError, CircuitOpenError, type FFetchOptions, NetworkError, RetryLimitError, TimeoutError, } from "@fetchkit/ffetch"; import { Effect, Layer } from "effect"; import { get } from "es-toolkit/compat"; import { runAsResult, withRetryPolicy, withSpan } from "../effect"; import type { FMODataErrorType } from "../errors"; import { HTTPError, ODataError, ResponseParseError, SchemaLockedError } from "../errors"; import { createLogger, type InternalLogger, type Logger } from "../logger"; import { type FMODataLayer, HttpClient, ODataConfig, ODataLogger } from "../services"; import type { Auth, ExecutionContext, Result } from "../types"; import { getAcceptHeader } from "../types"; import { mergePreferHeaderValues } from "./builders/mutation-helpers"; import { ClarisIdAuthManager } from "./claris-id"; import { Database } from "./database"; import { type DatabaseNameNormalizationMode, normalizeDatabasePath } from "./database-name"; import { safeJsonParse } from "./sanitize-json"; const TRAILING_SLASH_REGEX = /\/+$/; export class FMServerConnection implements ExecutionContext { private readonly fetchClient: ReturnType<typeof createClient>; private readonly serverUrl: string; private readonly auth: Auth; private readonly normalizeDatabaseName = true; private useEntityIds = false; private includeSpecialColumns = false; private readonly logger: InternalLogger; private readonly clarisIdAuthManager: ClarisIdAuthManager | null; private hasWarnedAboutOttoDatabaseNormalization = false; /** @internal Stored so credential-override flows can inherit non-auth config. */ readonly _fetchClientOptions: FFetchOptions | undefined; constructor(config: { serverUrl: string; auth: Auth; fetchClientOptions?: FFetchOptions; logger?: Logger; }) { this.logger = createLogger(config.logger); this._fetchClientOptions = config.fetchClientOptions; this.fetchClient = createClient({ retries: 0, ...config.fetchClientOptions, }); // Ensure the URL uses https://, is valid, and has no trailing slash const url = new URL(config.serverUrl); if (url.protocol !== "https:") { url.protocol = "https:"; } // Remove any trailing slash from pathname 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: boolean): void { this.useEntityIds = useEntityIds; } /** * @internal * Gets whether to use FileMaker entity IDs (FMFID/FMTID) in requests */ _getUseEntityIds(): boolean { return this.useEntityIds; } /** * @internal * Sets whether to include special columns (ROWID and ROWMODID) in requests */ _setIncludeSpecialColumns(includeSpecialColumns: boolean): void { this.includeSpecialColumns = includeSpecialColumns; } /** * @internal * Gets whether to include special columns (ROWID and ROWMODID) in requests */ _getIncludeSpecialColumns(): boolean { return this.includeSpecialColumns; } /** * @internal * Gets the base URL for OData requests */ _getBaseUrl(): string { return `${this.serverUrl}${"apiKey" in this.auth ? "/otto" : ""}/fmi/odata/v4`; } private _getAuthorizationHeader(fetchHandler?: typeof fetch): Promise<string> { 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(): InternalLogger { return this.logger; } /** * @internal * Returns the Effect Layer for this connection, composing HttpClient, ODataConfig, and ODataLogger services. */ _getLayer(): FMODataLayer { const httpLayer = Layer.succeed(HttpClient, { request: <T>( url: string, options?: RequestInit & FFetchOptions & { normalizeDatabaseName?: boolean; databaseNameNormalizationMode?: DatabaseNameNormalizationMode; useEntityIds?: boolean; includeSpecialColumns?: boolean; }, ) => this._makeRequestEffect<T>(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. */ private _classifyError(err: unknown, fullUrl: string): FMODataErrorType { 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. */ private _parseHttpError( resp: Response, fullUrl: string, errorBody: { error?: { code?: string | number; message?: string } } | undefined, ): FMODataErrorType { if (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. */ private _checkEmbeddedODataError<T>( data: T & { error?: { code?: string | number; message?: string } }, fullUrl: string, ): FMODataErrorType | undefined { 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 undefined; } /** * @internal * Builds the Effect pipeline for an HTTP request. * Each step in the pipeline is a discrete Effect, enabling composable error handling. */ private _makeRequestEffect<T>( url: string, options?: RequestInit & FFetchOptions & { normalizeDatabaseName?: boolean; databaseNameNormalizationMode?: DatabaseNameNormalizationMode; useEntityIds?: boolean; includeSpecialColumns?: boolean; }, ): Effect.Effect<T, FMODataErrorType> { const logger = this._getLogger(); const baseUrl = `${this.serverUrl}${"apiKey" in this.auth ? "/otto" : ""}/fmi/odata/v4`; const normalizeDatabaseName = 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?.databaseNameNormalizationMode, }); const fullUrl = baseUrl + normalizedUrl; // Use per-request override if provided, otherwise use the database-level setting const useEntityIds = options?.useEntityIds ?? this.useEntityIds; const includeSpecialColumns = options?.includeSpecialColumns ?? this.includeSpecialColumns; // Get includeODataAnnotations from options (it's passed through from execute options) // biome-ignore lint/suspicious/noExplicitAny: Type assertion for optional property access const includeODataAnnotations = (options as any)?.includeODataAnnotations; // Build Prefer header as comma-separated list when multiple preferences are set const preferValues: string[] = []; if (useEntityIds) { preferValues.push("fmodata.entity-ids"); } if (includeSpecialColumns) { preferValues.push("fmodata.include-specialcolumns"); } // TEMPORARY WORKAROUND: Hopefully this feature will be fixed in the ffetch library // Extract fetchHandler and headers separately, only for tests where we're overriding the fetch handler per-request const fetchHandler = options?.fetchHandler ?? this._fetchClientOptions?.fetchHandler; const { headers: _headers, fetchHandler: _fetchHandler, ...restOptions } = options || {}; const buildHeaders = async () => { const headers = new Headers(options?.headers); headers.set("Authorization", await this._getAuthorizationHeader(fetchHandler)); if (!headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } // Respect a caller-supplied Accept header (e.g. getMetadata({ format: "xml" }) // sets Accept: application/xml). Only fall back to the default JSON Accept // when the caller didn't specify one. if (!headers.has("Accept")) { headers.set("Accept", getAcceptHeader(includeODataAnnotations)); } const mergedPrefer = mergePreferHeaderValues( preferValues.length > 0 ? preferValues.join(", ") : undefined, headers.get("Prefer") ?? undefined, ); if (mergedPrefer) { headers.set("Prefer", mergedPrefer); } else { headers.delete("Prefer"); } return headers; }; // If fetchHandler is provided, create a temporary client with it // Otherwise use the existing client const clientToUse = fetchHandler ? createClient({ retries: 0, fetchHandler }) : this.fetchClient; // Step 1: Execute the HTTP request 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), }); // Step 2: Process the response const pipeline = fetchEffect.pipe( Effect.tap((resp) => Effect.sync(() => logger.debug(`${restOptions.method ?? "GET"} ${resp.status} ${fullUrl}`))), Effect.flatMap((resp) => { // Handle HTTP errors if (!resp.ok) { return Effect.tryPromise({ try: async () => { let errorBody: { error?: { code?: string | number; message?: string } } | undefined; try { if (resp.headers.get("content-type")?.includes("application/json")) { errorBody = await safeJsonParse<typeof errorBody>(resp); } } catch { // Ignore JSON parse errors } return errorBody; }, catch: () => new HTTPError(fullUrl, resp.status, resp.statusText) as FMODataErrorType, }).pipe(Effect.flatMap((errorBody) => Effect.fail(this._parseHttpError(resp, fullUrl, errorBody)))); } // Check for affected rows header (for DELETE and bulk PATCH operations) const affectedRows = resp.headers.get("fmodata.affected_rows"); if (affectedRows !== null) { return Effect.succeed(Number.parseInt(affectedRows, 10) as T); } // Handle 204 No Content with no body if (resp.status === 204) { const locationHeader = resp.headers?.get?.("Location") || resp.headers?.get?.("location"); if (locationHeader) { return Effect.succeed({ _location: locationHeader } as T); } return Effect.succeed(0 as T); } // Parse JSON response if (resp.headers.get("content-type")?.includes("application/json")) { return Effect.tryPromise({ try: () => safeJsonParse<T & { error?: { code?: string | number; message?: string } }>(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 as T); }), ); } // Plain text response return Effect.tryPromise({ try: () => resp.text(), catch: (err) => this._classifyError(err, fullUrl), }).pipe(Effect.map((text) => text as T)); }), ); // biome-ignore lint/suspicious/noExplicitAny: Type assertion for optional property access const retryPolicy = (options as any)?.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; // Apply retry policy and tracing span return withSpan(requestEffect, "fmodata.request", { "fmodata.url": normalizedUrl, "fmodata.method": method, }); } /** * @internal */ _makeRequest<T>( url: string, options?: RequestInit & FFetchOptions & { normalizeDatabaseName?: boolean; databaseNameNormalizationMode?: DatabaseNameNormalizationMode; useEntityIds?: boolean; includeSpecialColumns?: boolean; }, ): Promise<Result<T>> { return runAsResult(this._makeRequestEffect<T>(url, options)); } database<IncludeSpecialColumns extends boolean = false>( name: string, config?: { normalizeDatabaseName?: boolean; useEntityIds?: boolean; includeSpecialColumns?: IncludeSpecialColumns; }, ): Database<IncludeSpecialColumns> { return new Database<IncludeSpecialColumns>(name, this, config); } /** * Lists all available databases from the FileMaker OData service. * @returns Promise resolving to an array of database names */ async listDatabaseNames(): Promise<string[]> { const result = await this._makeRequest<{ value?: Array<{ name: string }>; }>("/$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 []; } }