2024-05-29 11:51:04 -04:00

758 lines
25 KiB
JavaScript

import _ from 'lodash'
import d from 'd'
import memoizee from 'memoizee'
import memoizeeMethods from 'memoizee/methods.js'
import utils from '@serverlessinc/sf-core/src/utils.js'
import ServerlessError from '../../../../../serverless-error.js'
import resolveLambdaTarget from '../../../utils/resolve-lambda-target.js'
const { log } = utils
const allowedMethods = new Set([
'ANY',
'GET',
'POST',
'PUT',
'PATCH',
'OPTIONS',
'HEAD',
'DELETE',
])
const methodPattern = new RegExp(
`^(?:\\*|${Array.from(allowedMethods).join('|')})$`,
'i',
)
const methodPathPattern = new RegExp(
`^(?:\\*|(${Array.from(allowedMethods).join('|')}) (\\/\\S*))$`,
'i',
)
const resolveTargetConfig = memoizee(({ functionLogicalId, functionAlias }) => {
const functionArnGetter = { 'Fn::GetAtt': [functionLogicalId, 'Arn'] }
if (!functionAlias) return functionArnGetter
return { 'Fn::Join': [':', [functionArnGetter, functionAlias.name]] }
})
const defaultCors = {
allowedOrigins: new Set(['*']),
allowedHeaders: new Set([
'Content-Type',
'X-Amz-Date',
'Authorization',
'X-Api-Key',
'X-Amz-Security-Token',
'X-Amz-User-Agent',
'X-Amzn-Trace-Id',
]),
}
const toSet = (item) => new Set(Array.isArray(item) ? item : [item])
class HttpApiEvents {
constructor(serverless) {
this.serverless = serverless
this.provider = this.serverless.getProvider('aws')
serverless.httpApiEventsPlugin = this
this.hooks = {
initialize: () => {
if (
this.serverless.service.provider.name === 'aws' &&
_.get(this.serverless.service.provider.httpApi, 'useProviderTags')
) {
this.serverless._logDeprecation(
'AWS_HTTP_API_USE_PROVIDER_TAGS_PROPERTY',
'Property "provider.httpApi.useProviderTags" is no longer effective as provider tags are applied to Http Api Gateway by default. You can safely remove this property from your configuration.',
)
}
},
'package:compileEvents': () => {
this.resolveConfiguration()
if (!this.config.routes.size) return
this.cfTemplate =
this.serverless.service.provider.compiledCloudFormationTemplate
this.compileApi()
this.compileLogGroup()
this.compileStage()
this.compileAuthorizers()
this.compileEndpoints()
},
}
this.serverless.configSchemaHandler.defineFunctionEvent('aws', 'httpApi', {
anyOf: [
{ type: 'string', regexp: methodPathPattern.toString() },
{
type: 'object',
properties: {
authorizer: {
anyOf: [
{ type: 'string' },
{
type: 'object',
properties: {
id: {
anyOf: [
{ type: 'string' },
{ $ref: '#/definitions/awsCfFunction' },
],
},
name: { type: 'string' },
scopes: { type: 'array', items: { type: 'string' } },
type: {
type: 'string',
enum: ['request', 'jwt', 'aws_iam'],
},
},
anyOf: [
{ required: ['id'] },
{ required: ['name'] },
{ required: ['type'] },
],
additionalProperties: false,
},
],
},
method: { type: 'string', regexp: methodPattern.toString() },
path: { type: 'string', regexp: /^(?:\*|\/\S*)$/.toString() },
},
required: ['path'],
additionalProperties: false,
},
],
})
}
getApiIdConfig() {
return this.config.id || { Ref: this.provider.naming.getHttpApiLogicalId() }
}
compileApi() {
if (this.config.id) return
const properties = {
Name: this.provider.naming.getHttpApiName(),
ProtocolType: 'HTTP',
DisableExecuteApiEndpoint:
this.config.disableDefaultEndpoint == null
? undefined
: this.config.disableDefaultEndpoint,
}
if (this.serverless.service.provider.tags) {
const tags = Object.assign({}, this.serverless.service.provider.tags)
properties.Tags = tags
}
const cors = this.config.cors
if (cors) {
properties.CorsConfiguration = {
AllowCredentials: cors.allowCredentials,
AllowHeaders: Array.from(cors.allowedHeaders),
AllowMethods: Array.from(cors.allowedMethods),
AllowOrigins: Array.from(cors.allowedOrigins),
ExposeHeaders:
cors.exposedResponseHeaders &&
Array.from(cors.exposedResponseHeaders),
MaxAge: cors.maxAge,
}
}
this.cfTemplate.Resources[this.provider.naming.getHttpApiLogicalId()] = {
Type: 'AWS::ApiGatewayV2::Api',
Properties: properties,
}
}
compileLogGroup() {
if (!this.config.accessLogFormat) return
const resource = {
Type: 'AWS::Logs::LogGroup',
Properties: {
LogGroupName: this.provider.naming.getHttpApiLogGroupName(),
},
}
const logRetentionInDays = this.provider.getLogRetentionInDays()
if (logRetentionInDays) {
resource.Properties.RetentionInDays = logRetentionInDays
}
const logDataProtectionPolicy = this.provider.getLogDataProtectionPolicy()
if (logDataProtectionPolicy) {
resource.Properties.DataProtectionPolicy = logDataProtectionPolicy
}
this.cfTemplate.Resources[
this.provider.naming.getHttpApiLogGroupLogicalId()
] = resource
}
compileStage() {
if (this.config.id) return
const properties = {
ApiId: { Ref: this.provider.naming.getHttpApiLogicalId() },
StageName: '$default',
AutoDeploy: true,
DefaultRouteSettings: {
DetailedMetricsEnabled: this.config.metrics,
},
}
if (this.serverless.service.provider.tags) {
properties.Tags = Object.assign({}, this.serverless.service.provider.tags)
}
const resource = (this.cfTemplate.Resources[
this.provider.naming.getHttpApiStageLogicalId()
] = {
Type: 'AWS::ApiGatewayV2::Stage',
Properties: properties,
})
if (this.config.accessLogFormat) {
properties.AccessLogSettings = {
DestinationArn: {
'Fn::GetAtt': [
this.provider.naming.getHttpApiLogGroupLogicalId(),
'Arn',
],
},
Format: this.config.accessLogFormat,
}
resource.DependsOn = this.provider.naming.getHttpApiLogGroupLogicalId()
}
this.cfTemplate.Outputs.HttpApiId = {
Description: 'Id of the HTTP API',
Value: { Ref: this.provider.naming.getHttpApiLogicalId() },
}
this.cfTemplate.Outputs.HttpApiUrl = {
Description: 'URL of the HTTP API',
Value: {
'Fn::Join': [
'',
[
'https://',
{ Ref: this.provider.naming.getHttpApiLogicalId() },
'.execute-api.',
{ Ref: 'AWS::Region' },
'.',
{ Ref: 'AWS::URLSuffix' },
],
],
},
}
}
compileAuthorizers() {
for (const authorizer of this.config.authorizers.values()) {
const authorizerLogicalId =
this.provider.naming.getHttpApiAuthorizerLogicalId(authorizer.name)
const authorizerResource = {
Type: 'AWS::ApiGatewayV2::Authorizer',
Properties: {
ApiId: this.getApiIdConfig(),
Name: authorizer.name,
IdentitySource: Array.isArray(authorizer.identitySource)
? authorizer.identitySource
: [authorizer.identitySource],
},
}
if (authorizer.type === 'request') {
// Compile custom (request) authorizer
authorizerResource.Properties.AuthorizerType = 'REQUEST'
authorizerResource.Properties.EnableSimpleResponses =
authorizer.enableSimpleResponses
authorizerResource.Properties.AuthorizerResultTtlInSeconds =
authorizer.resultTtlInSeconds
authorizerResource.Properties.AuthorizerPayloadFormatVersion =
authorizer.payloadVersion
authorizerResource.Properties.AuthorizerUri = {
'Fn::Join': [
'',
[
'arn:',
{ Ref: 'AWS::Partition' },
':apigateway:',
{ Ref: 'AWS::Region' },
':lambda:path/2015-03-31/functions/',
authorizer.functionArn ||
resolveLambdaTarget(
authorizer.functionName,
authorizer.functionObject,
),
'/invocations',
],
],
}
authorizerResource.DependsOn = _.get(
_.get(authorizer.functionObject, 'targetAlias'),
'logicalId',
)
// If authorizer is not managed externally, we need to make sure the correct permission is created that
// allows API Gateway to invoke authorizer function
if (!authorizer.managedExternally) {
this.compileAuthorizerLambdaPermission(authorizer)
}
} else {
// Compile JWT Authorizer
authorizerResource.Properties.AuthorizerType = 'JWT'
authorizerResource.Properties.JwtConfiguration = {
Audience: Array.from(authorizer.audience),
Issuer: authorizer.issuerUrl,
}
}
this.cfTemplate.Resources[authorizerLogicalId] = authorizerResource
}
}
compileAuthorizerLambdaPermission({
functionName,
functionArn,
name,
functionObject,
}) {
const authorizerPermissionLogicalId =
this.provider.naming.getLambdaAuthorizerHttpApiPermissionLogicalId(name)
const permissionResource = {
Type: 'AWS::Lambda::Permission',
Properties: {
FunctionName:
functionArn || resolveLambdaTarget(functionName, functionObject),
Action: 'lambda:InvokeFunction',
Principal: 'apigateway.amazonaws.com',
SourceArn: {
'Fn::Join': [
'',
[
'arn:',
{ Ref: 'AWS::Partition' },
':execute-api:',
{ Ref: 'AWS::Region' },
':',
{ Ref: 'AWS::AccountId' },
':',
this.getApiIdConfig(),
'/*',
],
],
},
},
}
if (functionObject && functionObject.targetAlias) {
permissionResource.DependsOn = functionObject.targetAlias.logicalId
}
this.cfTemplate.Resources[authorizerPermissionLogicalId] =
permissionResource
}
compileEndpoints() {
for (const [
routeKey,
{ targetData, authorizer, authorizationScopes },
] of this.config.routes) {
this.compileLambdaPermissions(targetData)
this.compileIntegration(targetData)
const resource = (this.cfTemplate.Resources[
this.provider.naming.getHttpApiRouteLogicalId(routeKey)
] = {
Type: 'AWS::ApiGatewayV2::Route',
Properties: {
ApiId: this.getApiIdConfig(),
RouteKey: routeKey === '*' ? '$default' : routeKey,
Target: {
'Fn::Join': [
'/',
[
'integrations',
{
Ref: this.provider.naming.getHttpApiIntegrationLogicalId(
targetData.functionName,
),
},
],
],
},
},
DependsOn: this.provider.naming.getHttpApiIntegrationLogicalId(
targetData.functionName,
),
})
if (authorizer) {
const { id, type } = authorizer
const authorizationType = (() => {
if (type === 'request') {
return 'CUSTOM'
}
if (type === 'aws_iam') {
return 'AWS_IAM'
}
return 'JWT'
})()
resource.Properties.AuthorizationType = authorizationType
if (type !== 'aws_iam') {
Object.assign(resource.Properties, {
AuthorizerId: id || {
Ref: this.provider.naming.getHttpApiAuthorizerLogicalId(
authorizer.name,
),
},
AuthorizationScopes:
authorizationScopes && Array.from(authorizationScopes),
})
}
}
}
}
}
Object.defineProperties(
HttpApiEvents.prototype,
memoizeeMethods({
resolveConfiguration: d(function () {
const routes = new Map()
const providerConfig = this.serverless.service.provider
const userConfig = providerConfig.httpApi || {}
this.config = {
routes,
id: userConfig.id,
metrics: userConfig.metrics || false,
disableDefaultEndpoint: userConfig.disableDefaultEndpoint,
}
let cors = null
let shouldFillCorsMethods = false
const userCors = userConfig.cors
if (userCors) {
if (userConfig.id) {
throw new ServerlessError(
'Cannot setup CORS rules for externally configured HTTP API',
'EXTERNAL_HTTP_API_CORS_CONFIG',
)
}
cors = this.config.cors = {}
if (userConfig.cors === true) {
Object.assign(cors, defaultCors)
shouldFillCorsMethods = true
} else {
cors.allowedOrigins = userCors.allowedOrigins
? toSet(userCors.allowedOrigins)
: defaultCors.allowedOrigins
cors.allowedHeaders = userCors.allowedHeaders
? toSet(userCors.allowedHeaders)
: defaultCors.allowedHeaders
if (userCors.allowedMethods)
cors.allowedMethods = toSet(userCors.allowedMethods)
else shouldFillCorsMethods = true
if (userCors.allowCredentials) cors.allowCredentials = true
if (userCors.exposedResponseHeaders) {
cors.exposedResponseHeaders = toSet(userCors.exposedResponseHeaders)
}
cors.maxAge = userCors.maxAge
}
if (shouldFillCorsMethods) cors.allowedMethods = new Set(['OPTIONS'])
}
const userAuthorizers = userConfig.authorizers
const authorizers = (this.config.authorizers = new Map())
if (userAuthorizers) {
if (userConfig.id) {
throw new ServerlessError(
'Cannot setup authorizers for externally configured HTTP API',
'EXTERNAL_HTTP_API_AUTHORIZERS_CONFIG',
)
}
for (const [name, authorizerConfig] of Object.entries(
userAuthorizers,
)) {
let authorizerFunctionObject
if (authorizerConfig.type === 'request') {
if (
!authorizerConfig.functionArn &&
!authorizerConfig.functionName
) {
throw new ServerlessError(
`Either "functionArn" or "functionName" property needs to be set on authorizer "${name}"`,
'HTTP_API_CUSTOM_AUTHORIZER_NEITHER_FUNCTION_ARN_NOR_FUNCTION_NAME_DEFINED',
)
}
if (authorizerConfig.functionArn && authorizerConfig.functionName) {
throw new ServerlessError(
`Either "functionArn" or "functionName" (not both) property needs to be set on authorizer "${name}"`,
'HTTP_API_CUSTOM_AUTHORIZER_BOTH_FUNCTION_ARN_AND_FUNCTION_NAME_DEFINED',
)
}
if (authorizerConfig.functionName) {
try {
authorizerFunctionObject = this.serverless.service.getFunction(
authorizerConfig.functionName,
)
} catch {
throw new ServerlessError(
`Function "${authorizerConfig.functionName}" for HTTP API authorizer "${name}" not found in service.`,
'HTTP_API_CUSTOM_AUTHORIZER_FUNCTION_NOT_FOUND_IN_SERVICE',
)
}
}
if (
authorizerConfig.resultTtlInSeconds &&
!authorizerConfig.identitySource
) {
throw new ServerlessError(
`Property "identitySource" has to be set on authorizer "${name}" when "resultTtlInSeconds" is set to non-zero value.`,
'HTTP_API_CUSTOM_AUTHORIZER_IDENTITY_SOURCE_MISSING_WHEN_CACHING_ENABLED',
)
}
}
authorizers.set(name, {
name: authorizerConfig.name || name,
identitySource: authorizerConfig.identitySource || [],
issuerUrl: authorizerConfig.issuerUrl,
audience: toSet(authorizerConfig.audience),
type: authorizerConfig.type,
functionName: authorizerConfig.functionName,
functionArn: authorizerConfig.functionArn,
managedExternally: authorizerConfig.managedExternally,
resultTtlInSeconds: authorizerConfig.resultTtlInSeconds,
enableSimpleResponses: authorizerConfig.enableSimpleResponses,
payloadVersion: authorizerConfig.payloadVersion || '2.0',
functionObject: authorizerFunctionObject,
})
}
}
const userLogsConfig = providerConfig.logs && providerConfig.logs.httpApi
if (userLogsConfig) {
if (userConfig.id) {
throw new ServerlessError(
'Cannot setup access logs for externally configured HTTP API',
'EXTERNAL_HTTP_API_LOGS_CONFIG',
)
}
this.config.accessLogFormat =
userLogsConfig.format ||
`${JSON.stringify({
requestId: '$context.requestId',
ip: '$context.identity.sourceIp',
requestTime: '$context.requestTime',
httpMethod: '$context.httpMethod',
routeKey: '$context.routeKey',
status: '$context.status',
protocol: '$context.protocol',
responseLength: '$context.responseLength',
})}`
}
for (const [functionName, functionData] of Object.entries(
this.serverless.service.functions,
)) {
const routeTargetData = {
functionName,
functionAlias: functionData.targetAlias,
functionLogicalId:
this.provider.naming.getLambdaLogicalId(functionName),
}
let hasHttpApiEvents = false
for (const event of functionData.events) {
if (!event.httpApi) continue
hasHttpApiEvents = true
let method
let path
let authorizer
if (_.isObject(event.httpApi)) {
;({ method, path, authorizer } = event.httpApi)
} else {
const methodPath = String(event.httpApi)
if (methodPath === '*') {
path = '*'
} else {
;[, method, path] = methodPath.match(methodPathPattern)
}
}
path = String(path)
let routeKey
if (path === '*') {
if (method && method !== '*') {
throw new ServerlessError(
`Invalid "path" property in function ${functionName} for httpApi event in serverless.yml`,
'INVALID_HTTP_API_PATH',
)
}
routeKey = '*'
event.resolvedMethod = 'ANY'
} else {
if (!method) {
throw new ServerlessError(
`Missing "method" property in function ${functionName} for httpApi event in serverless.yml`,
'MISSING_HTTP_API_METHOD',
)
}
method = String(method).toUpperCase()
if (method === '*') {
method = 'ANY'
} else if (!allowedMethods.has(method)) {
throw new ServerlessError(
`Invalid "method" property in function ${functionName} for httpApi event in serverless.yml`,
'INVALID_HTTP_API_METHOD',
)
}
event.resolvedMethod = method
event.resolvedPath = path
routeKey = `${method} ${path}`
if (routes.has(routeKey)) {
throw new ServerlessError(
`Duplicate route '${routeKey}' configuration in function ${functionName} for httpApi event in serverless.yml`,
'DUPLICATE_HTTP_API_ROUTE',
)
}
}
const routeConfig = { targetData: routeTargetData }
if (authorizer) {
const { name, scopes, id, type } = (() => {
if (_.isObject(authorizer)) return authorizer
return { name: authorizer }
})()
if (type !== 'aws_iam' && !id && !name) {
throw new ServerlessError(
`When configuring an authorizer with type: "${
type || 'jwt'
}", property "id" or "name" has to be specified.`,
'HTTP_API_AUTHORIZER_MISSING_ID_OR_NAME',
)
}
if (type === 'aws_iam' && (name || id || scopes)) {
throw new ServerlessError(
'When configuring authorizer with type: "aws_iam", all other properties are not supported.',
'HTTP_API_AUTHORIZER_AWS_IAM_UNEXPECTED_PROPERTIES',
)
}
if (id) {
if (!userConfig.id) {
throw new ServerlessError(
`Event references external authorizer '${id}', but httpApi is part of the current stack.`,
'EXTERNAL_HTTP_API_AUTHORIZER_WITHOUT_EXTERNAL_HTTP_API',
)
}
routeConfig.authorizer = { id, type }
} else if (type === 'aws_iam') {
routeConfig.authorizer = authorizer
} else if (!authorizers.has(name)) {
throw new ServerlessError(
`Event references not configured authorizer '${name}'`,
'UNRECOGNIZED_HTTP_API_AUTHORIZER',
)
} else {
routeConfig.authorizer = authorizers.get(name)
}
if (scopes) routeConfig.authorizationScopes = toSet(scopes)
}
routes.set(routeKey, routeConfig)
if (shouldFillCorsMethods) {
if (event.resolvedMethod === 'ANY') {
for (const allowedMethod of allowedMethods) {
if (allowedMethod === 'ANY') {
continue
}
cors.allowedMethods.add(allowedMethod)
}
} else {
cors.allowedMethods.add(event.resolvedMethod)
}
}
}
if (!hasHttpApiEvents) continue
const functionTimeout =
Number(functionData.timeout) ||
Number(this.serverless.service.provider.timeout) ||
6
if (functionTimeout > 30) {
log.warning(
`Function (${functionName}) timeout setting (${functionTimeout}) is greater than ` +
'maximum allowed timeout for HTTP API endpoint (30s). ' +
'This may introduce a situation where endpoint times out ' +
'for a successful lambda invocation.',
)
} else if (functionTimeout === 30) {
log.warning(
`Function (${functionName}) timeout setting (${functionTimeout}) may not provide ` +
'enough room to process an HTTP API request (of which timeout is limited to 30s). ' +
'This may introduce a situation where endpoint times out ' +
'for a successful lambda invocation.',
)
}
}
}),
compileIntegration: d(function (routeTargetData) {
const functionConfig = this.serverless.service.getFunction(
routeTargetData.functionName,
)
const funcHttpApi = functionConfig.httpApi || {}
const providerConfig = this.serverless.service.provider
const providerHttpApi = providerConfig.httpApi || {}
const properties = {
ApiId: this.getApiIdConfig(),
IntegrationType: 'AWS_PROXY',
IntegrationUri: resolveTargetConfig(routeTargetData),
PayloadFormatVersion:
funcHttpApi.payload || providerHttpApi.payload || '2.0',
TimeoutInMillis: 30000,
}
this.cfTemplate.Resources[
this.provider.naming.getHttpApiIntegrationLogicalId(
routeTargetData.functionName,
)
] = {
Type: 'AWS::ApiGatewayV2::Integration',
DependsOn: _.get(routeTargetData.functionAlias, 'logicalId'),
Properties: properties,
}
}),
compileLambdaPermissions: d(function (routeTargetData) {
this.cfTemplate.Resources[
this.provider.naming.getLambdaHttpApiPermissionLogicalId(
routeTargetData.functionName,
)
] = {
Type: 'AWS::Lambda::Permission',
Properties: {
FunctionName: resolveTargetConfig(routeTargetData),
Action: 'lambda:InvokeFunction',
Principal: 'apigateway.amazonaws.com',
SourceArn: {
'Fn::Join': [
'',
[
'arn:',
{ Ref: 'AWS::Partition' },
':execute-api:',
{ Ref: 'AWS::Region' },
':',
{ Ref: 'AWS::AccountId' },
':',
this.getApiIdConfig(),
'/*',
],
],
},
},
DependsOn: routeTargetData.functionAlias
? routeTargetData.functionAlias.logicalId
: undefined,
}
}),
}),
)
export default HttpApiEvents