mirror of
https://github.com/serverless/serverless.git
synced 2025-12-08 19:46:03 +00:00
448 lines
14 KiB
JavaScript
448 lines
14 KiB
JavaScript
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
|