import _ from 'lodash'; import { type Field, type FieldListItems, getLocalizedFieldForLocale } from '@stackbit/types'; import type * as StackbitTypes from '@stackbit/types'; import type * as ContentStoreTypes from '../types'; import { getDocumentFieldAtFieldPath, getModelFieldAtFieldPath } from './field-path-utils'; export class ContentValidationError extends Error { name: 'ContentValidationError'; validation?: string; fieldPath?: StackbitTypes.FieldPath; value?: any; relatedDocumentId?: string; constructor({ message, validation, fieldPath, value, relatedDocumentId }: { message: string; validation?: string; fieldPath?: (string | number)[]; value?: any; relatedDocumentId?: string; }) { super(message); this.name = 'ContentValidationError'; this.validation = validation; this.fieldPath = fieldPath; this.value = value; this.relatedDocumentId = relatedDocumentId; // redefine "message" as enumerable, this helps to compare the received and the expected error.message in Jest's toMatchObject() tests Object.defineProperty(this, 'message', { value: message, writable: true, enumerable: true, configurable: true }); } } export async function validateUpdateOperationFields({ updateOperationFields, modelName, locale, contentSourceData, configDelegate }: { updateOperationFields: Record; modelName: string; locale?: string; contentSourceData: ContentStoreTypes.ContentSourceData; configDelegate: StackbitTypes.ConfigDelegate; }) { const model = contentSourceData.modelMap[modelName]; const csiModel = contentSourceData.csiModelMap[modelName]; if (!model || !csiModel) { throw new Error(`Model '${modelName}' not found.`); } for (const [fieldName, updateOperationField] of Object.entries(updateOperationFields)) { const modelField = (model.fields ?? []).find((field) => field.name === fieldName); if (!modelField) { throw new ContentValidationError({ message: `Model field '${fieldName}' of model '${csiModel.name}' not found.` }); } // early return if this field has no validations if (_.isEmpty(modelField.validations)) { continue; } const updateOperation: StackbitTypes.UpdateOperationSet = { opType: 'set', fieldPath: [fieldName], locale, modelField, field: updateOperationField }; validateUniqueness({ updateOperation, modelName, contentSourceData }); await customValidation({ updateOperation, contentSourceData, csiModel, configDelegate }); } } export async function validateUpdateOperations({ updateOperations, csiDocument, contentSourceData, configDelegate }: { updateOperations: StackbitTypes.UpdateOperation[]; csiDocument: StackbitTypes.Document; contentSourceData: ContentStoreTypes.ContentSourceData; configDelegate: StackbitTypes.ConfigDelegate; }) { const modelName = csiDocument.modelName; const model = contentSourceData.modelMap[modelName]; const csiModel = contentSourceData.csiModelMap[modelName]; if (!model || !csiModel) { throw new Error(`Model '${modelName}' for document '${csiDocument.id}' not found.`); } const storeDocument = contentSourceData.documentMap[csiDocument.id]!; for (let updateOperation of updateOperations) { let modelField: Field | FieldListItems; try { modelField = getModelFieldAtFieldPath({ document: storeDocument, fieldPath: updateOperation.fieldPath, locale: updateOperation.locale, modelMap: contentSourceData.modelMap }); } catch (error) { const fieldPathStr = updateOperation.fieldPath.join('.'); throw new ContentValidationError({ message: `Cannot find model field of model '${modelName}' at field path '${fieldPathStr}'.` }); } // early return if this field has no validations if (_.isEmpty(modelField.validations)) { continue; } // Replace the modelField from the content-store models. // This modelField is extended with properties from modelExtensions updateOperation = { ...updateOperation, modelField } as StackbitTypes.UpdateOperation; validateUniqueness({ updateOperation, modelName, storeDocument, contentSourceData }); await customValidation({ updateOperation, contentSourceData, csiDocument, csiModel, configDelegate }); } } function validateUniqueness({ updateOperation, modelName, storeDocument, contentSourceData }: { updateOperation: StackbitTypes.UpdateOperation; modelName: string; storeDocument?: ContentStoreTypes.Document; contentSourceData: ContentStoreTypes.ContentSourceData; }) { const fieldPath = updateOperation.fieldPath; const locale = updateOperation.locale; const modelField = updateOperation.modelField; const validations = modelField.validations; if (_.isEmpty(validations)) { return; } if (!('unique' in validations) || !validations.unique) { return; } if (!fieldTypeSupportsUniqueValidation(modelField.type)) { return; } if (modelField.type === 'list' && fieldTypeSupportsUniqueValidation(modelField.items.type)) { // If a field is of type list, then check that all items in list are unique if (updateOperation.opType === 'set' && updateOperation.field.type === 'list') { // If setting a new list, check that all its values unique if (_.uniq(updateOperation.field.items).length !== updateOperation.field.items.length) { throw new ContentValidationError({ message: validations.errors?.unique ?? `The list must have unique items` }); } } else if (updateOperation.opType === 'insert' && 'value' in updateOperation.item && storeDocument) { // If inserting a new item into the list, check that there is no existing item with the same value. const insertedValue = updateOperation.item.value; const listField = getDocumentFieldAtFieldPath({ document: storeDocument, fieldPath, locale, returnUndefined: true }); if (listField && listField.type === 'list') { for (const item of listField.items) { if ('value' in item && item.value === insertedValue) { throw new ContentValidationError({ message: validations.errors?.unique ?? `The list must have unique items` }); } } } } } else if (updateOperation.opType === 'set') { // If a field is of type string, slug, url, number, enum, // then check that the value is unique across all documents of the same type. contentSourceData.documents.forEach((doc) => { if (doc.srcModelName !== modelName || (storeDocument && doc.srcObjectId === storeDocument.srcObjectId)) { return false; } const docField = getDocumentFieldAtFieldPath({ document: doc, fieldPath, locale, returnUndefined: true }); if (docField && 'value' in docField && 'value' in updateOperation.field && docField.value === updateOperation.field.value) { throw new ContentValidationError({ validation: 'unique', value: updateOperation.field.value, fieldPath, message: validations.errors?.unique ?? `The field '${fieldPath[fieldPath.length - 1]}' must be unique, another document has the same value`, relatedDocumentId: doc.srcObjectId }); } }); } } async function customValidation({ updateOperation, csiDocument, csiModel, contentSourceData, configDelegate }: { updateOperation: StackbitTypes.UpdateOperation; csiDocument?: StackbitTypes.Document; csiModel: StackbitTypes.Model; contentSourceData: ContentStoreTypes.ContentSourceData; configDelegate: StackbitTypes.ConfigDelegate; }) { const modelField = updateOperation.modelField; const validations = modelField.validations; if (_.isEmpty(validations)) { return; } // Check custom validate function if ('validate' in validations && validations.validate) { const customValidations = !Array.isArray(validations.validate) ? [validations.validate] : validations.validate; for (const customValidation of customValidations) { const validationResult = await customValidation({ updateOperation: updateOperation, document: csiDocument ? { srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, ...csiDocument } : undefined, model: { srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, ...csiModel }, ...configDelegate }); if (typeof validationResult === 'string') { throw new ContentValidationError({ message: validationResult }); } } } } type DistributeFieldTypesWithValidation = Field extends { validations?: Validation } ? Field['type'] : never; type FieldTypesWithUniqueValidation = DistributeFieldTypesWithValidation; type FieldTypesWithRegexpValidation = DistributeFieldTypesWithValidation; function fieldTypeSupportsUniqueValidation(fieldType: StackbitTypes.FieldType): fieldType is FieldTypesWithUniqueValidation { const fieldsWithUniqueValidation: FieldTypesWithUniqueValidation[] = ['string', 'slug', 'url', 'number', 'enum', 'list']; return _.includes(fieldsWithUniqueValidation, fieldType); } function fieldTypeSupportsRegexpValidation(fieldType: StackbitTypes.FieldType): fieldType is FieldTypesWithRegexpValidation { const fieldsWithUniqueValidation: FieldTypesWithRegexpValidation[] = ['string', 'slug', 'url', 'text', 'markdown', 'html']; return _.includes(fieldsWithUniqueValidation, fieldType); } export function internalValidateContent( documents: StackbitTypes.Document[], assets: StackbitTypes.Asset[], contentSourceData: ContentStoreTypes.ContentSourceData, locale?: string ): ContentStoreTypes.ValidationError[] { const errors: ContentStoreTypes.ValidationError[] = []; _.forEach(documents, (document) => { _.forEach(document.fields, (documentField, fieldName) => { const localizedField = getLocalizedFieldForLocale(documentField, locale); if (localizedField) { errors.push(...validateDocumentFields(document, localizedField, [fieldName], contentSourceData, locale)); } }); }); return errors; } function validateDocumentFields( document: StackbitTypes.Document, documentField: StackbitTypes.DocumentFieldNonLocalized, fieldPath: (string | number)[], contentSourceData: ContentStoreTypes.ContentSourceData, locale?: string ): ContentStoreTypes.ValidationError[] { const errors: ContentStoreTypes.ValidationError[] = []; if (documentField.type === 'object') { _.forEach(documentField.fields, (documentField, fieldName) => { const localizedField = getLocalizedFieldForLocale(documentField, locale); if (localizedField) { errors.push(...validateDocumentFields(document, localizedField, fieldPath.concat(fieldName), contentSourceData, locale)); } }); } else if (documentField.type === 'model') { _.forEach(documentField.fields, (documentField, fieldName) => { const localizedField = getLocalizedFieldForLocale(documentField, locale); if (localizedField) { errors.push(...validateDocumentFields(document, localizedField, fieldPath.concat(fieldName), contentSourceData, locale)); } }); } else if (documentField.type === 'reference') { const objRef = documentField.refType === 'asset' ? contentSourceData.assetMap[documentField.refId] : contentSourceData.documentMap[documentField.refId]; if (!objRef) { errors.push({ fieldPath, srcType: contentSourceData.srcType, srcProjectId: contentSourceData.srcProjectId, srcObjectType: documentField.refType, srcObjectId: document.id, message: `Can't find referenced ${documentField.refType}: ${documentField.refId}` }); } } else if (documentField.type === 'list') { _.forEach(documentField.items, (documentField, i) => { errors.push(...validateDocumentFields(document, documentField, fieldPath.concat(i), contentSourceData, locale)); }); } return errors; }