Merge pull request #2666 from serverless/refactor-api-gateway-resources-and-methods

Refactor api gateway resources and methods
This commit is contained in:
Eslam λ Hefnawy 2016-11-08 21:27:01 +07:00 committed by GitHub
commit 2854b9b91e
18 changed files with 2263 additions and 1698 deletions

View File

@ -6,10 +6,14 @@ const validate = require('./lib/validate');
const compileRestApi = require('./lib/restApi');
const compileApiKeys = require('./lib/apiKeys');
const compileResources = require('./lib/resources');
const compileMethods = require('./lib/methods');
const compileCors = require('./lib/cors');
const compileMethods = require('./lib/method/index');
const compileAuthorizers = require('./lib/authorizers');
const compileDeployment = require('./lib/deployment');
const compilePermissions = require('./lib/permissions');
const getMethodAuthorization = require('./lib/method/authorization');
const getMethodIntegration = require('./lib/method/integration');
const getMethodResponses = require('./lib/method/responses');
class AwsCompileApigEvents {
constructor(serverless, options) {
@ -23,10 +27,14 @@ class AwsCompileApigEvents {
compileRestApi,
compileApiKeys,
compileResources,
compileCors,
compileMethods,
compileAuthorizers,
compileDeployment,
compilePermissions
compilePermissions,
getMethodAuthorization,
getMethodIntegration,
getMethodResponses
);
this.hooks = {
@ -40,6 +48,7 @@ class AwsCompileApigEvents {
return BbPromise.bind(this)
.then(this.compileRestApi)
.then(this.compileResources)
.then(this.compileCors)
.then(this.compileMethods)
.then(this.compileAuthorizers)
.then(this.compileDeployment)

View File

@ -31,8 +31,7 @@ module.exports = {
});
}
const normalizedAuthorizerName = authorizer.name[0].toUpperCase()
+ authorizer.name.substr(1);
const normalizedAuthorizerName = _.capitalize(authorizer.name);
_.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, {
[`${normalizedAuthorizerName}ApiGatewayAuthorizer`]: {

View File

@ -0,0 +1,75 @@
'use strict';
const _ = require('lodash');
const BbPromise = require('bluebird');
module.exports = {
compileCors() {
_.forEach(this.validated.corsPreflight, (config, path) => {
const resourceName = this.getResourceName(path);
const resourceRef = this.getResourceId(path);
const preflightHeaders = {
'Access-Control-Allow-Origin': `'${config.origins.join(',')}'`,
'Access-Control-Allow-Headers': `'${config.headers.join(',')}'`,
'Access-Control-Allow-Methods': `'${config.methods.join(',')}'`,
};
_.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, {
[`ApiGatewayMethod${resourceName}Options`]: {
Type: 'AWS::ApiGateway::Method',
Properties: {
AuthorizationType: 'NONE',
HttpMethod: 'OPTIONS',
MethodResponses: this.generateCorsMethodResponses(preflightHeaders),
RequestParameters: {},
Integration: {
Type: 'MOCK',
RequestTemplates: {
'application/json': '{statusCode:200}',
},
IntegrationResponses: this.generateCorsIntegrationResponses(preflightHeaders),
},
ResourceId: resourceRef,
RestApiId: { Ref: this.apiGatewayRestApiLogicalId },
},
},
});
});
return BbPromise.resolve();
},
generateCorsMethodResponses(preflightHeaders) {
const methodResponseHeaders = {};
_.forEach(preflightHeaders, (value, header) => {
methodResponseHeaders[`method.response.header.${header}`] = true;
});
return [
{
StatusCode: '200',
ResponseParameters: methodResponseHeaders,
ResponseModels: {},
},
];
},
generateCorsIntegrationResponses(preflightHeaders) {
const responseParameters = _.mapKeys(preflightHeaders,
(value, header) => `method.response.header.${header}`);
return [
{
StatusCode: '200',
ResponseParameters: responseParameters,
ResponseTemplates: {
'application/json': '',
},
},
];
},
};

View File

@ -14,7 +14,7 @@ module.exports = {
RestApiId: { Ref: this.apiGatewayRestApiLogicalId },
StageName: this.options.stage,
},
DependsOn: this.methodDependencies,
DependsOn: this.apiGatewayMethodLogicalIds,
},
});

View File

@ -0,0 +1,25 @@
'use strict';
const _ = require('lodash');
module.exports = {
getMethodAuthorization(http) {
if (http.authorizer) {
const normalizedAuthorizerName = _.capitalize(http.authorizer.name);
const authorizerLogicalId = `${normalizedAuthorizerName}ApiGatewayAuthorizer`;
return {
Properties: {
AuthorizationType: 'CUSTOM',
AuthorizerId: { Ref: authorizerLogicalId },
},
DependsOn: authorizerLogicalId,
};
}
return {
Properties: {
AuthorizationType: 'NONE',
},
};
},
};

View File

@ -0,0 +1,48 @@
'use strict';
const BbPromise = require('bluebird');
const _ = require('lodash');
module.exports = {
compileMethods() {
this.apiGatewayMethodLogicalIds = [];
this.validated.events.forEach((event) => {
const resourceId = this.getResourceId(event.http.path);
const resourceName = this.getResourceName(event.http.path);
const requestParameters = (event.http.request && event.http.request.parameters) || {};
const template = {
Type: 'AWS::ApiGateway::Method',
Properties: {
HttpMethod: event.http.method.toUpperCase(),
RequestParameters: requestParameters,
ResourceId: resourceId,
RestApiId: { Ref: this.apiGatewayRestApiLogicalId },
},
};
if (event.http.private) {
template.Properties.ApiKeyRequired = true;
}
_.merge(template,
this.getMethodAuthorization(event.http),
this.getMethodIntegration(event.http, event.functionName),
this.getMethodResponses(event.http)
);
const methodName = _.capitalize(event.http.method);
const methodLogicalId = `ApiGatewayMethod${resourceName}${methodName}`;
this.apiGatewayMethodLogicalIds.push(methodLogicalId);
_.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, {
[methodLogicalId]: template,
});
});
return BbPromise.resolve();
},
};

View File

@ -0,0 +1,193 @@
'use strict';
const _ = require('lodash');
module.exports = {
getMethodIntegration(http, functionName) {
const normalizedFunctionName = _.capitalize(functionName);
const integration = {
IntegrationHttpMethod: 'POST',
Type: http.integration,
Uri: {
'Fn::Join': ['',
[
'arn:aws:apigateway:',
{ Ref: 'AWS::Region' },
':lambda:path/2015-03-31/functions/',
{ 'Fn::GetAtt': [`${normalizedFunctionName}LambdaFunction`, 'Arn'] },
'/invocations',
],
],
},
};
if (http.integration === 'AWS') {
_.assign(integration, {
PassthroughBehavior: http.request && http.request.passThrough,
RequestTemplates: this.getIntegrationRequestTemplates(http),
IntegrationResponses: this.getIntegrationResponses(http),
});
}
return {
Properties: {
Integration: integration,
},
};
},
getIntegrationResponses(http) {
const integrationResponses = [];
if (http.response) {
const integrationResponseHeaders = [];
if (http.cors) {
_.merge(integrationResponseHeaders, {
'Access-Control-Allow-Origin': `'${http.cors.origins.join(',')}'`,
});
}
if (http.response.headers) {
_.merge(integrationResponseHeaders, http.response.headers);
}
_.each(http.response.statusCodes, (config, statusCode) => {
const responseParameters = _.mapKeys(integrationResponseHeaders,
(value, header) => `method.response.header.${header}`);
const integrationResponse = {
StatusCode: parseInt(statusCode, 10),
SelectionPattern: config.pattern || '',
ResponseParameters: responseParameters,
ResponseTemplates: {},
};
if (config.headers) {
_.merge(integrationResponse.ResponseParameters, _.mapKeys(config.headers,
(value, header) => `method.response.header.${header}`));
}
if (http.response.template) {
_.merge(integrationResponse.ResponseTemplates, {
'application/json': http.response.template,
});
}
if (config.template) {
const template = typeof config.template === 'string' ?
{ 'application/json': config.template }
: config.template;
_.merge(integrationResponse.ResponseTemplates, template);
}
integrationResponses.push(integrationResponse);
});
}
return integrationResponses;
},
getIntegrationRequestTemplates(http) {
// default request templates
const integrationRequestTemplates = {
'application/json': this.DEFAULT_JSON_REQUEST_TEMPLATE,
'application/x-www-form-urlencoded': this.DEFAULT_FORM_URL_ENCODED_REQUEST_TEMPLATE,
};
// set custom request templates if provided
if (http.request && typeof http.request.template === 'object') {
_.assign(integrationRequestTemplates, http.request.template);
}
return integrationRequestTemplates;
},
DEFAULT_JSON_REQUEST_TEMPLATE: `
#define( $loop )
{
#foreach($key in $map.keySet())
"$util.escapeJavaScript($key)":
"$util.escapeJavaScript($map.get($key))"
#if( $foreach.hasNext ) , #end
#end
}
#end
{
"body": $input.json("$"),
"method": "$context.httpMethod",
"principalId": "$context.authorizer.principalId",
"stage": "$context.stage",
#set( $map = $input.params().header )
"headers": $loop,
#set( $map = $input.params().querystring )
"query": $loop,
#set( $map = $input.params().path )
"path": $loop,
#set( $map = $context.identity )
"identity": $loop,
#set( $map = $stageVariables )
"stageVariables": $loop
}
`,
DEFAULT_FORM_URL_ENCODED_REQUEST_TEMPLATE: `
#define( $body )
{
#foreach( $token in $input.path('$').split('&') )
#set( $keyVal = $token.split('=') )
#set( $keyValSize = $keyVal.size() )
#if( $keyValSize >= 1 )
#set( $key = $util.escapeJavaScript($util.urlDecode($keyVal[0])) )
#if( $keyValSize >= 2 )
#set( $val = $util.escapeJavaScript($util.urlDecode($keyVal[1])) )
#else
#set( $val = '' )
#end
"$key": "$val"#if($foreach.hasNext),#end
#end
#end
}
#end
#define( $loop )
{
#foreach($key in $map.keySet())
"$util.escapeJavaScript($key)":
"$util.escapeJavaScript($map.get($key))"
#if( $foreach.hasNext ) , #end
#end
}
#end
{
"body": $body,
"method": "$context.httpMethod",
"principalId": "$context.authorizer.principalId",
"stage": "$context.stage",
#set( $map = $input.params().header )
"headers": $loop,
#set( $map = $input.params().querystring )
"query": $loop,
#set( $map = $input.params().path )
"path": $loop,
#set( $map = $context.identity )
"identity": $loop,
#set( $map = $stageVariables )
"stageVariables": $loop
}
`,
};

View File

@ -0,0 +1,61 @@
'use strict';
const _ = require('lodash');
module.exports = {
getMethodResponses(http) {
const methodResponses = [];
if (http.integration === 'AWS') {
if (http.response) {
const methodResponseHeaders = [];
if (http.cors) {
_.merge(methodResponseHeaders, {
'Access-Control-Allow-Origin': `'${http.cors.origins.join(',')}'`,
});
}
if (http.response.headers) {
_.merge(methodResponseHeaders, http.response.headers);
}
_.each(http.response.statusCodes, (config, statusCode) => {
const methodResponse = {
ResponseParameters: {},
ResponseModels: {},
StatusCode: parseInt(statusCode, 10),
};
_.merge(methodResponse.ResponseParameters,
this.getMethodResponseHeaders(methodResponseHeaders));
if (config.headers) {
_.merge(methodResponse.ResponseParameters,
this.getMethodResponseHeaders(config.headers));
}
methodResponses.push(methodResponse);
});
}
}
return {
Properties: {
MethodResponses: methodResponses,
},
};
},
getMethodResponseHeaders(headers) {
const methodResponseHeaders = {};
Object.keys(headers).forEach(header => {
methodResponseHeaders[`method.response.header.${header}`] = true;
});
return methodResponseHeaders;
},
};

View File

@ -1,660 +0,0 @@
'use strict';
const BbPromise = require('bluebird');
const _ = require('lodash');
const NOT_FOUND = -1;
module.exports = {
compileMethods() {
const corsPreflight = {};
const defaultStatusCodes = {
200: {
pattern: '',
},
400: {
pattern: '.*\\[400\\].*',
},
401: {
pattern: '.*\\[401\\].*',
},
403: {
pattern: '.*\\[403\\].*',
},
404: {
pattern: '.*\\[404\\].*',
},
422: {
pattern: '.*\\[422\\].*',
},
500: {
pattern: '.*(Process\\s?exited\\s?before\\s?completing\\s?request|\\[500\\]).*',
},
502: {
pattern: '.*\\[502\\].*',
},
504: {
pattern: '.*\\[504\\].*',
},
};
/**
* Private helper functions
*/
const generateMethodResponseHeaders = (headers) => {
const methodResponseHeaders = {};
Object.keys(headers).forEach(header => {
methodResponseHeaders[`method.response.header.${header}`] = true;
});
return methodResponseHeaders;
};
const generateIntegrationResponseHeaders = (headers) => {
const integrationResponseHeaders = {};
Object.keys(headers).forEach(header => {
integrationResponseHeaders[`method.response.header.${header}`] = headers[header];
});
return integrationResponseHeaders;
};
const generateCorsPreflightConfig = (corsConfig, corsPreflightConfig, method) => {
const headers = [
'Content-Type',
'X-Amz-Date',
'Authorization',
'X-Api-Key',
'X-Amz-Security-Token',
];
let newCorsPreflightConfig;
const cors = {
origins: ['*'],
methods: ['OPTIONS'],
headers,
};
if (typeof corsConfig === 'object') {
Object.assign(cors, corsConfig);
cors.methods = [];
if (cors.headers) {
if (!Array.isArray(cors.headers)) {
const errorMessage = [
'CORS header values must be provided as an array.',
' Please check the docs for more info.',
].join('');
throw new this.serverless.classes
.Error(errorMessage);
}
} else {
cors.headers = headers;
}
if (cors.methods.indexOf('OPTIONS') === NOT_FOUND) {
cors.methods.push('OPTIONS');
}
if (cors.methods.indexOf(method.toUpperCase()) === NOT_FOUND) {
cors.methods.push(method.toUpperCase());
}
} else {
cors.methods.push(method.toUpperCase());
}
if (corsPreflightConfig) {
cors.methods = _.union(cors.methods, corsPreflightConfig.methods);
cors.headers = _.union(cors.headers, corsPreflightConfig.headers);
cors.origins = _.union(cors.origins, corsPreflightConfig.origins);
newCorsPreflightConfig = _.merge(corsPreflightConfig, cors);
} else {
newCorsPreflightConfig = cors;
}
return newCorsPreflightConfig;
};
const hasDefaultStatusCode = (statusCodes) =>
Object.keys(statusCodes).some((statusCode) => (statusCodes[statusCode].pattern === ''));
const generateResponse = (responseConfig) => {
const response = {
methodResponses: [],
integrationResponses: [],
};
const statusCodes = {};
Object.assign(statusCodes, responseConfig.statusCodes);
if (!hasDefaultStatusCode(statusCodes)) {
_.merge(statusCodes, { 200: defaultStatusCodes['200'] });
}
Object.keys(statusCodes).forEach((statusCode) => {
const methodResponse = {
ResponseParameters: {},
ResponseModels: {},
StatusCode: parseInt(statusCode, 10),
};
const integrationResponse = {
StatusCode: parseInt(statusCode, 10),
SelectionPattern: statusCodes[statusCode].pattern || '',
ResponseParameters: {},
ResponseTemplates: {},
};
_.merge(methodResponse.ResponseParameters,
generateMethodResponseHeaders(responseConfig.methodResponseHeaders));
if (statusCodes[statusCode].headers) {
_.merge(methodResponse.ResponseParameters,
generateMethodResponseHeaders(statusCodes[statusCode].headers));
}
_.merge(integrationResponse.ResponseParameters,
generateIntegrationResponseHeaders(responseConfig.integrationResponseHeaders));
if (statusCodes[statusCode].headers) {
_.merge(integrationResponse.ResponseParameters,
generateIntegrationResponseHeaders(statusCodes[statusCode].headers));
}
if (responseConfig.integrationResponseTemplate) {
_.merge(integrationResponse.ResponseTemplates, {
'application/json': responseConfig.integrationResponseTemplate,
});
}
if (statusCodes[statusCode].template) {
if (typeof statusCodes[statusCode].template === 'string') {
_.merge(integrationResponse.ResponseTemplates, {
'application/json': statusCodes[statusCode].template,
});
} else {
_.merge(integrationResponse.ResponseTemplates, statusCodes[statusCode].template);
}
}
response.methodResponses.push(methodResponse);
response.integrationResponses.push(integrationResponse);
});
return response;
};
const hasRequestTemplate = (event) => {
// check if custom request configuration should be used
if (Boolean(event.http.request) === true) {
if (typeof event.http.request === 'object') {
// merge custom request templates if provided
if (Boolean(event.http.request.template) === true) {
if (typeof event.http.request.template === 'object') {
return true;
}
const errorMessage = [
'Template config must be provided as an object.',
' Please check the docs for more info.',
].join('');
throw new this.serverless.classes.Error(errorMessage);
}
} else {
const errorMessage = [
'Request config must be provided as an object.',
' Please check the docs for more info.',
].join('');
throw new this.serverless.classes.Error(errorMessage);
}
}
return false;
};
const hasRequestParameters = (event) => (event.http.request && event.http.request.parameters);
const hasPassThroughRequest = (event) => {
const requestPassThroughBehaviors = [
'NEVER', 'WHEN_NO_MATCH', 'WHEN_NO_TEMPLATES',
];
if (event.http.request && Boolean(event.http.request.passThrough) === true) {
if (requestPassThroughBehaviors.indexOf(event.http.request.passThrough) === -1) {
const errorMessage = [
'Request passThrough "',
event.http.request.passThrough,
'" is not one of ',
requestPassThroughBehaviors.join(', '),
].join('');
throw new this.serverless.classes.Error(errorMessage);
}
return true;
}
return false;
};
const hasCors = (event) => (Boolean(event.http.cors) === true);
const hasResponseTemplate = (event) => (event.http.response && event.http.response.template);
const hasResponseHeaders = (event) => {
// check if custom response configuration should be used
if (Boolean(event.http.response) === true) {
if (typeof event.http.response === 'object') {
// prepare the headers if set
if (Boolean(event.http.response.headers) === true) {
if (typeof event.http.response.headers === 'object') {
return true;
}
const errorMessage = [
'Response headers must be provided as an object.',
' Please check the docs for more info.',
].join('');
throw new this.serverless.classes.Error(errorMessage);
}
} else {
const errorMessage = [
'Response config must be provided as an object.',
' Please check the docs for more info.',
].join('');
throw new this.serverless.classes.Error(errorMessage);
}
}
return false;
};
const configurePreflightMethods = (corsConfig, logicalIds) => {
const preflightMethods = {};
_.forOwn(corsConfig, (config, path) => {
const resourceLogicalId = logicalIds[path];
const preflightHeaders = {
'Access-Control-Allow-Origin': `'${config.origins.join(',')}'`,
'Access-Control-Allow-Headers': `'${config.headers.join(',')}'`,
'Access-Control-Allow-Methods': `'${config.methods.join(',')}'`,
};
const preflightMethodResponse = generateMethodResponseHeaders(preflightHeaders);
const preflightIntegrationResponse = generateIntegrationResponseHeaders(preflightHeaders);
const preflightTemplate = `
{
"Type" : "AWS::ApiGateway::Method",
"Properties" : {
"AuthorizationType" : "NONE",
"HttpMethod" : "OPTIONS",
"MethodResponses" : [
{
"ResponseModels" : {},
"ResponseParameters" : ${JSON.stringify(preflightMethodResponse)},
"StatusCode" : "200"
}
],
"RequestParameters" : {},
"Integration" : {
"Type" : "MOCK",
"RequestTemplates" : {
"application/json": "{statusCode:200}"
},
"IntegrationResponses" : [
{
"StatusCode" : "200",
"ResponseParameters" : ${JSON.stringify(preflightIntegrationResponse)},
"ResponseTemplates" : {
"application/json": ""
}
}
]
},
"ResourceId" : { "Ref": "${resourceLogicalId}" },
"RestApiId" : { "Ref": "ApiGatewayRestApi" }
}
}
`;
const extractedResourceId = resourceLogicalId.match(/ApiGatewayResource(.*)/)[1];
_.merge(preflightMethods, {
[`ApiGatewayMethod${extractedResourceId}Options`]:
JSON.parse(preflightTemplate),
});
});
return preflightMethods;
};
/**
* Lets start the real work now!
*/
_.forEach(this.serverless.service.functions, (functionObject, functionName) => {
functionObject.events.forEach(event => {
if (event.http) {
let method;
let path;
let requestPassThroughBehavior = 'NEVER';
let integrationType = 'AWS_PROXY';
let integrationResponseTemplate = null;
// Validate HTTP event object
if (typeof event.http === 'object') {
method = event.http.method;
path = event.http.path;
} else if (typeof event.http === 'string') {
method = event.http.split(' ')[0];
path = event.http.split(' ')[1];
} else {
const errorMessage = [
`HTTP event of function ${functionName} is not an object nor a string.`,
' The correct syntax is: http: get users/list',
' OR an object with "path" and "method" properties.',
' Please check the docs for more info.',
].join('');
throw new this.serverless.classes
.Error(errorMessage);
}
// Templates required to generate the cloudformation config
const DEFAULT_JSON_REQUEST_TEMPLATE = `
#define( $loop )
{
#foreach($key in $map.keySet())
"$util.escapeJavaScript($key)":
"$util.escapeJavaScript($map.get($key))"
#if( $foreach.hasNext ) , #end
#end
}
#end
{
"body": $input.json("$"),
"method": "$context.httpMethod",
"principalId": "$context.authorizer.principalId",
"stage": "$context.stage",
#set( $map = $input.params().header )
"headers": $loop,
#set( $map = $input.params().querystring )
"query": $loop,
#set( $map = $input.params().path )
"path": $loop,
#set( $map = $context.identity )
"identity": $loop,
#set( $map = $stageVariables )
"stageVariables": $loop
}
`;
const DEFAULT_FORM_URL_ENCODED_REQUEST_TEMPLATE = `
#define( $body )
{
#foreach( $token in $input.path('$').split('&') )
#set( $keyVal = $token.split('=') )
#set( $keyValSize = $keyVal.size() )
#if( $keyValSize >= 1 )
#set( $key = $util.escapeJavaScript($util.urlDecode($keyVal[0])) )
#if( $keyValSize >= 2 )
#set( $val = $util.escapeJavaScript($util.urlDecode($keyVal[1])) )
#else
#set( $val = '' )
#end
"$key": "$val"#if($foreach.hasNext),#end
#end
#end
}
#end
#define( $loop )
{
#foreach($key in $map.keySet())
"$util.escapeJavaScript($key)":
"$util.escapeJavaScript($map.get($key))"
#if( $foreach.hasNext ) , #end
#end
}
#end
{
"body": $body,
"method": "$context.httpMethod",
"principalId": "$context.authorizer.principalId",
"stage": "$context.stage",
#set( $map = $input.params().header )
"headers": $loop,
#set( $map = $input.params().querystring )
"query": $loop,
#set( $map = $input.params().path )
"path": $loop,
#set( $map = $context.identity )
"identity": $loop,
#set( $map = $stageVariables )
"stageVariables": $loop
}
`;
// default integration request templates
const integrationRequestTemplates = {
'application/json': DEFAULT_JSON_REQUEST_TEMPLATE,
'application/x-www-form-urlencoded': DEFAULT_FORM_URL_ENCODED_REQUEST_TEMPLATE,
};
// configuring logical names for resources
const resourceLogicalId = this.resourceLogicalIds[path];
const normalizedMethod = method[0].toUpperCase() +
method.substr(1).toLowerCase();
const extractedResourceId = resourceLogicalId.match(/ApiGatewayResource(.*)/)[1];
const normalizedFunctionName = functionName[0].toUpperCase()
+ functionName.substr(1);
// scaffolds for method responses headers
const methodResponseHeaders = [];
const integrationResponseHeaders = [];
const requestParameters = {};
// 1. Has request template
if (hasRequestTemplate(event)) {
_.forEach(event.http.request.template, (value, key) => {
const requestTemplate = {};
requestTemplate[key] = value;
_.merge(integrationRequestTemplates, requestTemplate);
});
}
if (hasRequestParameters(event)) {
// only these locations are currently supported
const locations = ['querystrings', 'paths', 'headers'];
_.each(locations, (location) => {
// strip the plural s
const singular = location.substring(0, location.length - 1);
_.each(event.http.request.parameters[location], (value, key) => {
requestParameters[`method.request.${singular}.${key}`] = value;
});
});
}
// 2. Has pass-through options
if (hasPassThroughRequest(event)) {
requestPassThroughBehavior = event.http.request.passThrough;
}
// 3. Has response template
if (hasResponseTemplate(event)) {
integrationResponseTemplate = event.http.response.template;
}
// 4. Has CORS enabled?
if (hasCors(event)) {
corsPreflight[path] = generateCorsPreflightConfig(event.http.cors,
corsPreflight[path], method);
const corsHeader = {
'Access-Control-Allow-Origin':
`'${corsPreflight[path].origins.join('\',\'')}'`,
};
_.merge(methodResponseHeaders, corsHeader);
_.merge(integrationResponseHeaders, corsHeader);
}
// Sort out response headers
if (hasResponseHeaders(event)) {
_.merge(methodResponseHeaders, event.http.response.headers);
_.merge(integrationResponseHeaders, event.http.response.headers);
}
// Sort out response config
const responseConfig = {
methodResponseHeaders,
integrationResponseHeaders,
integrationResponseTemplate,
};
// Merge in any custom response config
if (event.http.response && event.http.response.statusCodes) {
responseConfig.statusCodes = event.http.response.statusCodes;
} else {
responseConfig.statusCodes = defaultStatusCodes;
}
const response = generateResponse(responseConfig);
// check if LAMBDA or LAMBDA-PROXY was used for the integration type
if (typeof event.http === 'object') {
if (Boolean(event.http.integration) === true) {
// normalize the integration for further processing
const normalizedIntegration = event.http.integration.toUpperCase();
// check if the user has entered a non-valid integration
const allowedIntegrations = [
'LAMBDA', 'LAMBDA-PROXY',
];
if (allowedIntegrations.indexOf(normalizedIntegration) === -1) {
const errorMessage = [
`Invalid APIG integration "${event.http.integration}"`,
` in function "${functionName}".`,
' Supported integrations are: lambda, lambda-proxy.',
].join('');
throw new this.serverless.classes.Error(errorMessage);
}
// map the Serverless integration to the corresponding CloudFormation types
// LAMBDA --> AWS
// LAMBDA-PROXY --> AWS_PROXY
if (normalizedIntegration === 'LAMBDA') {
integrationType = 'AWS';
} else if (normalizedIntegration === 'LAMBDA-PROXY') {
integrationType = 'AWS_PROXY';
} else {
// default to AWS_PROXY (just in case…)
integrationType = 'AWS_PROXY';
}
}
}
// show a warning when request / response config is used with AWS_PROXY (LAMBDA-PROXY)
if (integrationType === 'AWS_PROXY' && (
(!!event.http.request) || (!!event.http.response)
)) {
const warningMessage = [
'Warning! You\'re using the LAMBDA-PROXY in combination with request / response',
` configuration in your function "${functionName}".`,
' This configuration will be ignored during deployment.',
].join('');
this.serverless.cli.log(warningMessage);
}
const methodTemplate = `
{
"Type" : "AWS::ApiGateway::Method",
"Properties" : {
"AuthorizationType" : "NONE",
"HttpMethod" : "${method.toUpperCase()}",
"MethodResponses" : ${JSON.stringify(response.methodResponses)},
"RequestParameters" : ${JSON.stringify(requestParameters)},
"Integration" : {
"IntegrationHttpMethod" : "POST",
"Type" : "${integrationType}",
"Uri" : {
"Fn::Join": [ "",
[
"arn:aws:apigateway:",
{"Ref" : "AWS::Region"},
":lambda:path/2015-03-31/functions/",
{"Fn::GetAtt" : ["${normalizedFunctionName}LambdaFunction", "Arn"]},
"/invocations"
]
]
},
"RequestTemplates" : ${JSON.stringify(integrationRequestTemplates)},
"PassthroughBehavior": "${requestPassThroughBehavior}",
"IntegrationResponses" : ${JSON.stringify(response.integrationResponses)}
},
"ResourceId" : { "Ref": "${resourceLogicalId}" },
"RestApiId" : { "Ref": "ApiGatewayRestApi" }
}
}
`;
const methodTemplateJson = JSON.parse(methodTemplate);
// set authorizer config if available
if (event.http.authorizer) {
const authorizerName = event.http.authorizer.name;
const normalizedAuthorizerName = authorizerName[0].toUpperCase()
+ authorizerName.substr(1);
const AuthorizerLogicalId = `${normalizedAuthorizerName}ApiGatewayAuthorizer`;
methodTemplateJson.Properties.AuthorizationType = 'CUSTOM';
methodTemplateJson.Properties.AuthorizerId = {
Ref: AuthorizerLogicalId,
};
methodTemplateJson.DependsOn = AuthorizerLogicalId;
}
if (event.http.private) methodTemplateJson.Properties.ApiKeyRequired = true;
const methodObject = {
[`ApiGatewayMethod${extractedResourceId}${normalizedMethod}`]:
methodTemplateJson,
};
_.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources,
methodObject);
// store a method logical id in memory to be used
// by Deployment resources "DependsOn" property
if (this.methodDependencies) {
this.methodDependencies
.push(`ApiGatewayMethod${extractedResourceId}${normalizedMethod}`);
} else {
this.methodDependencies =
[`ApiGatewayMethod${extractedResourceId}${normalizedMethod}`];
}
}
});
});
if (!_.isEmpty(corsPreflight)) {
// If we have some CORS config. configure the preflight method and merge
_.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources,
configurePreflightMethods(corsPreflight, this.resourceLogicalIds));
}
return BbPromise.resolve();
},
};

View File

@ -7,8 +7,7 @@ module.exports = {
compilePermissions() {
this.validated.events.forEach((event) => {
const normalizedFunctionName = event.functionName[0].toUpperCase()
+ event.functionName.substr(1);
const normalizedFunctionName = _.capitalize(event.functionName);
_.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, {
[`${normalizedFunctionName}LambdaPermissionApiGateway`]: {
@ -25,8 +24,7 @@ module.exports = {
if (event.http.authorizer) {
const authorizer = event.http.authorizer;
const normalizedAuthorizerName = authorizer.name[0].toUpperCase()
+ authorizer.name.substr(1);
const normalizedAuthorizerName = _.capitalize(authorizer.name);
_.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, {
[`${normalizedAuthorizerName}LambdaPermissionApiGateway`]: {

View File

@ -4,91 +4,78 @@ const BbPromise = require('bluebird');
const _ = require('lodash');
module.exports = {
compileResources() {
this.resourceFunctions = [];
this.resourcePaths = [];
this.resourceLogicalIds = {};
const resourcePaths = this.getResourcePaths();
_.forEach(this.serverless.service.functions, (functionObject, functionName) => {
functionObject.events.forEach(event => {
if (event.http) {
let path;
this.apiGatewayResourceNames = {};
this.apiGatewayResourceLogicalIds = {};
if (typeof event.http === 'object') {
path = event.http.path;
} else if (typeof event.http === 'string') {
path = event.http.split(' ')[1];
} else {
const errorMessage = [
`HTTP event of function ${functionName} is not an object nor a string.`,
' The correct syntax is: http: get users/list',
' OR an object with "path" and "method" proeprties.',
' Please check the docs for more info.',
].join('');
throw new this.serverless.classes
.Error(errorMessage);
}
// ['users', 'users/create', 'users/create/something']
resourcePaths.forEach(path => {
const pathArray = path.split('/');
const resourceName = pathArray.map(this.capitalizeAlphaNumericPath).join('');
const resourceLogicalId = `ApiGatewayResource${resourceName}`;
const pathPart = pathArray.pop();
const parentPath = pathArray.join('/');
const parentRef = this.getResourceId(parentPath);
while (path !== '') {
if (this.resourcePaths.indexOf(path) === -1) {
this.resourcePaths.push(path);
this.resourceFunctions.push(functionName);
}
this.apiGatewayResourceNames[path] = resourceName;
this.apiGatewayResourceLogicalIds[path] = resourceLogicalId;
const splittedPath = path.split('/');
splittedPath.pop();
path = splittedPath.join('/');
}
}
_.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, {
[resourceLogicalId]: {
Type: 'AWS::ApiGateway::Resource',
Properties: {
ParentId: parentRef,
PathPart: pathPart,
RestApiId: { Ref: this.apiGatewayRestApiLogicalId },
},
},
});
});
return BbPromise.resolve();
},
const capitalizeAlphaNumericPath = (path) => _.upperFirst(
getResourcePaths() {
const paths = _.reduce(this.validated.events, (resourcePaths, event) => {
let path = event.http.path;
while (path !== '') {
if (resourcePaths.indexOf(path) === -1) {
resourcePaths.push(path);
}
const splittedPath = path.split('/');
splittedPath.pop();
path = splittedPath.join('/');
}
return resourcePaths;
}, []);
// (stable) sort so that parents get processed before children
return _.sortBy(paths, path => path.split('/').length);
},
capitalizeAlphaNumericPath(path) {
return _.upperFirst(
_.capitalize(path)
.replace(/-/g, 'Dash')
.replace(/\{(.*)\}/g, '$1Var')
.replace(/[^0-9A-Za-z]/g, '')
);
},
// ['users', 'users/create', 'users/create/something']
this.resourcePaths.forEach(path => {
const resourcesArray = path.split('/');
// resource name is the last element in the endpoint. It's not unique.
const resourceName = path.split('/')[path.split('/').length - 1];
const resourcePath = path;
const normalizedResourceName = resourcesArray.map(capitalizeAlphaNumericPath).join('');
const resourceLogicalId = `ApiGatewayResource${normalizedResourceName}`;
this.resourceLogicalIds[resourcePath] = resourceLogicalId;
resourcesArray.pop();
getResourceId(path) {
if (path === '') {
return { 'Fn::GetAtt': [this.apiGatewayRestApiLogicalId, 'RootResourceId'] };
}
return { Ref: this.apiGatewayResourceLogicalIds[path] };
},
let resourceParentId;
if (resourcesArray.length === 0) {
resourceParentId = '{ "Fn::GetAtt": ["ApiGatewayRestApi", "RootResourceId"] }';
} else {
const normalizedResourceParentName = resourcesArray
.map(capitalizeAlphaNumericPath).join('');
resourceParentId = `{ "Ref" : "ApiGatewayResource${normalizedResourceParentName}" }`;
}
const resourceTemplate = `
{
"Type" : "AWS::ApiGateway::Resource",
"Properties" : {
"ParentId" : ${resourceParentId},
"PathPart" : "${resourceName}",
"RestApiId" : { "Ref" : "ApiGatewayRestApi" }
}
}
`;
const resourceObject = {
[resourceLogicalId]: JSON.parse(resourceTemplate),
};
_.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources,
resourceObject);
});
return BbPromise.resolve();
getResourceName(path) {
if (path === '') {
return '';
}
return this.apiGatewayResourceNames[path];
},
};

View File

@ -2,9 +2,41 @@
const _ = require('lodash');
const NOT_FOUND = -1;
const DEFAULT_STATUS_CODES = {
200: {
pattern: '',
},
400: {
pattern: '.*\\[400\\].*',
},
401: {
pattern: '.*\\[401\\].*',
},
403: {
pattern: '.*\\[403\\].*',
},
404: {
pattern: '.*\\[404\\].*',
},
422: {
pattern: '.*\\[422\\].*',
},
500: {
pattern: '.*(Process\\s?exited\\s?before\\s?completing\\s?request|\\[500\\]).*',
},
502: {
pattern: '.*\\[502\\].*',
},
504: {
pattern: '.*\\[504\\].*',
},
};
module.exports = {
validate() {
const events = [];
const corsPreflight = {};
_.forEach(this.serverless.service.functions, (functionObject, functionName) => {
_.forEach(functionObject.events, (event) => {
@ -18,6 +50,60 @@ module.exports = {
http.authorizer = this.getAuthorizer(http, functionName);
}
if (http.cors) {
http.cors = this.getCors(http);
const cors = corsPreflight[http.path] || {};
cors.headers = _.union(http.cors.headers, cors.headers);
cors.methods = _.union(http.cors.methods, cors.methods);
cors.origins = _.union(http.cors.origins, cors.origins);
corsPreflight[http.path] = cors;
}
http.integration = this.getIntegration(http);
if (http.integration === 'AWS') {
if (http.request) {
http.request = this.getRequest(http);
if (http.request.parameters) {
http.request.parameters = this.getRequestParameters(http.request);
}
} else {
http.request = {};
}
http.request.passThrough = this.getRequestPassThrough(http);
if (http.response) {
http.response = this.getResponse(http);
} else {
http.response = {};
}
if (http.response.statusCodes) {
http.response.statusCodes = _.assign({}, http.response.statusCodes);
if (!_.some(http.response.statusCodes, code => code.pattern === '')) {
http.response.statusCodes['200'] = DEFAULT_STATUS_CODES['200'];
}
} else {
http.response.statusCodes = DEFAULT_STATUS_CODES;
}
} else if (http.integration === 'AWS_PROXY') {
// show a warning when request / response config is used with AWS_PROXY (LAMBDA-PROXY)
if (http.request || http.response) {
const warningMessage = [
'Warning! You\'re using the LAMBDA-PROXY in combination with request / response',
` configuration in your function "${functionName}".`,
' This configuration will be ignored during deployment.',
].join('');
this.serverless.cli.log(warningMessage);
}
}
events.push({
functionName,
http,
@ -28,6 +114,7 @@ module.exports = {
return {
events,
corsPreflight,
};
},
@ -146,9 +233,149 @@ module.exports = {
};
},
getCors(http) {
const headers = [
'Content-Type',
'X-Amz-Date',
'Authorization',
'X-Api-Key',
'X-Amz-Security-Token',
];
let cors = {
origins: ['*'],
methods: ['OPTIONS'],
headers,
};
if (typeof http.cors === 'object') {
cors = http.cors;
cors.methods = cors.methods || [];
if (cors.headers) {
if (!Array.isArray(cors.headers)) {
const errorMessage = [
'CORS header values must be provided as an array.',
' Please check the docs for more info.',
].join('');
throw new this.serverless.classes.Error(errorMessage);
}
} else {
cors.headers = headers;
}
if (cors.methods.indexOf('OPTIONS') === NOT_FOUND) {
cors.methods.push('OPTIONS');
}
if (cors.methods.indexOf(http.method.toUpperCase()) === NOT_FOUND) {
cors.methods.push(http.method.toUpperCase());
}
} else {
cors.methods.push(http.method.toUpperCase());
}
return cors;
},
getIntegration(http) {
if (http.integration) {
const allowedIntegrations = [
'LAMBDA-PROXY', 'LAMBDA',
];
// normalize the integration for further processing
const normalizedIntegration = http.integration.toUpperCase();
// check if the user has entered a non-valid integration
if (allowedIntegrations.indexOf(normalizedIntegration) === NOT_FOUND) {
const errorMessage = [
`Invalid APIG integration "${http.integration}"`,
` in function "${http.functionName}".`,
' Supported integrations are: lambda, lambda-proxy.',
].join('');
throw new this.serverless.classes.Error(errorMessage);
}
if (normalizedIntegration === 'LAMBDA') {
return 'AWS';
}
}
return 'AWS_PROXY';
},
getRequest(http) {
if (typeof http.request !== 'object') {
const errorMessage = [
'Request config must be provided as an object.',
' Please check the docs for more info.',
].join('');
throw new this.serverless.classes.Error(errorMessage);
}
if (http.request.template && typeof http.request.template !== 'object') {
const errorMessage = [
'Template config must be provided as an object.',
' Please check the docs for more info.',
].join('');
throw new this.serverless.classes.Error(errorMessage);
}
return http.request;
},
getRequestParameters(httpRequest) {
const parameters = {};
// only these locations are currently supported
const locations = ['querystrings', 'paths', 'headers'];
_.each(locations, (location) => {
// strip the plural s
const singular = location.substring(0, location.length - 1);
_.each(httpRequest.parameters[location], (value, key) => {
parameters[`method.request.${singular}.${key}`] = value;
});
});
return parameters;
},
getRequestPassThrough(http) {
const requestPassThroughBehaviors = [
'NEVER', 'WHEN_NO_MATCH', 'WHEN_NO_TEMPLATES',
];
if (http.request.passThrough) {
if (requestPassThroughBehaviors.indexOf(http.request.passThrough) === -1) {
const errorMessage = [
'Request passThrough "',
http.request.passThrough,
'" is not one of ',
requestPassThroughBehaviors.join(', '),
].join('');
throw new this.serverless.classes.Error(errorMessage);
}
return http.request.passThrough;
}
return requestPassThroughBehaviors[0];
},
getResponse(http) {
if (typeof http.response !== 'object') {
const errorMessage = [
'Response config must be provided as an object.',
' Please check the docs for more info.',
].join('');
throw new this.serverless.classes.Error(errorMessage);
}
if (http.response.headers && typeof http.response.headers !== 'object') {
const errorMessage = [
'Response headers must be provided as an object.',
' Please check the docs for more info.',
].join('');
throw new this.serverless.classes.Error(errorMessage);
}
return http.response;
},
getLambdaArn(name) {
this.serverless.service.getFunction(name);
const normalizedName = name[0].toUpperCase() + name.substr(1);
const normalizedName = _.capitalize(name);
return { 'Fn::GetAtt': [`${normalizedName}LambdaFunction`, 'Arn'] };
},

View File

@ -5,6 +5,7 @@ require('./validate');
require('./restApi');
require('./apiKeys');
require('./resources');
require('./cors');
require('./methods');
require('./authorizers');
require('./deployment');

View File

@ -0,0 +1,126 @@
'use strict';
const expect = require('chai').expect;
const AwsCompileApigEvents = require('../index');
const Serverless = require('../../../../../../../Serverless');
describe('#compileCors()', () => {
let serverless;
let awsCompileApigEvents;
beforeEach(() => {
serverless = new Serverless();
serverless.service.service = 'first-service';
serverless.service.provider.compiledCloudFormationTemplate = { Resources: {} };
serverless.service.environment = {
stages: {
dev: {
regions: {
'us-east-1': {
vars: {
IamRoleLambdaExecution:
'arn:aws:iam::12345678:role/service-dev-IamRoleLambdaExecution-FOO12345678',
},
},
},
},
},
};
const options = {
stage: 'dev',
region: 'us-east-1',
};
awsCompileApigEvents = new AwsCompileApigEvents(serverless, options);
awsCompileApigEvents.apiGatewayRestApiLogicalId = 'ApiGatewayRestApi';
awsCompileApigEvents.apiGatewayResourceLogicalIds = {
'users/create': 'ApiGatewayResourceUsersCreate',
'users/list': 'ApiGatewayResourceUsersList',
'users/update': 'ApiGatewayResourceUsersUpdate',
'users/delete': 'ApiGatewayResourceUsersDelete',
};
awsCompileApigEvents.apiGatewayResourceNames = {
'users/create': 'UsersCreate',
'users/list': 'UsersList',
'users/update': 'UsersUpdate',
'users/delete': 'UsersDelete',
};
awsCompileApigEvents.validated = {};
});
it('should create preflight method for CORS enabled resource', () => {
awsCompileApigEvents.validated.corsPreflight = {
'users/update': {
origins: ['*'],
headers: ['*'],
methods: ['OPTIONS', 'PUT'],
},
'users/create': {
origins: ['*'],
headers: ['*'],
methods: ['OPTIONS', 'POST'],
},
'users/delete': {
origins: ['*'],
headers: ['CustomHeaderA', 'CustomHeaderB'],
methods: ['OPTIONS', 'DELETE'],
},
};
return awsCompileApigEvents.compileCors().then(() => {
expect(
awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayMethodUsersCreateOptions
.Properties.Integration.IntegrationResponses[0]
.ResponseParameters['method.response.header.Access-Control-Allow-Origin']
).to.equal('\'*\'');
expect(
awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayMethodUsersCreateOptions
.Properties.Integration.IntegrationResponses[0]
.ResponseParameters['method.response.header.Access-Control-Allow-Headers']
).to.equal('\'*\'');
expect(
awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayMethodUsersCreateOptions
.Properties.Integration.IntegrationResponses[0]
.ResponseParameters['method.response.header.Access-Control-Allow-Methods']
).to.equal('\'OPTIONS,POST\'');
expect(
awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayMethodUsersUpdateOptions
.Properties.Integration.IntegrationResponses[0]
.ResponseParameters['method.response.header.Access-Control-Allow-Origin']
).to.equal('\'*\'');
expect(
awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayMethodUsersUpdateOptions
.Properties.Integration.IntegrationResponses[0]
.ResponseParameters['method.response.header.Access-Control-Allow-Methods']
).to.equal('\'OPTIONS,PUT\'');
expect(
awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayMethodUsersDeleteOptions
.Properties.Integration.IntegrationResponses[0]
.ResponseParameters['method.response.header.Access-Control-Allow-Origin']
).to.equal('\'*\'');
expect(
awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayMethodUsersDeleteOptions
.Properties.Integration.IntegrationResponses[0]
.ResponseParameters['method.response.header.Access-Control-Allow-Headers']
).to.equal('\'CustomHeaderA,CustomHeaderB\'');
expect(
awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayMethodUsersDeleteOptions
.Properties.Integration.IntegrationResponses[0]
.ResponseParameters['method.response.header.Access-Control-Allow-Methods']
).to.equal('\'OPTIONS,DELETE\'');
});
});
});

View File

@ -20,7 +20,7 @@ describe('#compileDeployment()', () => {
};
awsCompileApigEvents = new AwsCompileApigEvents(serverless, options);
awsCompileApigEvents.apiGatewayRestApiLogicalId = 'ApiGatewayRestApi';
awsCompileApigEvents.methodDependencies = ['method-dependency1', 'method-dependency2'];
awsCompileApigEvents.apiGatewayMethodLogicalIds = ['method-dependency1', 'method-dependency2'];
});
it('should create a deployment resource', () => awsCompileApigEvents

View File

@ -12,124 +12,232 @@ describe('#compileResources()', () => {
serverless = new Serverless();
serverless.service.provider.compiledCloudFormationTemplate = { Resources: {} };
awsCompileApigEvents = new AwsCompileApigEvents(serverless);
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
path: 'foo/bar',
method: 'POST',
},
},
{
http: 'GET bar/-',
},
{
http: 'GET bar/foo',
},
{
http: 'GET bar/{id}',
},
{
http: 'GET bar/{id}/foobar',
},
{
http: 'GET bar/{foo_id}',
},
{
http: 'GET bar/{foo_id}/foobar',
},
],
},
};
awsCompileApigEvents.apiGatewayRestApiLogicalId = 'ApiGatewayRestApi';
awsCompileApigEvents.validated = {};
});
it('should throw an error if http event type is not a string or an object', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: 42,
},
],
// sorted makes parent refs easier
it('should construct the correct (sorted) resourcePaths array', () => {
awsCompileApigEvents.validated.events = [
{
http: {
path: '',
method: 'GET',
},
},
};
expect(() => awsCompileApigEvents.compileResources()).to.throw(Error);
{
http: {
path: 'foo/bar',
method: 'POST',
},
},
{
http: {
path: 'bar/-',
method: 'GET',
},
},
{
http: {
path: 'bar/foo',
method: 'GET',
},
},
{
http: {
path: 'bar/{id}/foobar',
method: 'GET',
},
},
{
http: {
path: 'bar/{id}',
method: 'GET',
},
},
{
http: {
path: 'bar/{foo_id}',
method: 'GET',
},
},
{
http: {
path: 'bar/{foo_id}/foobar',
method: 'GET',
},
},
];
expect(awsCompileApigEvents.getResourcePaths()).to.deep.equal([
'foo',
'bar',
'foo/bar',
'bar/-',
'bar/foo',
'bar/{id}',
'bar/{foo_id}',
'bar/{id}/foobar',
'bar/{foo_id}/foobar',
]);
});
it('should construct the correct resourcePaths array', () => awsCompileApigEvents
.compileResources().then(() => {
const expectedResourcePaths = [
'foo/bar',
'foo',
'bar/-',
'bar',
'bar/foo',
'bar/{id}',
'bar/{id}/foobar',
'bar/{foo_id}',
'bar/{foo_id}/foobar',
];
expect(awsCompileApigEvents.resourcePaths).to.deep.equal(expectedResourcePaths);
})
);
it('should construct the correct resourceLogicalIds object', () => awsCompileApigEvents
.compileResources().then(() => {
const expectedResourceLogicalIds = {
'bar/-': 'ApiGatewayResourceBarDash',
'foo/bar': 'ApiGatewayResourceFooBar',
foo: 'ApiGatewayResourceFoo',
'bar/{id}/foobar': 'ApiGatewayResourceBarIdVarFoobar',
'bar/{id}': 'ApiGatewayResourceBarIdVar',
'bar/{foo_id}/foobar': 'ApiGatewayResourceBarFooidVarFoobar',
'bar/{foo_id}': 'ApiGatewayResourceBarFooidVar',
'bar/foo': 'ApiGatewayResourceBarFoo',
bar: 'ApiGatewayResourceBar',
};
expect(awsCompileApigEvents.resourceLogicalIds).to.deep.equal(expectedResourceLogicalIds);
})
);
it('should create resource resources when http events are given', () => awsCompileApigEvents
.compileResources().then(() => {
expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayResourceFooBar.Properties.PathPart)
.to.equal('bar');
expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayResourceFooBar.Properties.ParentId.Ref)
.to.equal('ApiGatewayResourceFoo');
it('should reference the appropriate ParentId', () => {
awsCompileApigEvents.validated.events = [
{
http: {
path: 'foo/bar',
method: 'POST',
},
},
{
http: {
path: 'bar/-',
method: 'GET',
},
},
{
http: {
path: 'bar/foo',
method: 'GET',
},
},
{
http: {
path: 'bar/{id}/foobar',
method: 'GET',
},
},
{
http: {
path: 'bar/{id}',
method: 'GET',
},
},
];
return awsCompileApigEvents.compileResources().then(() => {
expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayResourceFoo.Properties.ParentId['Fn::GetAtt'][0])
.to.equal('ApiGatewayRestApi');
expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayResourceBar.Properties.ParentId['Fn::GetAtt'][1])
.Resources.ApiGatewayResourceFoo.Properties.ParentId['Fn::GetAtt'][1])
.to.equal('RootResourceId');
expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayResourceFooBar.Properties.ParentId.Ref)
.to.equal('ApiGatewayResourceFoo');
expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayResourceBarIdVar.Properties.ParentId.Ref)
.to.equal('ApiGatewayResourceBar');
expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayResourceBarFooidVar.Properties.ParentId.Ref)
.to.equal('ApiGatewayResourceBar');
expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayResourceBarFooidVarFoobar.Properties.ParentId.Ref)
.to.equal('ApiGatewayResourceBarFooidVar');
})
);
});
});
it('should not create resource resources when http events are not given', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [],
it('should construct the correct resourceLogicalIds object', () => {
awsCompileApigEvents.validated.events = [
{
http: {
path: '',
method: 'POST',
},
},
};
{
http: {
path: 'foo',
method: 'GET',
},
},
{
http: {
path: 'foo/{foo_id}/bar',
method: 'GET',
},
},
{
http: {
path: 'baz/foo',
method: 'GET',
},
},
];
return awsCompileApigEvents.compileResources().then(() => {
expect(
awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources
).to.deep.equal({});
expect(awsCompileApigEvents.apiGatewayResourceLogicalIds).to.deep.equal({
baz: 'ApiGatewayResourceBaz',
'baz/foo': 'ApiGatewayResourceBazFoo',
foo: 'ApiGatewayResourceFoo',
'foo/{foo_id}': 'ApiGatewayResourceFooFooidVar',
'foo/{foo_id}/bar': 'ApiGatewayResourceFooFooidVarBar',
});
});
});
it('should construct resourceLogicalIds that do not collide', () => {
awsCompileApigEvents.validated.events = [
{
http: {
path: 'foo/bar',
method: 'POST',
},
},
{
http: {
path: 'foo/{bar}',
method: 'GET',
},
},
];
return awsCompileApigEvents.compileResources().then(() => {
expect(awsCompileApigEvents.apiGatewayResourceLogicalIds).to.deep.equal({
foo: 'ApiGatewayResourceFoo',
'foo/bar': 'ApiGatewayResourceFooBar',
'foo/{bar}': 'ApiGatewayResourceFooBarVar',
});
});
});
it('should set the appropriate Pathpart', () => {
awsCompileApigEvents.validated.events = [
{
http: {
path: 'foo/{bar}',
method: 'GET',
},
},
{
http: {
path: 'foo/bar',
method: 'GET',
},
},
{
http: {
path: 'foo/{bar}/baz',
method: 'GET',
},
},
];
return awsCompileApigEvents.compileResources().then(() => {
expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayResourceFooBar.Properties.PathPart)
.to.equal('bar');
expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayResourceFooBarVar.Properties.PathPart)
.to.equal('{bar}');
expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources.ApiGatewayResourceFooBarVarBaz.Properties.PathPart)
.to.equal('baz');
});
});
it('should handle root resource references', () => {
awsCompileApigEvents.validated.events = [
{
http: {
path: '',
method: 'GET',
},
},
];
return awsCompileApigEvents.compileResources().then(() => {
expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate
.Resources).to.deep.equal({});
});
});
});

View File

@ -1,26 +1,16 @@
'use strict';
const expect = require('chai').expect;
const sinon = require('sinon');
const AwsCompileApigEvents = require('../index');
const Serverless = require('../../../../../../../Serverless');
describe('#validate()', () => {
let serverless;
let awsCompileApigEvents;
beforeEach(() => {
const serverless = new Serverless();
serverless.service.environment = {
vars: {},
stages: {
dev: {
vars: {},
regions: {},
},
},
};
serverless.service.environment.stages.dev.regions['us-east-1'] = {
vars: {},
};
serverless = new Serverless();
const options = {
stage: 'dev',
region: 'us-east-1',
@ -28,6 +18,20 @@ describe('#validate()', () => {
awsCompileApigEvents = new AwsCompileApigEvents(serverless, options);
});
it('should ignore non-http events', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
ignored: {},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(0);
});
it('should reject an invalid http event', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
@ -41,6 +45,20 @@ describe('#validate()', () => {
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('should throw an error if http event type is not a string or an object', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: 42,
},
],
},
};
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('should validate the http events "path" property', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
@ -158,6 +176,28 @@ describe('#validate()', () => {
expect(validated.events).to.be.an('Array').with.length(1);
});
it('should discard a starting slash from paths', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'POST',
path: '/foo/bar',
},
},
{
http: 'GET /foo/bar',
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(2);
expect(validated.events[0].http).to.have.property('path', 'foo/bar');
expect(validated.events[1].http).to.have.property('path', 'foo/bar');
});
it('should throw if an authorizer is an invalid value', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
@ -312,4 +352,748 @@ describe('#validate()', () => {
expect(authorizer.identitySource).to.equal('method.request.header.Custom');
expect(authorizer.identityValidationExpression).to.equal('foo');
});
it('should process cors defaults', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'POST',
path: '/foo/bar',
cors: true,
},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(1);
expect(validated.events[0].http.cors).to.deep.equal({
headers: ['Content-Type', 'X-Amz-Date', 'Authorization', 'X-Api-Key', 'X-Amz-Security-Token'],
methods: ['OPTIONS', 'POST'],
origins: ['*'],
});
});
it('should throw if request is malformed', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'POST',
path: '/foo/bar',
integration: 'lambda',
request: 'invalid',
},
},
],
},
};
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('should throw if request.passThrough is invalid', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'POST',
path: '/foo/bar',
integration: 'lambda',
request: {
passThrough: 'INVALID',
},
},
},
],
},
};
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('should throw if request.template is malformed', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'POST',
path: '/foo/bar',
integration: 'lambda',
request: {
template: 'invalid',
},
},
},
],
},
};
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('should throw if response is malformed', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'POST',
path: '/foo/bar',
integration: 'lambda',
response: 'invalid',
},
},
],
},
};
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('should throw if response.headers are malformed', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'POST',
path: '/foo/bar',
integration: 'lambda',
response: {
headers: 'invalid',
},
},
},
],
},
};
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('should throw if cors headers are not an array', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'POST',
path: '/foo/bar',
cors: {
headers: true,
},
},
},
],
},
};
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('should process cors options', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'POST',
path: '/foo/bar',
cors: {
headers: ['X-Foo-Bar'],
origins: ['acme.com'],
methods: ['POST', 'OPTIONS'],
},
},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(1);
expect(validated.events[0].http.cors).to.deep.equal({
headers: ['X-Foo-Bar'],
methods: ['POST', 'OPTIONS'],
origins: ['acme.com'],
});
});
it('should merge all preflight origins, method, and headers for a path', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'GET',
path: 'users',
cors: {
origins: [
'http://example.com',
],
},
},
}, {
http: {
method: 'POST',
path: 'users',
cors: {
origins: [
'http://example2.com',
],
},
},
}, {
http: {
method: 'PUT',
path: 'users/{id}',
cors: {
headers: [
'TestHeader',
],
},
},
}, {
http: {
method: 'DELETE',
path: 'users/{id}',
cors: {
headers: [
'TestHeader2',
],
},
},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.corsPreflight['users/{id}'].methods)
.to.deep.equal(['OPTIONS', 'DELETE', 'PUT']);
expect(validated.corsPreflight.users.origins)
.to.deep.equal(['http://example2.com', 'http://example.com']);
expect(validated.corsPreflight['users/{id}'].headers)
.to.deep.equal(['TestHeader2', 'TestHeader']);
});
it('should add default statusCode to custom statusCodes', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'GET',
path: 'users/list',
integration: 'lambda',
response: {
statusCodes: {
404: {
pattern: '.*"statusCode":404,.*',
template: '$input.path(\'$.errorMessage\')',
headers: {
'Content-Type': 'text/html',
},
},
},
},
},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(1);
expect(validated.events[0].http.response.statusCodes).to.deep.equal({
200: {
pattern: '',
},
404: {
pattern: '.*"statusCode":404,.*',
template: '$input.path(\'$.errorMessage\')',
headers: {
'Content-Type': 'text/html',
},
},
});
});
it('should allow custom statusCode with default pattern', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'GET',
path: 'users/list',
integration: 'lambda',
response: {
statusCodes: {
418: {
pattern: '',
template: '$input.path(\'$.foo\')',
},
},
},
},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(1);
expect(validated.events[0].http.response.statusCodes).to.deep.equal({
418: {
pattern: '',
template: '$input.path(\'$.foo\')',
},
});
});
it('should handle expicit methods', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'POST',
path: '/foo/bar',
cors: {
methods: ['POST'],
},
},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(1);
expect(validated.events[0].http.cors.methods).to.deep.equal(['POST', 'OPTIONS']);
});
it('should throw an error if the method is invalid', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
path: 'foo/bar',
method: 'INVALID',
},
},
],
},
};
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('should set authorizer.arn when provided a name string', () => {
awsCompileApigEvents.serverless.service.functions = {
authorizer: {},
first: {
events: [
{
http: {
path: 'foo/bar',
method: 'GET',
authorizer: 'authorizer',
},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(1);
expect(validated.events[0].http.authorizer.name).to.equal('authorizer');
expect(validated.events[0].http.authorizer.arn).to.deep.equal({
'Fn::GetAtt': ['AuthorizerLambdaFunction', 'Arn'],
});
});
it('should set authorizer.arn when provided an ARN string', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
path: 'foo/bar',
method: 'GET',
authorizer: 'xxx:dev-authorizer',
},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(1);
expect(validated.events[0].http.authorizer.name).to.equal('authorizer');
expect(validated.events[0].http.authorizer.arn).to.equal('xxx:dev-authorizer');
});
it('should handle authorizer.name object', () => {
awsCompileApigEvents.serverless.service.functions = {
authorizer: {},
first: {
events: [
{
http: {
path: 'foo/bar',
method: 'GET',
authorizer: {
name: 'authorizer',
},
},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(1);
expect(validated.events[0].http.authorizer.name).to.equal('authorizer');
expect(validated.events[0].http.authorizer.arn).to.deep.equal({
'Fn::GetAtt': [
'AuthorizerLambdaFunction',
'Arn',
],
});
});
it('should handle an authorizer.arn object', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
path: 'foo/bar',
method: 'GET',
authorizer: {
arn: 'xxx:dev-authorizer',
},
},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(1);
expect(validated.events[0].http.authorizer.name).to.equal('authorizer');
expect(validated.events[0].http.authorizer.arn).to.equal('xxx:dev-authorizer');
});
it('should throw an error if the provided config is not an object', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'GET',
path: 'users/list',
request: 'some string',
},
},
],
},
};
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('should throw an error if the template config is not an object', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'GET',
path: 'users/list',
request: {
template: 'some string',
},
},
},
],
},
};
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('should process request parameters', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
integration: 'lambda',
path: 'foo/bar',
method: 'GET',
request: {
parameters: {
querystrings: {
foo: true,
bar: false,
},
paths: {
foo: true,
bar: false,
},
headers: {
foo: true,
bar: false,
},
},
},
},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(1);
expect(validated.events[0].http.request.parameters).to.deep.equal({
'method.request.querystring.foo': true,
'method.request.querystring.bar': false,
'method.request.path.foo': true,
'method.request.path.bar': false,
'method.request.header.foo': true,
'method.request.header.bar': false,
});
});
it('should throw an error if the provided response config is not an object', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
path: 'foo/bar',
method: 'GET',
response: 'some string',
},
},
],
},
};
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('should throw an error if the response headers are not objects', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
path: 'foo/bar',
method: 'GET',
response: {
headers: 'some string',
},
},
},
],
},
};
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('throw error if authorizer property is not a string or object', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
path: 'foo/bar',
method: 'GET',
authorizer: 2,
},
},
],
},
};
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('throw error if authorizer property is an object but no name or arn provided', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
path: 'foo/bar',
method: 'GET',
authorizer: {},
},
},
],
},
};
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('should set "AWS_PROXY" as the default integration type', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'GET',
path: 'users/list',
},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(1);
expect(validated.events[0].http.integration).to.equal('AWS_PROXY');
});
it('should support LAMBDA integration', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'GET',
path: 'users/list',
integration: 'LAMBDA',
},
},
{
http: {
method: 'PUT',
path: 'users/list',
integration: 'lambda',
},
},
{
http: {
method: 'POST',
path: 'users/list',
integration: 'lambda-proxy',
},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(3);
expect(validated.events[0].http.integration).to.equal('AWS');
expect(validated.events[1].http.integration).to.equal('AWS');
expect(validated.events[2].http.integration).to.equal('AWS_PROXY');
});
it('should show a warning message when using request / response config with LAMBDA-PROXY', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'GET',
path: 'users/list',
integration: 'lambda-proxy',
request: {
template: {
'template/1': '{ "stage" : "$context.stage" }',
'template/2': '{ "httpMethod" : "$context.httpMethod" }',
},
},
response: {
template: "$input.path('$.foo')",
},
},
},
],
},
};
// initialize so we get the log method from the CLI in place
serverless.init();
const logStub = sinon.stub(serverless.cli, 'log');
awsCompileApigEvents.validate();
expect(logStub.calledOnce).to.be.equal(true);
expect(logStub.args[0][0].length).to.be.at.least(1);
});
it('should throw an error when an invalid integration type was provided', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'GET',
path: 'users/list',
integration: 'INVALID',
},
},
],
},
};
expect(() => awsCompileApigEvents.validate()).to.throw(Error);
});
it('should accept a valid passThrough', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'GET',
path: 'users/list',
integration: 'lambda',
request: {
passThrough: 'WHEN_NO_MATCH',
},
},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(1);
expect(validated.events[0].http.request.passThrough).to.equal('WHEN_NO_MATCH');
});
it('should default pass through to NEVER', () => {
awsCompileApigEvents.serverless.service.functions = {
first: {
events: [
{
http: {
method: 'GET',
path: 'users/list',
integration: 'lambda',
},
},
],
},
};
const validated = awsCompileApigEvents.validate();
expect(validated.events).to.be.an('Array').with.length(1);
expect(validated.events[0].http.request.passThrough).to.equal('NEVER');
});
});