UNPKG

osls

Version:

Open-source alternative to Serverless Framework

386 lines (339 loc) 13.4 kB
'use strict'; const _ = require('lodash'); const ensurePlainObject = require('type/plain-object/ensure'); const schema = require('../../config-schema'); const ServerlessError = require('../../serverless-error'); const normalizeAjvErrors = require('./normalize-ajv-errors'); const resolveAjvValidate = require('./resolve-ajv-validate'); const { log, style } = require('@serverless/utils/log'); 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); 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; } 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); } module.exports = ConfigSchemaHandler;