import { CompassComponentType, CustomFieldFromYAML, CustomFieldType, CustomFields, } from '@atlassian/forge-graphql-types'; import { MISSING_CUSTOM_FIELDS_IN_COMPONENT, invalidCustomFieldTypeErrorMessage, invalidCustomFieldTypeForExistingComponent, invalidFieldTypeErrorMessage, invalidFieldValueErrorMessage, invalidKeyErrorMessage, invalidLinkTypeErrorMessage, invalidOptionIdTypeErrorMessage, invalidValueTypeErrorMessage, labelHasUppercaseErrorMessage, labelHasWhitespaceErrorMessage, maxValueLengthErrorMessage, missingKeyErrorMessage, missingNestedKeyErrorMessage, missingSpecificCustomFieldInComponent, multipleSameCustomFieldsError, } from './models/error-messages'; import { configKeyTypes, customFieldsTypes, fieldKeyTypes, isRequired, linkKeyTypes, parseType, relationshipKeyTypes, types, validLifecycleValues, validLinkTypes, validTierValues, validTypeIdValues, } from './models/compass-yaml-types'; import { MAX_COMPONENT_SLUG_LENGTH, MAX_COMPONENT_TYPE_NAME_LENGTH, MAX_DESCRIPTION_LENGTH, MAX_LABEL_NAME_LENGTH, MAX_NAME_LENGTH, YAML_VERSION, } from '../../../../helpers/constants'; import { isValidARI, parseARI } from '../../../../helpers/parseAri'; import { capitalize } from '../../../../helpers/capitalize'; import { containsSpaces } from '../../../../helpers/containsSpaces'; import { containsUppercase } from '../../../../helpers/containsUppercase'; import { mappedCustomFieldsToYamlFormat } from '../buildCustomFields'; const unwrapPropertyKeys = (object: any, expectedObject: any): any => ({ actualKeys: Object.keys(object), expectedKeys: Object.keys(expectedObject), }); export function isObjEmpty(obj: Record): boolean { return Object.keys(obj).length === 0; } const isValidUUIDv4 = (uuid: string): boolean => { const v4UUIDPattern = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-4[0-9a-fA-F]{3}\b-[89abAB][0-9a-fA-F]{3}\b-[0-9a-fA-F]{12}$/i; return v4UUIDPattern.test(uuid); }; // Validates component slug is a nonblank string, < 64 characters, and contains no invalid chars const isComponentSlugValid = (slug: string): boolean => { if ( slug == null || typeof slug !== 'string' || slug.trim().length === 0 || slug.length > MAX_COMPONENT_SLUG_LENGTH ) { return false; } return new RegExp('^[0-9a-zA-Z-]+$').test(slug); }; // Validates component type name is a nonblank string, < 128 characters const isComponentTypeNameValid = (name: string): boolean => { if ( name == null || typeof name !== 'string' || name.trim().length === 0 || name.length > MAX_COMPONENT_TYPE_NAME_LENGTH ) { return false; } return true; }; const isArray = (actualKeys: Array): boolean => actualKeys.length > 0 && actualKeys[0] === '0'; export default class ConfigFileParser { public errors: Array; private type: CompassComponentType | string; constructor(compassComponentType: CompassComponentType | string) { this.errors = []; this.type = compassComponentType; } validateConfigCustomFieldsAgainstComponent( configCustomFields?: CustomFieldFromYAML[], componentCustomFields?: CustomFields, ): void { if (this.errors.length || !configCustomFields) { return; } if (configCustomFields.length && !componentCustomFields) { this.addError(MISSING_CUSTOM_FIELDS_IN_COMPONENT); return; } configCustomFields.forEach((customField) => { const sameCustomFieldInComponent = componentCustomFields.find( (componentCustomField) => componentCustomField.definition.name === customField.name, ); if (!sameCustomFieldInComponent) { this.addError(missingSpecificCustomFieldInComponent(customField.name)); return; } const customFieldToYamlFormat = mappedCustomFieldsToYamlFormat( sameCustomFieldInComponent, ); if ( sameCustomFieldInComponent.definition.name === customField.name && customFieldToYamlFormat.type !== customField.type.toLocaleLowerCase() ) { this.addError( invalidCustomFieldTypeForExistingComponent( customFieldToYamlFormat.name, customFieldToYamlFormat.type, ), ); } }); } validateConfig(config: any): void { this.validateTopLevelProperties(config, configKeyTypes); const validFields = config.fields && this.validValueType(config.fields, configKeyTypes.fields, 'fields'); if (validFields) { this.validateFieldProperties(config.fields); } const validCustomFields = config.customFields && this.validValueType( config.customFields, configKeyTypes.customFields, 'customFields', ); if (validCustomFields) { this.validateCustomFieldsProperties(config.customFields); } const validLinks = config.links && this.validValueType(config.links, configKeyTypes.links, 'links'); if (validLinks) { this.validateLinkProperties(config.links); } const validRelationships = config.relationships && this.validValueType( config.relationships, configKeyTypes.relationships, 'relationships', ); if (validRelationships) { this.validateRelationshipProperties(config.relationships); } const validLabels = config.labels && this.validValueType(config.labels, configKeyTypes.labels, 'labels'); if (validLabels) { this.validateLabels(config.labels, MAX_LABEL_NAME_LENGTH); } } validateTopLevelProperties(object: any, expectedObject: any): void { const { actualKeys, expectedKeys } = unwrapPropertyKeys( object, expectedObject, ); this.checkForMandatoryKeys(actualKeys, expectedObject); for (const key of actualKeys) { this.checkIfKeyIsUnknown(key, expectedKeys); if ( ![ 'fields', 'links', 'relationships', 'labels', 'customFields', ].includes(key) ) { this.validValueType(object[key], expectedObject[key], key); } } } validateFieldProperties(fields: any): void { const { actualKeys, expectedKeys } = unwrapPropertyKeys( fields, fieldKeyTypes, ); if (isArray(actualKeys)) { this.addError(invalidValueTypeErrorMessage('fields', 'object')); return; } this.checkForMandatoryKeys(actualKeys, fieldKeyTypes, 'fields'); this.checkForUnknownKeys(actualKeys, expectedKeys); for (const [key, value] of Object.entries(fields)) { this.checkFields(key, value); } } validateCustomFieldsProperties(customFields: any): void { if (customFields === null) { return; } if (!Array.isArray(customFields)) { this.addError(invalidValueTypeErrorMessage('customFields', 'array')); return; } const mappedByNameCustomFields = customFields.reduce<{ [key: string]: any[]; }>((groupedCustomFields, currentCustomField) => { if (groupedCustomFields[currentCustomField.name]) { groupedCustomFields[currentCustomField.name].push(currentCustomField); return groupedCustomFields; } // eslint-disable-next-line no-param-reassign groupedCustomFields[currentCustomField.name] = [currentCustomField]; return groupedCustomFields; }, {}); for (const customField of customFields) { if (!this.isCustomFieldTypeValid(customField.type)) { return; } if (mappedByNameCustomFields[customField.name].length > 1) { this.addError(multipleSameCustomFieldsError(customField.name)); return; } const actualKeys = Object.keys(customField); const expectedCustomFieldTypes = customFieldsTypes[ customField.type.toLocaleLowerCase() as CustomFieldType ]; this.checkForMandatoryKeys( actualKeys, expectedCustomFieldTypes, 'customFields', ); this.checkForUnknownKeys( actualKeys, Object.keys(expectedCustomFieldTypes), ); Object.keys(customField).forEach((customFieldKey) => { this.validValueType( customField[customFieldKey], expectedCustomFieldTypes[customFieldKey], customFieldKey, ); }); } } validateLinkProperties(links: any): void { if (links == null) { return; } if (!Array.isArray(links)) { this.addError(invalidValueTypeErrorMessage('links', 'array')); return; } for (const link of links) { this.checkLinkType(link.type); const actualKeys = Object.keys(link); this.checkForMandatoryKeys(actualKeys, linkKeyTypes, 'links'); this.checkForUnknownLinkKeys(actualKeys, link); } } validateRelationshipProperties(relationships: any): void { const { actualKeys, expectedKeys } = unwrapPropertyKeys( relationships, relationshipKeyTypes, ); if (isArray(actualKeys)) { this.addError(invalidValueTypeErrorMessage('relationships', 'object')); return; } this.checkForMandatoryKeys(actualKeys, expectedKeys, 'relationships'); this.checkForUnknownKeys(actualKeys, expectedKeys); for (const key of Object.keys(relationships)) { // Check that the relationship type is valid ie. DEPENDS_ON if (Object.keys(relationshipKeyTypes).includes(key)) { // Validate the array of ARIs const validRelationshipsArray = !this.validValueType( relationships[key], (relationshipKeyTypes as any)[key], key, ); if (validRelationshipsArray) { this.validateRelationshipsArray(relationships, key); } } } } validateRelationshipsArray( endNodes: Array, relationshipType: string, ): void { if (typeof endNodes !== 'object' || endNodes == null) { return; } endNodes.forEach((componentId) => { this.validValueType( componentId, types.REQUIRED_ARI_OR_COMPONENT_SLUG, `${relationshipType} elements`, ); }); } validateLabels(labels: Array, maxLabelLength: number): void { if (labels == null) { return; } if (!Array.isArray(labels)) { this.addError(invalidValueTypeErrorMessage('labels', 'array')); return; } labels.forEach((label) => { const isvalid = this.validValueType( label, types.REQUIRED_STRING, 'label', ); if (isvalid) { this.checkLabelValue(label, maxLabelLength); } }); } // CHECKS checkForMandatoryKeys( actualKeys: Array, expectedObject: any, topLevelProperty?: string, ): void { // Check if there are keys that are required to exist in config file but do not const expectedKeys = Object.keys(expectedObject); const mandatoryKeys = expectedKeys.filter((key) => isRequired(expectedObject[key]), ); const missingKeys = []; for (const key of mandatoryKeys) { if (!actualKeys.includes(key)) { missingKeys.push(key); } } if (missingKeys.length > 0) { const errorMessage = topLevelProperty ? missingNestedKeyErrorMessage(missingKeys, topLevelProperty) : missingKeyErrorMessage(missingKeys); this.addError(errorMessage); } } checkForUnknownKeys( actualKeys: Array, expectedKeys: Array, ): void { for (const key of actualKeys) { this.checkIfKeyIsUnknown(key, expectedKeys, true); } } checkForUnknownLinkKeys(actualKeys: Array, link: any): void { for (const key of actualKeys) { this.checkIfKeyIsUnknown(key, Object.keys(linkKeyTypes), true); if (key !== 'type') { this.validValueType(link[key], (linkKeyTypes as any)[key], key); } } } checkIfKeyIsUnknown( key: string, expectedKeys: Array, nested = false, ): void { // Check if there are extra keys not defined in config file if (!expectedKeys.includes(key)) { const errorMessage = nested ? invalidKeyErrorMessage(key, expectedKeys) : invalidKeyErrorMessage(key); this.addError(errorMessage); } } validValueType(value: any, expectedType: string, key: string): boolean { let isValid = true; // checkIfKeyIsUnknown will catch this if (expectedType === undefined) { return isValid; } if (!isRequired(expectedType) && value == null) { return isValid; } if ( expectedType === types.OPTIONAL_ARI || expectedType === types.REQUIRED_ARI || expectedType === types.REQUIRED_ARI_OR_COMPONENT_SLUG ) { if (!isValidARI(value)) { if ( expectedType !== types.REQUIRED_ARI_OR_COMPONENT_SLUG || !isComponentSlugValid(value) ) { isValid = false; this.addError(invalidValueTypeErrorMessage(key, 'ARI')); } } return isValid; } if (expectedType === types.OPTIONAL_USER_ARI) { try { const { resourceId } = parseARI(value); if (!resourceId) { isValid = false; this.addError(invalidValueTypeErrorMessage(key, 'ARI')); } } catch (e) { isValid = false; this.addError(invalidValueTypeErrorMessage(key, 'ARI')); } return isValid; } if (expectedType === types.OPTIONAL_OPTION_UUID) { if (!isValidUUIDv4(value)) { this.addError(invalidOptionIdTypeErrorMessage(value, 'UUID')); } return false; } if (expectedType === types.OPTIONAL_OPTION_UUID_LIST) { if (!Array.isArray(value)) { this.addError(invalidValueTypeErrorMessage(key, 'array')); return false; } value.forEach((optionId) => { if (!isValidUUIDv4(optionId)) { this.addError(invalidOptionIdTypeErrorMessage(optionId, 'UUID')); isValid = false; } }); return isValid; } if (expectedType === types.OPTIONAL_STRING_LIST) { if (!Array.isArray(value)) { this.addError(invalidValueTypeErrorMessage(key, 'array')); isValid = false; } value.forEach((strVal: string) => { if (typeof strVal !== 'string') { this.addError(invalidValueTypeErrorMessage(key, 'string')); isValid = false; } }); return isValid; } const parsedExpectedTypes = parseType(expectedType).split('|'); if ( !parsedExpectedTypes.some( (parsedExpectedType) => typeof value === parsedExpectedType, ) ) { isValid = false; this.addError( invalidValueTypeErrorMessage(key, parsedExpectedTypes.join(', ')), ); } if (isValid && key === 'name') { if ((value as string).length > MAX_NAME_LENGTH) { isValid = false; this.addError(maxValueLengthErrorMessage(key, MAX_NAME_LENGTH)); } } if ( isValid && key === 'description' && (value as string).length > MAX_DESCRIPTION_LENGTH ) { isValid = false; this.addError(maxValueLengthErrorMessage(key, MAX_DESCRIPTION_LENGTH)); } if (isValid && key === 'configVersion' && Number(value) !== YAML_VERSION) { isValid = false; this.addError(invalidFieldValueErrorMessage(key, YAML_VERSION)); } if ( isValid && key === 'typeId' && !( Object.values(validTypeIdValues).includes(value.toUpperCase()) || new RegExp('^ari:cloud:compass:[^:]+:component-type/[^/]+/[^/]+$').test( value, ) || isComponentTypeNameValid(value) ) ) { isValid = false; this.addError( `"${key}" must be one of these built-in types or a custom type ARI: ${Object.values( validTypeIdValues, ).join(', ')}`, ); } return isValid; } checkFields(key: string, value: any): void { if (key === 'tier' && !validTierValues.includes(value.toString())) { this.addError(invalidFieldTypeErrorMessage('tier', validTierValues)); } if ( key === 'lifecycle' && !Object.values(validLifecycleValues).includes(capitalize(value)) ) { this.addError( invalidFieldTypeErrorMessage( 'lifecycle', Object.values(validLifecycleValues), ), ); } } checkLinkType(type: string): void { if (type == null) { return; } if ( typeof type !== 'string' || !validLinkTypes.includes(type.toUpperCase()) ) { this.addError(invalidLinkTypeErrorMessage(type, validLinkTypes)); } } isCustomFieldTypeValid(type: string): boolean { const customFieldTypes = Object.keys(CustomFieldType); if (type == null) { this.addError(invalidCustomFieldTypeErrorMessage(type, customFieldTypes)); return false; } if ( typeof type !== 'string' || !customFieldTypes.includes(type.toUpperCase()) ) { this.addError(invalidCustomFieldTypeErrorMessage(type, customFieldTypes)); return false; } return true; } checkLabelValue(label: string, maxLabelLength: number): void { if (label.length > maxLabelLength) { this.addError(maxValueLengthErrorMessage('label', maxLabelLength)); } if (containsSpaces(label)) { this.addError(labelHasWhitespaceErrorMessage(label)); } if (containsUppercase(label)) { this.addError(labelHasUppercaseErrorMessage(label)); } } addError(message: string): void { this.errors.push(message); } }