@proofkit/fmodata
Version:
FileMaker OData API client
482 lines (446 loc) • 16.2 kB
text/typescript
import type { ODataRecordMetadata } from "./types";
import { StandardSchemaV1 } from "@standard-schema/spec";
import type { TableOccurrence } from "./client/table-occurrence";
import {
ValidationError,
ResponseStructureError,
RecordCountMismatchError,
} from "./errors";
// Type for expand validation configuration
export type ExpandValidationConfig = {
relation: string;
targetSchema?: Record<string, StandardSchemaV1>;
targetOccurrence?: TableOccurrence<any, any, any, any>;
targetBaseTable?: any; // BaseTable instance for transformation
occurrence?: TableOccurrence<any, any, any, any>; // For transformation
selectedFields?: string[];
nestedExpands?: ExpandValidationConfig[];
};
/**
* Validates a single record against a schema, only validating selected fields.
* Also validates expanded relations if expandConfigs are provided.
*/
export async function validateRecord<T extends Record<string, any>>(
record: any,
schema: Record<string, StandardSchemaV1> | undefined,
selectedFields?: (keyof T)[],
expandConfigs?: ExpandValidationConfig[],
): Promise<
| { valid: true; data: T & ODataRecordMetadata }
| { valid: false; error: ValidationError }
> {
// Extract OData metadata fields (don't validate them - include if present)
const { "@id": id, "@editLink": editLink, ...rest } = record;
// Include metadata fields if present (don't validate they exist)
const metadata: ODataRecordMetadata = {
"@id": id || "",
"@editLink": editLink || "",
};
// If no schema, just return the data with metadata
if (!schema) {
return {
valid: true,
data: { ...rest, ...metadata } as T & ODataRecordMetadata,
};
}
// Filter out FileMaker system fields that shouldn't be in responses by default
const { ROWID, ROWMODID, ...restWithoutSystemFields } = rest;
// If selected fields are specified, validate only those fields
if (selectedFields && selectedFields.length > 0) {
const validatedRecord: Record<string, any> = {};
for (const field of selectedFields) {
const fieldName = String(field);
const fieldSchema = schema[fieldName];
if (fieldSchema) {
const input = rest[fieldName];
try {
let result = fieldSchema["~standard"].validate(input);
if (result instanceof Promise) result = await result;
// if the `issues` field exists, the validation failed
if (result.issues) {
return {
valid: false,
error: new ValidationError(
`Validation failed for field '${fieldName}'`,
result.issues,
{
field: fieldName,
value: input,
cause: result.issues,
},
),
};
}
validatedRecord[fieldName] = result.value;
} catch (originalError) {
// If the validator throws directly, wrap it
return {
valid: false,
error: new ValidationError(
`Validation failed for field '${fieldName}'`,
[],
{
field: fieldName,
value: input,
cause: originalError,
},
),
};
}
} else {
// For fields not in schema (like when explicitly selecting ROWID/ROWMODID)
// include them from the original response
validatedRecord[fieldName] = rest[fieldName];
}
}
// Validate expanded relations
if (expandConfigs && expandConfigs.length > 0) {
for (const expandConfig of expandConfigs) {
const expandValue = rest[expandConfig.relation];
// Check if expand field is missing
if (expandValue === undefined) {
// Check for inline error array (FileMaker returns errors inline when expand fails)
if (Array.isArray(rest.error) && rest.error.length > 0) {
// Extract error message from inline error
const errorDetail = rest.error[0]?.error;
if (errorDetail?.message) {
const errorMessage = errorDetail.message;
// Check if the error is related to this expand by checking if:
// 1. The error mentions the relation name, OR
// 2. The error mentions any of the selected fields
const isRelatedToExpand =
errorMessage
.toLowerCase()
.includes(expandConfig.relation.toLowerCase()) ||
(expandConfig.selectedFields &&
expandConfig.selectedFields.some((field) =>
errorMessage.toLowerCase().includes(field.toLowerCase()),
));
if (isRelatedToExpand) {
return {
valid: false,
error: new ValidationError(
`Validation failed for expanded relation '${expandConfig.relation}': ${errorMessage}`,
[],
{
field: expandConfig.relation,
},
),
};
}
}
}
// If no inline error but expand was expected, that's also an issue
// However, this might be a legitimate case (e.g., no related records)
// So we'll only fail if there's an explicit error array
} else {
// Original validation logic for when expand exists
if (Array.isArray(expandValue)) {
// Validate each item in the expanded array
const validatedExpandedItems: any[] = [];
for (let i = 0; i < expandValue.length; i++) {
const item = expandValue[i];
const itemValidation = await validateRecord(
item,
expandConfig.targetSchema,
expandConfig.selectedFields as string[] | undefined,
expandConfig.nestedExpands,
);
if (!itemValidation.valid) {
return {
valid: false,
error: new ValidationError(
`Validation failed for expanded relation '${expandConfig.relation}' at index ${i}: ${itemValidation.error.message}`,
itemValidation.error.issues,
{
field: expandConfig.relation,
cause: itemValidation.error.cause,
},
),
};
}
validatedExpandedItems.push(itemValidation.data);
}
validatedRecord[expandConfig.relation] = validatedExpandedItems;
} else {
// Single expanded item (shouldn't happen in OData, but handle it)
const itemValidation = await validateRecord(
expandValue,
expandConfig.targetSchema,
expandConfig.selectedFields as string[] | undefined,
expandConfig.nestedExpands,
);
if (!itemValidation.valid) {
return {
valid: false,
error: new ValidationError(
`Validation failed for expanded relation '${expandConfig.relation}': ${itemValidation.error.message}`,
itemValidation.error.issues,
{
field: expandConfig.relation,
cause: itemValidation.error.cause,
},
),
};
}
validatedRecord[expandConfig.relation] = itemValidation.data;
}
}
}
}
// Merge validated data with metadata
return {
valid: true,
data: { ...validatedRecord, ...metadata } as T & ODataRecordMetadata,
};
}
// Validate all fields in schema, but exclude ROWID/ROWMODID by default
const validatedRecord: Record<string, any> = { ...restWithoutSystemFields };
for (const [fieldName, fieldSchema] of Object.entries(schema)) {
const input = rest[fieldName];
try {
let result = fieldSchema["~standard"].validate(input);
if (result instanceof Promise) result = await result;
// if the `issues` field exists, the validation failed
if (result.issues) {
return {
valid: false,
error: new ValidationError(
`Validation failed for field '${fieldName}'`,
result.issues,
{
field: fieldName,
value: input,
cause: result.issues,
},
),
};
}
validatedRecord[fieldName] = result.value;
} catch (originalError) {
// If the validator throws an error directly, catch and wrap it
// This preserves the original error instance for instanceof checks
return {
valid: false,
error: new ValidationError(
`Validation failed for field '${fieldName}'`,
[],
{
field: fieldName,
value: input,
cause: originalError,
},
),
};
}
}
// Validate expanded relations even when not using selected fields
if (expandConfigs && expandConfigs.length > 0) {
for (const expandConfig of expandConfigs) {
const expandValue = rest[expandConfig.relation];
// Check if expand field is missing
if (expandValue === undefined) {
// Check for inline error array (FileMaker returns errors inline when expand fails)
if (Array.isArray(rest.error) && rest.error.length > 0) {
// Extract error message from inline error
const errorDetail = rest.error[0]?.error;
if (errorDetail?.message) {
const errorMessage = errorDetail.message;
// Check if the error is related to this expand by checking if:
// 1. The error mentions the relation name, OR
// 2. The error mentions any of the selected fields
const isRelatedToExpand =
errorMessage
.toLowerCase()
.includes(expandConfig.relation.toLowerCase()) ||
(expandConfig.selectedFields &&
expandConfig.selectedFields.some((field) =>
errorMessage.toLowerCase().includes(field.toLowerCase()),
));
if (isRelatedToExpand) {
return {
valid: false,
error: new ValidationError(
`Validation failed for expanded relation '${expandConfig.relation}': ${errorMessage}`,
[],
{
field: expandConfig.relation,
},
),
};
}
}
}
// If no inline error but expand was expected, that's also an issue
// However, this might be a legitimate case (e.g., no related records)
// So we'll only fail if there's an explicit error array
} else {
// Original validation logic for when expand exists
if (Array.isArray(expandValue)) {
// Validate each item in the expanded array
const validatedExpandedItems: any[] = [];
for (let i = 0; i < expandValue.length; i++) {
const item = expandValue[i];
const itemValidation = await validateRecord(
item,
expandConfig.targetSchema,
expandConfig.selectedFields as string[] | undefined,
expandConfig.nestedExpands,
);
if (!itemValidation.valid) {
return {
valid: false,
error: new ValidationError(
`Validation failed for expanded relation '${expandConfig.relation}' at index ${i}: ${itemValidation.error.message}`,
itemValidation.error.issues,
{
field: expandConfig.relation,
cause: itemValidation.error.cause,
},
),
};
}
validatedExpandedItems.push(itemValidation.data);
}
validatedRecord[expandConfig.relation] = validatedExpandedItems;
} else {
// Single expanded item (shouldn't happen in OData, but handle it)
const itemValidation = await validateRecord(
expandValue,
expandConfig.targetSchema,
expandConfig.selectedFields as string[] | undefined,
expandConfig.nestedExpands,
);
if (!itemValidation.valid) {
return {
valid: false,
error: new ValidationError(
`Validation failed for expanded relation '${expandConfig.relation}': ${itemValidation.error.message}`,
itemValidation.error.issues,
{
field: expandConfig.relation,
cause: itemValidation.error.cause,
},
),
};
}
validatedRecord[expandConfig.relation] = itemValidation.data;
}
}
}
}
return {
valid: true,
data: { ...validatedRecord, ...metadata } as T & ODataRecordMetadata,
};
}
/**
* Validates a list response against a schema.
*/
export async function validateListResponse<T extends Record<string, any>>(
response: any,
schema: Record<string, StandardSchemaV1> | undefined,
selectedFields?: (keyof T)[],
expandConfigs?: ExpandValidationConfig[],
): Promise<
| { valid: true; data: (T & ODataRecordMetadata)[] }
| { valid: false; error: ResponseStructureError | ValidationError }
> {
// Check if response has the expected structure
if (!response || typeof response !== "object") {
return {
valid: false,
error: new ResponseStructureError("an object", response),
};
}
// Extract @context (for internal validation, but we won't return it)
const { "@context": context, value, ...rest } = response;
if (!Array.isArray(value)) {
return {
valid: false,
error: new ResponseStructureError(
"'value' property to be an array",
value,
),
};
}
// Validate each record in the array
const validatedRecords: (T & ODataRecordMetadata)[] = [];
for (let i = 0; i < value.length; i++) {
const record = value[i];
const validation = await validateRecord<T>(
record,
schema,
selectedFields,
expandConfigs,
);
if (!validation.valid) {
return {
valid: false,
error: validation.error,
};
}
validatedRecords.push(validation.data);
}
return {
valid: true,
data: validatedRecords,
};
}
/**
* Validates a single record response against a schema.
*/
export async function validateSingleResponse<T extends Record<string, any>>(
response: any,
schema: Record<string, StandardSchemaV1> | undefined,
selectedFields?: (keyof T)[],
expandConfigs?: ExpandValidationConfig[],
mode: "exact" | "maybe" = "maybe",
): Promise<
| { valid: true; data: (T & ODataRecordMetadata) | null }
| { valid: false; error: RecordCountMismatchError | ValidationError }
> {
// Check for multiple records (error in both modes)
if (
response.value &&
Array.isArray(response.value) &&
response.value.length > 1
) {
return {
valid: false,
error: new RecordCountMismatchError(
mode === "exact" ? "one" : "at-most-one",
response.value.length,
),
};
}
// Handle empty responses
if (!response || (response.value && response.value.length === 0)) {
if (mode === "exact") {
return {
valid: false,
error: new RecordCountMismatchError("one", 0),
};
}
// mode === "maybe" - return null for empty
return {
valid: true,
data: null,
};
}
// Single record validation
const record = response.value?.[0] ?? response;
const validation = await validateRecord<T>(
record,
schema,
selectedFields,
expandConfigs,
);
if (!validation.valid) {
return validation as { valid: false; error: ValidationError };
}
return {
valid: true,
data: validation.data,
};
}