mirror of
https://github.com/serverless/serverless.git
synced 2026-02-01 16:07:28 +00:00
481 lines
18 KiB
JavaScript
481 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
const _ = require('lodash');
|
|
const d = require('d');
|
|
const memoizee = require('memoizee');
|
|
const memoizeeMethods = require('memoizee/methods');
|
|
const { logWarning } = require('../../../../../../classes/Error');
|
|
|
|
const allowedMethods = new Set(['GET', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'HEAD', 'DELETE']);
|
|
const methodPathPattern = /^([a-zA-Z]+) (.+)$/;
|
|
|
|
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',
|
|
]),
|
|
};
|
|
|
|
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 = {
|
|
'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();
|
|
},
|
|
};
|
|
}
|
|
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',
|
|
};
|
|
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;
|
|
this.cfTemplate.Resources[this.provider.naming.getHttpApiLogGroupLogicalId()] = {
|
|
Type: 'AWS::Logs::LogGroup',
|
|
Properties: { LogGroupName: this.provider.naming.getHttpApiLogGroupName() },
|
|
};
|
|
}
|
|
compileStage() {
|
|
if (this.config.id) return;
|
|
const properties = {
|
|
ApiId: { Ref: this.provider.naming.getHttpApiLogicalId() },
|
|
StageName: '$default',
|
|
AutoDeploy: true,
|
|
};
|
|
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.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()) {
|
|
this.cfTemplate.Resources[
|
|
this.provider.naming.getHttpApiAuthorizerLogicalId(authorizer.name)
|
|
] = {
|
|
Type: 'AWS::ApiGatewayV2::Authorizer',
|
|
Properties: {
|
|
ApiId: this.getApiIdConfig(),
|
|
AuthorizerType: 'JWT',
|
|
IdentitySource: [authorizer.identitySource],
|
|
JwtConfiguration: {
|
|
Audience: Array.from(authorizer.audience),
|
|
Issuer: authorizer.issuerUrl,
|
|
},
|
|
Name: authorizer.name,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
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) {
|
|
Object.assign(resource.Properties, {
|
|
AuthorizationType: 'JWT',
|
|
AuthorizerId: {
|
|
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 };
|
|
let cors = null;
|
|
let shouldFillCorsMethods = false;
|
|
const userCors = userConfig.cors;
|
|
if (userCors) {
|
|
if (userConfig.id) {
|
|
throw new this.serverless.classes.Error(
|
|
'Cannot setup CORS rules for externally confugured 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) {
|
|
for (const [name, authorizerConfig] of _.entries(userAuthorizers)) {
|
|
authorizers.set(name, {
|
|
name: authorizerConfig.name || name,
|
|
identitySource: authorizerConfig.identitySource,
|
|
issuerUrl: authorizerConfig.issuerUrl,
|
|
audience: toSet(authorizerConfig.audience),
|
|
});
|
|
}
|
|
}
|
|
|
|
const userLogsConfig = providerConfig.logs && providerConfig.logs.httpApi;
|
|
if (userLogsConfig) {
|
|
if (userConfig.id) {
|
|
throw new this.serverless.classes.Error(
|
|
'Cannot setup access logs for externally confugured 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',
|
|
})}}`;
|
|
}
|
|
|
|
if (userConfig.timeout) {
|
|
logWarning(
|
|
'provider.httpApi.timeout is deprecated. ' +
|
|
'HTTP API endpoints are configured to follow timeout setting as set for functions.'
|
|
);
|
|
}
|
|
for (const [functionName, functionData] of _.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;
|
|
let timeout;
|
|
if (_.isObject(event.httpApi)) {
|
|
({ method, path, authorizer, timeout } = event.httpApi);
|
|
} else {
|
|
const methodPath = String(event.httpApi);
|
|
if (methodPath === '*') {
|
|
path = '*';
|
|
} else {
|
|
const tokens = methodPath.match(methodPathPattern);
|
|
if (!tokens) {
|
|
throw new this.serverless.classes.Error(
|
|
`Invalid "<method> <path>" route in function ${functionName} for httpApi event in serverless.yml`,
|
|
'INVALID_HTTP_API_ROUTE'
|
|
);
|
|
}
|
|
[, method, path] = tokens;
|
|
}
|
|
}
|
|
if (!path) {
|
|
throw new this.serverless.classes.Error(
|
|
`Missing "path" property in function ${functionName} for httpApi event in serverless.yml`,
|
|
'MISSING_HTTP_API_PATH'
|
|
);
|
|
}
|
|
path = String(path);
|
|
let routeKey;
|
|
if (path === '*') {
|
|
if (method && method !== '*') {
|
|
throw new this.serverless.classes.Error(
|
|
`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 this.serverless.classes.Error(
|
|
`Missing "method" property in function ${functionName} for httpApi event in serverless.yml`,
|
|
'MISSING_HTTP_API_METHOD'
|
|
);
|
|
}
|
|
method = String(method).toUpperCase();
|
|
if (method === '*') {
|
|
method = 'ANY';
|
|
if (
|
|
Array.from(
|
|
allowedMethods,
|
|
allowedMethod => `${allowedMethod} ${path}`
|
|
).some(duplicateRouteKey => routes.has(duplicateRouteKey))
|
|
) {
|
|
throw new this.serverless.classes.Error(
|
|
`Duplicate method for "${path}" path in function ${functionName} for httpApi event in serverless.yml`,
|
|
'DUPLICATE_HTTP_API_METHOD'
|
|
);
|
|
}
|
|
} else {
|
|
if (!allowedMethods.has(method)) {
|
|
throw new this.serverless.classes.Error(
|
|
`Invalid "method" property in function ${functionName} for httpApi event in serverless.yml`,
|
|
'INVALID_HTTP_API_METHOD'
|
|
);
|
|
}
|
|
if (routes.has(`ANY ${path}`)) {
|
|
throw new this.serverless.classes.Error(
|
|
`Duplicate method for "${path}" path in function ${functionName} for httpApi event in serverless.yml`,
|
|
'DUPLICATE_HTTP_API_METHOD'
|
|
);
|
|
}
|
|
}
|
|
event.resolvedMethod = method;
|
|
event.resolvedPath = path;
|
|
routeKey = `${method} ${path}`;
|
|
|
|
if (routes.has(routeKey)) {
|
|
throw new this.serverless.classes.Error(
|
|
`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 } = (() => {
|
|
if (_.isObject(authorizer)) return authorizer;
|
|
return { name: authorizer };
|
|
})();
|
|
if (!authorizers.has(name)) {
|
|
throw new this.serverless.classes.Error(
|
|
`Event references not configured authorizer '${name}'`,
|
|
'UNRECOGNIZED_HTTP_API_AUTHORIZER'
|
|
);
|
|
}
|
|
routeConfig.authorizer = authorizers.get(name);
|
|
if (scopes) routeConfig.authorizationScopes = toSet(scopes);
|
|
}
|
|
if (!timeout) timeout = userConfig.timeout || null;
|
|
if (typeof routeTargetData.timeout !== 'undefined') {
|
|
if (routeTargetData.timeout !== timeout) {
|
|
throw new this.serverless.classes.Error(
|
|
`Inconsistent timeout settings for ${functionName} events`,
|
|
'INCONSISTENT_HTTP_API_TIMEOUT'
|
|
);
|
|
}
|
|
} else {
|
|
routeTargetData.timeout = timeout;
|
|
}
|
|
routes.set(routeKey, routeConfig);
|
|
if (shouldFillCorsMethods) {
|
|
if (event.resolvedMethod === 'ANY') {
|
|
for (const allowedMethod of allowedMethods) {
|
|
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 (routeTargetData.timeout) {
|
|
logWarning(
|
|
`httpApi.timeout is deprecated (found one defined for '${functionName}'s endpoint). ` +
|
|
'HTTP API endpoints are configured to follow timeout setting as set for functions.'
|
|
);
|
|
if (functionTimeout >= routeTargetData.timeout) {
|
|
logWarning(
|
|
`HTTP API endpoint timeout setting (${routeTargetData.timeout}s) is ` +
|
|
`lower or equal to function (${functionName}) timeout (${functionTimeout}s). ` +
|
|
'This may introduce a situation where endpoint times out ' +
|
|
'for a succesful lambda invocation.'
|
|
);
|
|
}
|
|
} else {
|
|
if (functionTimeout > 29) {
|
|
logWarning(
|
|
`Function (${functionName}) timeout setting (${functionTimeout}) is greater than ` +
|
|
'maximum allowed timeout for HTTP API endpoint (29s). ' +
|
|
'This may introduce a situation where endpoint times out ' +
|
|
'for a succesful lambda invocation.'
|
|
);
|
|
} else if (functionTimeout === 29) {
|
|
logWarning(
|
|
`Function (${functionName}) timeout setting (${functionTimeout}) may not provide ` +
|
|
'enough room to process an HTTP API request (of which timeout is limited to 29s). ' +
|
|
'This may introduce a situation where endpoint times out ' +
|
|
'for a succesful lambda invocation.'
|
|
);
|
|
}
|
|
// Ensure endpoint has slightly larger timeout than a function,
|
|
// It's a margin needed for some side processing time on AWS side.
|
|
// Otherwise there's a risk of observing 503 status for successfully resolved invocation
|
|
// (which just fit function timeout setting)
|
|
routeTargetData.timeout = Math.min(functionTimeout + 0.5, 29);
|
|
}
|
|
}
|
|
}),
|
|
compileIntegration: d(function(routeTargetData) {
|
|
const properties = {
|
|
ApiId: this.getApiIdConfig(),
|
|
IntegrationType: 'AWS_PROXY',
|
|
IntegrationUri: resolveTargetConfig(routeTargetData),
|
|
PayloadFormatVersion: '1.0',
|
|
};
|
|
if (routeTargetData.timeout) {
|
|
properties.TimeoutInMillis = Math.round(routeTargetData.timeout * 1000);
|
|
}
|
|
this.cfTemplate.Resources[
|
|
this.provider.naming.getHttpApiIntegrationLogicalId(routeTargetData.functionName)
|
|
] = {
|
|
Type: 'AWS::ApiGatewayV2::Integration',
|
|
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;
|