UNPKG

@dossierhq/core

Version:

The core Dossier library used by clients and server alike, used to interact with schema and entities directly, as well as remotely through a client.

334 lines 13.8 kB
/// <reference types="./ContentValidator.d.ts" /> import { assertExhaustive, assertIsDefined } from '../utils/Asserts.js'; import { ContentTraverseNodeErrorType, ContentTraverseNodeType, } from './ContentTraverser.js'; import { isComponentItemField, isLocationItemField, isNumberItemField, isReferenceItemField, isRichTextTextNode, isStringItemField, } from './ContentTypeUtils.js'; const LINE_BREAK_REGEX = /[\r\n]/; export function validateEntityInfo(schema, path, entity) { // info.type, info.authKey const typeAuthKeyValidation = validateTypeAndAuthKey(schema, path, entity, false); if (typeAuthKeyValidation) return typeAuthKeyValidation; // info.name const saveValidation = validateName(path, entity.info.name, false); if (saveValidation) return saveValidation; return null; } export function validateEntityInfoForCreate(schema, path, entity) { // info.type, info.authKey const typeAuthKeyValidation = validateTypeAndAuthKey(schema, path, entity, true); if (typeAuthKeyValidation) return typeAuthKeyValidation; // info.name const saveValidation = validateName(path, entity.info.name, true); if (saveValidation) return saveValidation; // info.version const version = entity.info.version; if (version !== undefined && version !== 1) { return { type: 'save', path: [...path, 'info', 'version'], message: `Version must be 1 when creating a new entity`, }; } return null; } export function validateEntityInfoForUpdate(path, existingEntity, entity) { if (entity.info?.type && entity.info.type !== existingEntity.info.type) { return { type: 'save', path: [...path, 'info', 'type'], message: `New type ${entity.info.type} doesn’t correspond to previous type ${existingEntity.info.type}`, }; } const authKey = entity.info?.authKey; if (authKey !== undefined && authKey !== null && authKey !== existingEntity.info.authKey) { return { type: 'save', path: [...path, 'info', 'authKey'], message: `New authKey doesn’t correspond to previous authKey (${authKey}!=${existingEntity.info.authKey})`, }; } if (entity.info?.name) { const saveValidation = validateName(path, entity.info.name, false); if (saveValidation) return saveValidation; } const expectedVersion = existingEntity.info.version + 1; if (entity.info?.version !== undefined && entity.info.version !== expectedVersion) { return { type: 'save', path: [...path, 'info', 'version'], message: `The latest version of the entity is ${existingEntity.info.version}, so the new version must be ${expectedVersion} (got ${entity.info.version})`, }; } return null; } function validateTypeAndAuthKey(schema, path, entity, create) { // info.type const type = entity.info.type; if (!type) { return { type: 'save', path: [...path, 'info', 'type'], message: 'Type is required' }; } const entitySpec = schema.getEntityTypeSpecification(type); if (!entitySpec) { return { type: 'save', path: [...path, 'info', 'type'], message: `Entity type ${type} doesn’t exist`, }; } // info.authKey let authKey = entity.info.authKey; if (create && (authKey === undefined || authKey === null)) { authKey = ''; } if (typeof authKey !== 'string') { return { type: 'save', path: [...path, 'info', 'authKey'], message: 'AuthKey must be a string', }; } if (entitySpec.authKeyPattern) { const authKeyRegExp = schema.getPatternRegExp(entitySpec.authKeyPattern); if (!authKeyRegExp) { return { type: 'save', path: [...path, 'info', 'authKey'], message: `Pattern '${entitySpec.authKeyPattern}' for authKey of type '${entitySpec.name}' not found`, }; } if (!authKeyRegExp.test(authKey)) { return { type: 'save', path: ['info', 'authKey'], message: `AuthKey '${authKey}' does not match pattern '${entitySpec.authKeyPattern}' (${authKeyRegExp.source})`, }; } } else { if (authKey !== '') { return { type: 'save', path: [...path, 'info', 'authKey'], message: 'AuthKey is not allowed for this entity type since no authKeyPattern is defined', }; } } return null; } function validateName(path, name, create) { if (!create && !name) { return { type: 'save', path: [...path, 'info', 'name'], message: 'Name is required' }; } if (name && LINE_BREAK_REGEX.test(name)) { return { type: 'save', path: [...path, 'info', 'name'], message: 'Name cannot contain line breaks', }; } return null; } export function validateTraverseNodeForSave(schema, node, options) { const ignoreExtraContentFields = !!options?.ignoreExtraContentFields; const nodeType = node.type; switch (nodeType) { case ContentTraverseNodeType.entity: { // Check if there are any extra fields if (!ignoreExtraContentFields) { const invalidFields = new Set(Object.keys(node.entity.fields)); node.entitySpec.fields.forEach((it) => invalidFields.delete(it.name)); if (invalidFields.size > 0) { return { type: 'save', path: [...node.path, 'fields'], message: `Invalid fields for entity of type ${node.entitySpec.name}: ${[ ...invalidFields, ].join(', ')}`, }; } } break; } case ContentTraverseNodeType.field: break; case ContentTraverseNodeType.fieldItem: if (isReferenceItemField(node.fieldSpec, node.value) && node.value) { const invalidKeys = Object.keys(node.value).filter((it) => it !== 'id'); if (invalidKeys.length > 0) { return { type: 'save', path: node.path, message: `Invalid keys for Entity: ${invalidKeys.join(', ')}`, }; } } else if (isLocationItemField(node.fieldSpec, node.value) && node.value) { const invalidKeys = Object.keys(node.value).filter((it) => it !== 'lat' && it !== 'lng'); if (invalidKeys.length > 0) { return { type: 'save', path: node.path, message: `Invalid keys for Location: ${invalidKeys.join(', ')}`, }; } } else if (isNumberItemField(node.fieldSpec, node.value) && node.value !== null) { const numberFieldSpec = node.fieldSpec; if (numberFieldSpec.integer && !Number.isInteger(node.value)) { return { type: 'save', path: node.path, message: 'Value must be an integer', }; } } else if (isStringItemField(node.fieldSpec, node.value) && node.value) { const stringFieldSpec = node.fieldSpec; if (stringFieldSpec.matchPattern) { const regexp = schema.getPatternRegExp(stringFieldSpec.matchPattern); assertIsDefined(regexp); if (!regexp.test(node.value)) { return { type: 'save', path: node.path, message: `Value does not match pattern ${stringFieldSpec.matchPattern}`, }; } } if (!stringFieldSpec.multiline && LINE_BREAK_REGEX.test(node.value)) { return { type: 'save', path: node.path, message: 'Value cannot contain line breaks', }; } if (stringFieldSpec.values.length > 0) { const match = stringFieldSpec.values.some((it) => it.value === node.value); if (!match) { return { type: 'save', path: node.path, message: 'Value does not match any of the allowed values', }; } } } else if (isComponentItemField(node.fieldSpec, node.value) && node.value) { const componentFieldSpec = node.fieldSpec; if (componentFieldSpec.componentTypes.length > 0 && !componentFieldSpec.componentTypes.includes(node.value.type)) { return { type: 'save', path: node.path, message: `Component of type ${node.value.type} is not allowed in field (supported types: ${componentFieldSpec.componentTypes.join(', ')})`, }; } } break; case ContentTraverseNodeType.error: return { type: 'save', path: node.path, message: node.message }; case ContentTraverseNodeType.richTextNode: { const richTextFieldSpec = node.fieldSpec; if (richTextFieldSpec.richTextNodes && richTextFieldSpec.richTextNodes.length > 0) { if (!richTextFieldSpec.richTextNodes.includes(node.node.type)) { return { type: 'save', path: node.path, message: `Rich text node type ${node.node.type} is not allowed in field (supported nodes: ${richTextFieldSpec.richTextNodes.join(', ')})`, }; } } if (isRichTextTextNode(node.node)) { if (LINE_BREAK_REGEX.test(node.node.text)) { return { type: 'save', path: node.path, message: 'Rich text text nodes cannot contain line breaks, use linebreak nodes instead', }; } } else if (node.node.type === 'valueItem') { // Renamed after v 0.4.7 return { type: 'save', path: node.path, message: 'Rich text node valueItem should be converted to a component node', }; } break; } case ContentTraverseNodeType.component: // Check if there are any extra fields if (!ignoreExtraContentFields) { const invalidFields = new Set(Object.keys(node.component)); invalidFields.delete('type'); node.componentSpec.fields.forEach((it) => invalidFields.delete(it.name)); if (invalidFields.size > 0) { return { type: 'save', path: node.path, message: `Invalid fields for component of type ${node.component.type}: ${[ ...invalidFields, ].join(', ')}`, }; } } break; default: assertExhaustive(nodeType); } return null; } export function validateTraverseNodeForPublish(schema, node) { switch (node.type) { case ContentTraverseNodeType.field: if (node.fieldSpec.required && node.value === null) { return { type: 'publish', path: node.path, message: 'Required field is empty', }; } break; case ContentTraverseNodeType.error: if (node.errorType === ContentTraverseNodeErrorType.missingTypeSpec && node.kind === 'component') { const fullTypeSpec = schema.getComponentTypeSpecification(node.typeName); if (fullTypeSpec?.adminOnly) { return { type: 'publish', path: node.path, message: `Component of type ${node.typeName} is adminOnly`, }; } } return { type: 'publish', path: node.path, message: node.message }; } return null; } export function groupValidationIssuesByTopLevelPath(errors) { const root = []; const children = new Map(); for (const error of errors) { if (error.path.length === 0) { root.push(error); } else { const [topLevel, ...rest] = error.path; const newError = { ...error, path: rest }; const existingErrors = children.get(topLevel); if (existingErrors) { existingErrors.push(newError); } else { children.set(topLevel, [newError]); } } } return { root, children }; } //# sourceMappingURL=ContentValidator.js.map