UNPKG

@proofkit/fmodata

Version:
264 lines (236 loc) 8.05 kB
import createClient, { FFetchOptions, TimeoutError, AbortError, NetworkError, RetryLimitError, CircuitOpenError, } from "@fetchkit/ffetch"; import type { Auth, ExecutionContext, Result } from "../types"; import { HTTPError, ODataError, SchemaLockedError } from "../errors"; import { Database } from "./database"; import { TableOccurrence } from "./table-occurrence"; export class FMServerConnection implements ExecutionContext { private fetchClient: ReturnType<typeof createClient>; private serverUrl: string; private auth: Auth; private useEntityIds: boolean = false; constructor(config: { serverUrl: string; auth: Auth; fetchClientOptions?: FFetchOptions; }) { 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(/\/+$/, ""); this.serverUrl = url.toString().replace(/\/+$/, ""); this.auth = config.auth; } /** * @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 * Gets the base URL for OData requests */ _getBaseUrl(): string { return `${this.serverUrl}${"apiKey" in this.auth ? `/otto` : ""}/fmi/odata/v4`; } /** * @internal */ async _makeRequest<T>( url: string, options?: RequestInit & FFetchOptions & { useEntityIds?: boolean }, ): Promise<Result<T>> { const baseUrl = `${this.serverUrl}${"apiKey" in this.auth ? `/otto` : ""}/fmi/odata/v4`; const fullUrl = baseUrl + url; // Use per-request override if provided, otherwise use the database-level setting const useEntityIds = options?.useEntityIds ?? this.useEntityIds; const headers = { Authorization: "apiKey" in this.auth ? `Bearer ${this.auth.apiKey}` : `Basic ${btoa(`${this.auth.username}:${this.auth.password}`)}`, "Content-Type": "application/json", Accept: "application/json", ...(useEntityIds ? { Prefer: "fmodata.entity-ids" } : {}), ...(options?.headers || {}), }; // 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; const { headers: _headers, fetchHandler: _fetchHandler, ...restOptions } = options || {}; // If fetchHandler is provided, create a temporary client with it // Otherwise use the existing client const clientToUse = fetchHandler ? createClient({ retries: 0, fetchHandler }) : this.fetchClient; try { const finalOptions = { ...restOptions, headers, }; // For batch requests, use native fetch to avoid any potential serialization issues with ffetch const resp = url.includes("/$batch") ? await fetch(fullUrl, { method: finalOptions.method, headers: finalOptions.headers, body: finalOptions.body, }) : await clientToUse(fullUrl, finalOptions); // Handle HTTP errors if (!resp.ok) { // Try to parse error body if it's JSON let errorBody; try { if (resp.headers.get("content-type")?.includes("application/json")) { errorBody = await resp.json(); } } catch { // Ignore JSON parse errors } // Check if it's an OData error response if (errorBody?.error) { const errorCode = errorBody.error.code; const errorMessage = errorBody.error.message || resp.statusText; // Check for schema locked error (code 303) if (errorCode === "303" || errorCode === 303) { return { data: undefined, error: new SchemaLockedError( fullUrl, errorMessage, errorBody.error, ), }; } return { data: undefined, error: new ODataError( fullUrl, errorMessage, errorCode, errorBody.error, ), }; } return { data: undefined, error: new HTTPError( fullUrl, resp.status, resp.statusText, errorBody, ), }; } // Check for affected rows header (for DELETE and bulk PATCH operations) // FileMaker may return this with 204 No Content or 200 OK const affectedRows = resp.headers.get("fmodata.affected_rows"); if (affectedRows !== null) { return { data: parseInt(affectedRows, 10) as T, error: undefined }; } // Handle 204 No Content with no body if (resp.status === 204) { // Check for Location header (used for insert with return=minimal) // Use optional chaining for safety with mocks that might not have proper headers const locationHeader = resp.headers?.get?.("Location") || resp.headers?.get?.("location"); if (locationHeader) { // Return the location header so InsertBuilder can extract ROWID return { data: { _location: locationHeader } as T, error: undefined }; } return { data: 0 as T, error: undefined }; } // Parse response if (resp.headers.get("content-type")?.includes("application/json")) { const data = await resp.json(); // Check for embedded OData errors if (data.error) { const errorCode = data.error.code; const errorMessage = data.error.message || "Unknown OData error"; // Check for schema locked error (code 303) if (errorCode === "303" || errorCode === 303) { return { data: undefined, error: new SchemaLockedError(fullUrl, errorMessage, data.error), }; } return { data: undefined, error: new ODataError(fullUrl, errorMessage, errorCode, data.error), }; } return { data: data as T, error: undefined }; } return { data: (await resp.text()) as T, error: undefined }; } catch (err) { // Map ffetch errors - return them directly (no re-wrapping) if ( err instanceof TimeoutError || err instanceof AbortError || err instanceof NetworkError || err instanceof RetryLimitError || err instanceof CircuitOpenError ) { return { data: undefined, error: err }; } // Unknown error - wrap it as NetworkError return { data: undefined, error: new NetworkError(fullUrl, err), }; } } database< const Occurrences extends readonly TableOccurrence<any, any, any, any>[], >( name: string, config?: { occurrences?: Occurrences | undefined; useEntityIds?: boolean; }, ): Database<Occurrences> { 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(): Promise<string[]> { const result = await this._makeRequest<{ value?: Array<{ name: string }>; }>("/"); if (result.error) { throw result.error; } if (result.data.value && Array.isArray(result.data.value)) { return result.data.value.map((item) => item.name); } return []; } }