UNPKG

@proofkit/fmodata

Version:
482 lines (446 loc) 16.2 kB
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, }; }