UNPKG

osls

Version:

Open-source alternative to Serverless Framework

682 lines (639 loc) 25.2 kB
'use strict'; const _ = require('lodash'); const d = require('d'); const memoizee = require('memoizee'); const memoizeeMethods = require('memoizee/methods'); const { log } = require('@serverless/utils/log'); const ServerlessError = require('../../../../../serverless-error'); const resolveLambdaTarget = require('../../../utils/resolve-lambda-target'); 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(), Description: this.provider.naming.getHttpApiDescription(), 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, }; }), }) ); module.exports = HttpApiEvents;