@proofkit/fmodata
Version:
FileMaker OData API client
440 lines (399 loc) • 15.8 kB
text/typescript
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 [];
}
}