@proofkit/fmodata
Version:
FileMaker OData API client
404 lines (352 loc) • 12.7 kB
text/typescript
import type {
ExecutionContext,
ExecutableBuilder,
Result,
ODataRecordMetadata,
InferSchemaType,
ExecuteOptions,
ConditionallyWithODataAnnotations,
} from "../types";
import type { TableOccurrence } from "./table-occurrence";
import { validateSingleResponse } from "../validation";
import { type FFetchOptions } from "@fetchkit/ffetch";
import {
transformFieldNamesToIds,
transformTableName,
transformResponseFields,
getTableIdentifiers,
} from "../transform";
import { InvalidLocationHeaderError } from "../errors";
export type InsertOptions = {
return?: "minimal" | "representation";
};
export class InsertBuilder<
T extends Record<string, any>,
Occ extends TableOccurrence<any, any, any, any> | undefined = undefined,
ReturnPreference extends "minimal" | "representation" = "representation",
> implements
ExecutableBuilder<
ReturnPreference extends "minimal" ? { ROWID: number } : T
>
{
private occurrence?: Occ;
private tableName: string;
private databaseName: string;
private context: ExecutionContext;
private data: Partial<T>;
private returnPreference: ReturnPreference;
private databaseUseEntityIds: boolean;
constructor(config: {
occurrence?: Occ;
tableName: string;
databaseName: string;
context: ExecutionContext;
data: Partial<T>;
returnPreference?: ReturnPreference;
databaseUseEntityIds?: boolean;
}) {
this.occurrence = config.occurrence;
this.tableName = config.tableName;
this.databaseName = config.databaseName;
this.context = config.context;
this.data = config.data;
this.returnPreference = (config.returnPreference ||
"representation") as ReturnPreference;
this.databaseUseEntityIds = config.databaseUseEntityIds ?? false;
}
/**
* Helper to merge database-level useEntityIds with per-request options
*/
private mergeExecuteOptions(
options?: RequestInit & FFetchOptions & ExecuteOptions,
): RequestInit & FFetchOptions & { useEntityIds?: boolean } {
// If useEntityIds is not set in options, use the database-level setting
return {
...options,
useEntityIds: options?.useEntityIds ?? this.databaseUseEntityIds,
};
}
/**
* Helper to conditionally strip OData annotations based on options
*/
private stripODataAnnotationsIfNeeded<T extends Record<string, any>>(
data: T,
options?: ExecuteOptions,
): T {
// Only include annotations if explicitly requested
if (options?.includeODataAnnotations === true) {
return data;
}
// Strip OData annotations
const { "@id": _id, "@editLink": _editLink, ...rest } = data;
return rest as T;
}
/**
* Parse ROWID from Location header
* Expected formats:
* - contacts(ROWID=4583)
* - contacts('some-uuid')
*/
private parseLocationHeader(locationHeader: string | undefined): number {
if (!locationHeader) {
throw new InvalidLocationHeaderError(
"Location header is required but was not provided",
);
}
// Try to match ROWID=number pattern
const rowidMatch = locationHeader.match(/ROWID=(\d+)/);
if (rowidMatch && rowidMatch[1]) {
return parseInt(rowidMatch[1], 10);
}
// Try to extract value from parentheses and parse as number
const parenMatch = locationHeader.match(/\(['"]?([^'"]+)['"]?\)/);
if (parenMatch && parenMatch[1]) {
const value = parenMatch[1];
const numValue = parseInt(value, 10);
if (!isNaN(numValue)) {
return numValue;
}
}
throw new InvalidLocationHeaderError(
`Could not extract ROWID from Location header: ${locationHeader}`,
locationHeader,
);
}
/**
* Gets the table ID (FMTID) if using entity IDs, otherwise returns the table name
* @param useEntityIds - Optional override for entity ID usage
*/
private getTableId(useEntityIds?: boolean): string {
if (!this.occurrence) {
return this.tableName;
}
const contextDefault = this.context._getUseEntityIds?.() ?? false;
const shouldUseIds = useEntityIds ?? contextDefault;
if (shouldUseIds) {
const identifiers = getTableIdentifiers(this.occurrence);
if (!identifiers.id) {
throw new Error(
`useEntityIds is true but TableOccurrence "${identifiers.name}" does not have an fmtId defined`
);
}
return identifiers.id;
}
return this.occurrence.getTableName();
}
async execute<EO extends ExecuteOptions>(
options?: RequestInit & FFetchOptions & EO,
): Promise<
Result<
ReturnPreference extends "minimal"
? { ROWID: number }
: ConditionallyWithODataAnnotations<
T,
EO["includeODataAnnotations"] extends true ? true : false
>
>
> {
// Merge database-level useEntityIds with per-request options
const mergedOptions = this.mergeExecuteOptions(options);
// Get table identifier with override support
const tableId = this.getTableId(mergedOptions.useEntityIds);
const url = `/${this.databaseName}/${tableId}`;
// Transform field names to FMFIDs if using entity IDs
// Only transform if useEntityIds resolves to true (respects per-request override)
const shouldUseIds = mergedOptions.useEntityIds ?? false;
const transformedData = this.occurrence?.baseTable && shouldUseIds
? transformFieldNamesToIds(this.data, this.occurrence.baseTable)
: this.data;
// Set Prefer header based on return preference
const preferHeader =
this.returnPreference === "minimal"
? "return=minimal"
: "return=representation";
// Make POST request with JSON body
const result = await this.context._makeRequest<any>(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Prefer: preferHeader,
...((mergedOptions as any)?.headers || {}),
},
body: JSON.stringify(transformedData),
...mergedOptions,
});
if (result.error) {
return { data: undefined, error: result.error };
}
// Handle return=minimal case
if (this.returnPreference === "minimal") {
// The response should be empty (204 No Content)
// _makeRequest will return { _location: string } when there's a Location header
const responseData = result.data as any;
if (!responseData || !responseData._location) {
throw new InvalidLocationHeaderError(
"Location header is required when using return=minimal but was not found in response",
);
}
const rowid = this.parseLocationHeader(responseData._location);
return { data: { ROWID: rowid } as any, error: undefined };
}
let response = result.data;
// Transform response field IDs back to names if using entity IDs
// Only transform if useEntityIds resolves to true (respects per-request override)
if (this.occurrence?.baseTable && shouldUseIds) {
response = transformResponseFields(
response,
this.occurrence.baseTable,
undefined, // No expand configs for insert
);
}
// Get schema from occurrence if available
const schema = this.occurrence?.baseTable?.schema;
// Validate the response (FileMaker returns the created record)
const validation = await validateSingleResponse<T>(
response,
schema,
undefined, // No selected fields for insert
undefined, // No expand configs
"exact", // Expect exactly one record
);
if (!validation.valid) {
return { data: undefined, error: validation.error };
}
// Handle null response (shouldn't happen for insert, but handle it)
if (validation.data === null) {
return {
data: undefined,
error: new Error("Insert operation returned null response"),
};
}
// Strip OData annotations unless explicitly requested
const finalData = this.stripODataAnnotationsIfNeeded(
validation.data,
options,
);
return { data: finalData as any, error: undefined };
}
getRequestConfig(): { method: string; url: string; body?: any } {
// For batch operations, use database-level setting (no per-request override available here)
const tableId = this.getTableId(this.databaseUseEntityIds);
// Transform field names to FMFIDs if using entity IDs
const transformedData = this.occurrence?.baseTable && this.databaseUseEntityIds
? transformFieldNamesToIds(this.data, this.occurrence.baseTable)
: this.data;
return {
method: "POST",
url: `/${this.databaseName}/${tableId}`,
body: JSON.stringify(transformedData),
};
}
toRequest(baseUrl: string): Request {
const config = this.getRequestConfig();
const fullUrl = `${baseUrl}${config.url}`;
// Set Prefer header based on return preference
const preferHeader =
this.returnPreference === "minimal"
? "return=minimal"
: "return=representation";
return new Request(fullUrl, {
method: config.method,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Prefer: preferHeader,
},
body: config.body,
});
}
async processResponse(
response: Response,
options?: ExecuteOptions,
): Promise<
Result<ReturnPreference extends "minimal" ? { ROWID: number } : T>
> {
// Handle 204 No Content (common in batch/changeset operations)
// FileMaker uses return=minimal for changeset operations regardless of Prefer header
if (response.status === 204) {
// Check for Location header (for return=minimal)
if (this.returnPreference === "minimal") {
const locationHeader =
response.headers.get("Location") || response.headers.get("location");
if (locationHeader) {
const rowid = this.parseLocationHeader(locationHeader);
return { data: { ROWID: rowid } as any, error: undefined };
}
throw new InvalidLocationHeaderError(
"Location header is required when using return=minimal but was not found in response",
);
}
// For 204 responses without return=minimal, FileMaker doesn't return the created entity
// This is valid OData behavior for changeset operations
// We return a success indicator but no actual data
return {
data: {} as any,
error: undefined,
};
}
// If we expected return=minimal but got a body, that's unexpected
if (this.returnPreference === "minimal") {
throw new InvalidLocationHeaderError(
"Expected 204 No Content for return=minimal, but received response with body",
);
}
let rawResponse;
try {
rawResponse = await response.json();
} catch (err) {
// If parsing fails with 204, handle it gracefully
if (response.status === 204) {
return {
data: {} as any,
error: undefined,
};
}
return {
data: undefined,
error: {
name: "ResponseParseError",
message: `Failed to parse response JSON: ${err instanceof Error ? err.message : "Unknown error"}`,
timestamp: new Date(),
} as any,
};
}
// Transform response field IDs back to names if using entity IDs
// Only transform if useEntityIds resolves to true (respects per-request override)
const shouldUseIds = options?.useEntityIds ?? this.databaseUseEntityIds;
let transformedResponse = rawResponse;
if (this.occurrence?.baseTable && shouldUseIds) {
transformedResponse = transformResponseFields(
rawResponse,
this.occurrence.baseTable,
undefined, // No expand configs for insert
);
}
// Get schema from occurrence if available
const schema = this.occurrence?.baseTable?.schema;
// Validate the response (FileMaker returns the created record)
const validation = await validateSingleResponse<T>(
transformedResponse,
schema,
undefined, // No selected fields for insert
undefined, // No expand configs
"exact", // Expect exactly one record
);
if (!validation.valid) {
return { data: undefined, error: validation.error };
}
// Handle null response (shouldn't happen for insert, but handle it)
if (validation.data === null) {
return {
data: undefined,
error: new Error("Insert operation returned null response"),
};
}
// Strip OData annotations unless explicitly requested
const finalData = this.stripODataAnnotationsIfNeeded(
validation.data,
options,
);
return { data: finalData as any, error: undefined };
}
}