import _ from 'lodash' import ensurePlainObject from 'type/plain-object/ensure.js' import schema from '../../config-schema.js' import ServerlessError from '../../serverless-error.js' import normalizeAjvErrors from './normalize-ajv-errors.js' import resolveAjvValidate from './resolve-ajv-validate.js' import utils from '@serverlessinc/sf-core/src/utils.js' const { log, style } = utils const FUNCTION_NAME_PATTERN = '^[a-zA-Z0-9-_]+$' const ERROR_PREFIX = 'Configuration error' const normalizeSchemaObject = (object, instanceSchema) => { for (const [key, value] of Object.entries(object)) { if (!_.isObject(value)) continue if (!value.$ref) { normalizeSchemaObject(value, instanceSchema) continue } if (!value.$ref.startsWith('#/definitions/')) { throw new Error(`Unsupported reference ${value.$ref}`) } object[key] = _.get(instanceSchema, value.$ref.slice(2).split('/')) } } // Normalizer is introduced to workaround https://github.com/ajv-validator/ajv/issues/1287 // normalizedObjectsMap allows to handle circular structures without issues const normalizeUserConfig = (userConfig) => { const normalizedObjectsSet = new WeakSet() const removedValuesMap = [] const normalizeObject = (object, path) => { if (normalizedObjectsSet.has(object)) return normalizedObjectsSet.add(object) if (Array.isArray(object)) { for (const [index, value] of object.entries()) { if (_.isObject(value)) normalizeObject(value, path.concat(index)) } } else { for (const [key, value] of Object.entries(object)) { if (value == null) { removedValuesMap.push({ path: path.concat(key), value }) delete object[key] } else if (_.isObject(value)) { normalizeObject(value, path.concat(key)) } } } } normalizeObject(userConfig, []) return { removedValuesMap } } const denormalizeUserConfig = (userConfig, { removedValuesMap }) => { for (const removedValueData of removedValuesMap) { _.set(userConfig, removedValueData.path, removedValueData.value) } } const configurationValidationResults = new WeakMap() class ConfigSchemaHandler { constructor(serverless) { this.serverless = serverless this.schema = _.cloneDeep(schema) deepFreeze(this.schema.properties.service) deepFreeze(this.schema.properties.plugins) deepFreeze(this.schema.properties.package) } static getConfigurationValidationResult(configuration) { if (!configurationValidationResults.has(ensurePlainObject(configuration))) return null return configurationValidationResults.get(ensurePlainObject(configuration)) } async validateConfig(userConfig) { if (!this.schema.properties.provider.properties.name) { configurationValidationResults.set( this.serverless.configurationInput, false, ) if (this.serverless.service.configValidationMode !== 'off') { log.warning( [ `You're relying on provider "${this.serverless.service.provider.name}" defined by a plugin which doesn't provide a validation schema for its config.`, `Please report the issue at its bug tracker linking: ${style.link( 'https://www.serverless.com/framework/docs/providers/aws/guide/plugins#extending-validation-schema', )}`, 'You may turn off this message with "configValidationMode: off" setting', '', ].join('\n'), ) } this.relaxProviderSchema() } // Workaround https://github.com/ajv-validator/ajv/issues/1255 normalizeSchemaObject(this.schema, this.schema) const validate = await resolveAjvValidate(this.schema) const denormalizeOptions = normalizeUserConfig(userConfig) validate(userConfig) denormalizeUserConfig(userConfig, denormalizeOptions) if ( !configurationValidationResults.has(this.serverless.configurationInput) ) { configurationValidationResults.set( this.serverless.configurationInput, !validate.errors, ) } if ( validate.errors && this.serverless.service.configValidationMode !== 'off' ) { const messages = normalizeAjvErrors(validate.errors).map( (err) => err.message, ) this.handleErrorMessages(messages) } } handleErrorMessages(messages) { if (messages.length) { if (this.serverless.service.configValidationMode === 'error') { throw new ServerlessError( `${ messages.length > 1 ? `${ERROR_PREFIX}: \n ${messages.join('\n ')}` : `${ERROR_PREFIX} ${messages[0]}` }\n\nLearn more about configuration validation here: http://slss.io/configuration-validation`, 'INVALID_NON_SCHEMA_COMPLIANT_CONFIGURATION', ) } else { log.notice() log.warning( [ 'Invalid configuration encountered', ...messages.map((message) => ` ${message}`), '', `Learn more about configuration validation here: ${style.link( 'http://slss.io/configuration-validation', )}`, ].join('\n'), ) if (!this.serverless.configurationInput.configValidationMode) { this.serverless._logDeprecation( 'CONFIG_VALIDATION_MODE_DEFAULT_V3', 'Starting with the next major, Serverless will throw on configuration errors by' + ' default. Adapt to this behavior now by adding "configValidationMode: error" to' + ' the service configuration', ) } } } } defineTopLevelProperty(name, subSchema) { if (this.schema.properties[name]) { throw new ServerlessError( `Top-level property '${name}' already have a definition - this property might have already been defined by the Serverless framework or one other plugin`, 'SCHEMA_COLLISION', ) } this.schema.properties[name] = subSchema } defineBuildProperty(name, subSchema) { const idx = this.schema.properties.build.anyOf.findIndex( (item) => item.type === 'object', ) if (this.schema.properties.build.anyOf[idx].properties[name]) { throw new ServerlessError( `Build property '${name}' already have a definition - this property might have already been defined by the Serverless framework or one other plugin`, 'SCHEMA_COLLISION', ) } this.schema.properties.build.anyOf[idx].properties[name] = subSchema } defineProvider(name, options = {}) { const currentProvider = this.serverless.service.provider.name if (currentProvider !== name) { return } if (options.definitions) { Object.assign(this.schema.definitions, options.definitions) } this.schema.properties.provider.properties.name = { const: name } if (options.provider) { try { addPropertiesToSchema(this.schema.properties.provider, options.provider) } catch (error) { if (error instanceof PropertyCollisionError) { throw new ServerlessError( `Property 'provider.${error.property}' already have a definition - this property might have already been defined by the Serverless framework or one other plugin`, 'SCHEMA_COLLISION', ) } throw error } } if (options.function) { try { addPropertiesToSchema( this.schema.properties.functions.patternProperties[ FUNCTION_NAME_PATTERN ], options.function, ) } catch (error) { if (error instanceof PropertyCollisionError) { throw new ServerlessError( `Property 'functions[].${error.property}' already have a definition - this property might have already been defined by the Serverless framework or one other plugin`, 'SCHEMA_COLLISION', ) } throw error } } if (options.functionEvents) { for (const functionName of Object.keys(options.functionEvents)) { this.defineFunctionEvent( name, functionName, options.functionEvents[functionName], ) } } if (options.resources) this.schema.properties.resources = options.resources if (options.layers) this.schema.properties.layers = options.layers // In case provider implementers do not set stage then it is set here. // The framework internally sets these options in Service class. if (!this.schema.properties.provider.properties.stage) { addPropertiesToSchema(this.schema.properties.provider, { properties: { stage: { type: 'string' } }, }) } } defineCustomProperties(configSchemaParts) { try { addPropertiesToSchema(this.schema.properties.custom, configSchemaParts) } catch (error) { if (error instanceof PropertyCollisionError) { throw new ServerlessError( `Property 'custom.${error.property}' already have a definition - this property might have already been defined by the Serverless framework or one other plugin`, 'SCHEMA_COLLISION', ) } throw error } } defineFunctionEvent(providerName, name, configSchema) { if (this.serverless.service.provider.name !== providerName) { return } const existingFunctionEvents = new Set( this.schema.properties.functions.patternProperties[ FUNCTION_NAME_PATTERN ].properties.events.items.anyOf.map((functionEventSchema) => Object.keys(functionEventSchema.properties).pop(), ), ) if (existingFunctionEvents.has(name)) { throw new ServerlessError( `Function event '${name}' already have a definition - this event might have already been defined by the Serverless framework or one other plugin`, 'SCHEMA_COLLISION', ) } this.schema.properties.functions.patternProperties[ FUNCTION_NAME_PATTERN ].properties.events.items.anyOf.push({ type: 'object', properties: { [name]: configSchema }, required: [name], additionalProperties: false, }) } defineFunctionEventProperties(providerName, name, configSchema) { if (this.serverless.service.provider.name !== providerName) { return } const existingEventDefinition = this.schema.properties.functions.patternProperties[ FUNCTION_NAME_PATTERN ].properties.events.items.anyOf.find( (eventDefinition) => name === eventDefinition.required[0], ) if (!existingEventDefinition) { throw new ServerlessError( `Event '${name}' is not an existing function event`, 'UNRECOGNIZED_FUNCTION_EVENT_SCHEMA', ) } let definitionToUpdate if (existingEventDefinition.properties[name].type === 'object') { // Event root definition is an object definition definitionToUpdate = existingEventDefinition.properties[name] } else if (existingEventDefinition.properties[name].anyOf) { // Event root definition has multiple definitions. Finding the object definition definitionToUpdate = existingEventDefinition.properties[name].anyOf.find( (definition) => definition.type === 'object', ) } if (!definitionToUpdate) { throw new ServerlessError( `Event '${name}' has no object definition. Its schema cannot be modified`, 'FUNCTION_EVENT_SCHEMA_NOT_OBJECT', ) } try { addPropertiesToSchema(definitionToUpdate, configSchema) } catch (error) { if (error instanceof PropertyCollisionError) { throw new ServerlessError( `Property 'functions[].events[].${name}.${error.property}' already have a definition - this property might have already been defined by the Serverless framework or one other plugin`, 'SCHEMA_COLLISION', ) } throw error } } defineFunctionProperties(providerName, configSchema) { if (this.serverless.service.provider.name !== providerName) { return } try { addPropertiesToSchema( this.schema.properties.functions.patternProperties[ FUNCTION_NAME_PATTERN ], configSchema, ) } catch (error) { if (error instanceof PropertyCollisionError) { throw new ServerlessError( `Property 'functions[].${error.property}' already have a definition - this property might have already been defined by the Serverless framework or one other plugin`, 'SCHEMA_COLLISION', ) } throw error } } relaxProviderSchema() { // provider this.schema.properties.provider.additionalProperties = true // functions[] this.schema.properties.functions.patternProperties[ FUNCTION_NAME_PATTERN ].additionalProperties = true // functions[].events[] if ( Array.isArray( this.schema.properties.functions.patternProperties[ FUNCTION_NAME_PATTERN ].properties.events.items.anyOf, ) && this.schema.properties.functions.patternProperties[FUNCTION_NAME_PATTERN] .properties.events.items.anyOf.length === 1 ) { this.schema.properties.functions.patternProperties[ FUNCTION_NAME_PATTERN ].properties.events.items = {} } } } class PropertyCollisionError extends Error { constructor(property) { super() this.property = property } } function addPropertiesToSchema( subSchema, extension = { properties: {}, required: [] }, ) { let collidingExtensionPropertyKey const existingSubSchemaPropertiesKeys = new Set( Object.keys(subSchema.properties), ) Object.keys(extension.properties).some((extensionPropertiesKey) => { const isColliding = existingSubSchemaPropertiesKeys.has( extensionPropertiesKey, ) if (isColliding) collidingExtensionPropertyKey = extensionPropertiesKey return isColliding }) if (collidingExtensionPropertyKey) { throw new PropertyCollisionError(collidingExtensionPropertyKey) } subSchema.properties = Object.assign( subSchema.properties, extension.properties, ) if (!subSchema.required) subSchema.required = [] if (Array.isArray(extension.required)) subSchema.required.push(...extension.required) } /* * Deep freezes an object. Stolen from * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze */ function deepFreeze(object) { const propNames = Object.getOwnPropertyNames(object) for (const name of propNames) { const value = object[name] if (value && typeof value === 'object') { deepFreeze(value) } } return Object.freeze(object) } export default ConfigSchemaHandler