From a415e9af7795b66153f3f2f15b967ade47baf37d Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Wed, 14 Aug 2019 23:43:14 +0200 Subject: [PATCH 01/19] feat: added schema validation for S3 --- lib/package/s3/schema.js | 31 +++++ lib/package/s3/validateS3ServiceProxy.js | 18 +++ lib/package/s3/validateS3ServiceProxy.test.js | 121 ++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 lib/package/s3/schema.js create mode 100644 lib/package/s3/validateS3ServiceProxy.js create mode 100644 lib/package/s3/validateS3ServiceProxy.test.js diff --git a/lib/package/s3/schema.js b/lib/package/s3/schema.js new file mode 100644 index 0000000..0744bc2 --- /dev/null +++ b/lib/package/s3/schema.js @@ -0,0 +1,31 @@ +'use strict' + +const Joi = require('@hapi/joi') + +const action = Joi.string().valid('GetObject', 'PutObject', 'DeleteObject') + +const bucket = Joi.alternatives().try([ + Joi.string(), + Joi.object().keys({ + Ref: Joi.string().required() + }) +]) + +const key = Joi.alternatives().try([ + Joi.string(), + Joi.object() + .keys({ + pathParam: Joi.string(), + queryStringParam: Joi.string() + }) + .xor('pathParam', 'queryStringParam') +]) + +const schema = Joi.object().keys({ + action: action.required(), + bucket: bucket.required(), + key: key.required(), + cors: Joi.boolean().default(false) +}) + +module.exports = schema diff --git a/lib/package/s3/validateS3ServiceProxy.js b/lib/package/s3/validateS3ServiceProxy.js new file mode 100644 index 0000000..b4035f7 --- /dev/null +++ b/lib/package/s3/validateS3ServiceProxy.js @@ -0,0 +1,18 @@ +'use strict' + +const Joi = require('@hapi/joi') +const schema = require('./schema') + +module.exports = { + validateS3ServiceProxy() { + this.validated.events.map((serviceProxy) => { + if (serviceProxy.serviceName == 's3') { + const { error } = Joi.validate(serviceProxy.http, schema, { allowUnknown: true }) + + if (error) { + throw new this.serverless.classes.Error(error) + } + } + }) + } +} diff --git a/lib/package/s3/validateS3ServiceProxy.test.js b/lib/package/s3/validateS3ServiceProxy.test.js new file mode 100644 index 0000000..216fbd6 --- /dev/null +++ b/lib/package/s3/validateS3ServiceProxy.test.js @@ -0,0 +1,121 @@ +'use strict' + +const Serverless = require('serverless/lib/Serverless') +const AwsProvider = require('serverless/lib/plugins/aws/provider/awsProvider') +const ServerlessApigatewayServiceProxy = require('./../../index') + +const expect = require('chai').expect + +describe('#validateS3ServiceProxy()', () => { + let serverless + let serverlessApigatewayServiceProxy + + beforeEach(() => { + serverless = new Serverless() + serverless.servicePath = true + serverless.service.service = 'apigw-service-proxy' + const options = { + stage: 'dev', + region: 'us-east-1' + } + serverless.setProvider('aws', new AwsProvider(serverless)) + serverless.service.provider.compiledCloudFormationTemplate = { Resources: {} } + serverlessApigatewayServiceProxy = new ServerlessApigatewayServiceProxy(serverless, options) + }) + + const genEvent = (key, value, ...missing) => { + const proxy = { + serviceName: 's3', + http: { + action: 'GetObject', + bucket: 'myBucket', + key: 'myKey', + path: 's3', + method: 'post' + } + } + + proxy.http[key] = value + missing.forEach((k) => delete proxy.http[k]) + return proxy + } + + const shouldError = (key, value) => { + serverlessApigatewayServiceProxy.validated = { + events: [genEvent(key, value)] + } + + expect(serverlessApigatewayServiceProxy.validateS3ServiceProxy).to.throw + } + + const shouldSuceed = (key, value) => { + serverlessApigatewayServiceProxy.validated = { + events: [genEvent(key, value)] + } + + expect(serverlessApigatewayServiceProxy.validateS3ServiceProxy).to.not.throw + } + + it('should error if the "bucket" property is missing', () => { + serverlessApigatewayServiceProxy.validated = { + events: [genEvent(null, null, 'bucket')] + } + + expect(serverlessApigatewayServiceProxy.validateS3ServiceProxy).to.throw + }) + + it('should succeed if the "bucket" property is string or AWS Ref function', () => { + shouldSuceed('bucket', 'x') + shouldSuceed('bucket', { Ref: 'x' }) + }) + + it('should error if the "bucket" property if AWS Ref function is invalid', () => { + shouldError('bucket', { xxx: 's3Bucket' }) + shouldError('bucket', { Ref: ['s3Bucket', 'Arn'] }) + shouldError('bucket', ['xx', 'yy']) + shouldError('bucket', { 'Fn::GetAtt': ['x', 'Arn'] }) + }) + + it('should error if the "action" property is missing', () => { + serverlessApigatewayServiceProxy.validated = { + events: [genEvent(null, null, 'action')] + } + + expect(serverlessApigatewayServiceProxy.validateS3ServiceProxy).to.throw + }) + + it('should error if the "action" property is not one of the allowed values', () => { + shouldError('action', ['x']) // arrays + shouldError('action', { Ref: 'x' }) // object + shouldError('action', 'ListObjects') // invalid actions + }) + + it('should succeed if the "action" property is one of the allowed values', () => { + shouldSuceed('action', 'GetObject') + shouldSuceed('action', 'PutObject') + shouldSuceed('action', 'DeleteObject') + }) + + it('should error the "key" property is missing', () => { + serverlessApigatewayServiceProxy.validated = { + events: [genEvent(null, null, 'key')] + } + + expect(serverlessApigatewayServiceProxy.validateS3ServiceProxy).to.throw + }) + + it('should succeed if the "key" property is string or valid object', () => { + shouldSuceed('key', 'myKey') + shouldSuceed('key', { pathParam: 'myKey' }) + shouldSuceed('key', { queryStringParam: 'myKey' }) + }) + + it('should error if the "key" property specifies both pathParam and queryStringParam', () => { + shouldError('key', { pathParam: 'myKey', queryStringParam: 'myKey' }) + }) + + it('should error if the "key" property is not a string or valid object', () => { + shouldError('key', ['x']) + shouldError('key', { param: 'myKey' }) + }) +}) From c7dc7b57dc2e94e5cad003c8cd927de6e3d632b2 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Thu, 15 Aug 2019 00:58:05 +0200 Subject: [PATCH 02/19] feat: added schema validation for S3 --- lib/package/s3/validateS3ServiceProxy.js | 2 +- lib/package/s3/validateS3ServiceProxy.test.js | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/package/s3/validateS3ServiceProxy.js b/lib/package/s3/validateS3ServiceProxy.js index b4035f7..69c3d97 100644 --- a/lib/package/s3/validateS3ServiceProxy.js +++ b/lib/package/s3/validateS3ServiceProxy.js @@ -5,7 +5,7 @@ const schema = require('./schema') module.exports = { validateS3ServiceProxy() { - this.validated.events.map((serviceProxy) => { + this.validated.events.forEach((serviceProxy) => { if (serviceProxy.serviceName == 's3') { const { error } = Joi.validate(serviceProxy.http, schema, { allowUnknown: true }) diff --git a/lib/package/s3/validateS3ServiceProxy.test.js b/lib/package/s3/validateS3ServiceProxy.test.js index 216fbd6..36a47c3 100644 --- a/lib/package/s3/validateS3ServiceProxy.test.js +++ b/lib/package/s3/validateS3ServiceProxy.test.js @@ -45,7 +45,9 @@ describe('#validateS3ServiceProxy()', () => { events: [genEvent(key, value)] } - expect(serverlessApigatewayServiceProxy.validateS3ServiceProxy).to.throw + expect(() => serverlessApigatewayServiceProxy.validateS3ServiceProxy()).throws( + serverless.classes.Error + ) } const shouldSuceed = (key, value) => { @@ -53,7 +55,7 @@ describe('#validateS3ServiceProxy()', () => { events: [genEvent(key, value)] } - expect(serverlessApigatewayServiceProxy.validateS3ServiceProxy).to.not.throw + serverlessApigatewayServiceProxy.validateS3ServiceProxy() } it('should error if the "bucket" property is missing', () => { @@ -61,7 +63,9 @@ describe('#validateS3ServiceProxy()', () => { events: [genEvent(null, null, 'bucket')] } - expect(serverlessApigatewayServiceProxy.validateS3ServiceProxy).to.throw + expect(() => serverlessApigatewayServiceProxy.validateS3ServiceProxy()).throws( + serverless.classes.Error + ) }) it('should succeed if the "bucket" property is string or AWS Ref function', () => { @@ -81,7 +85,9 @@ describe('#validateS3ServiceProxy()', () => { events: [genEvent(null, null, 'action')] } - expect(serverlessApigatewayServiceProxy.validateS3ServiceProxy).to.throw + expect(() => serverlessApigatewayServiceProxy.validateS3ServiceProxy()).throws( + serverless.classes.Error + ) }) it('should error if the "action" property is not one of the allowed values', () => { @@ -101,7 +107,9 @@ describe('#validateS3ServiceProxy()', () => { events: [genEvent(null, null, 'key')] } - expect(serverlessApigatewayServiceProxy.validateS3ServiceProxy).to.throw + expect(() => serverlessApigatewayServiceProxy.validateS3ServiceProxy()).throws( + serverless.classes.Error + ) }) it('should succeed if the "key" property is string or valid object', () => { From 5adbf7c43bd4a8b392235fa59de24437ff79a391 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Thu, 15 Aug 2019 00:58:55 +0200 Subject: [PATCH 03/19] feat: added compiling iam role for S3 --- lib/package/s3/compileIamRoleToS3.js | 77 ++++++++++ lib/package/s3/compileIamRoleToS3.test.js | 172 ++++++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 lib/package/s3/compileIamRoleToS3.js create mode 100644 lib/package/s3/compileIamRoleToS3.test.js diff --git a/lib/package/s3/compileIamRoleToS3.js b/lib/package/s3/compileIamRoleToS3.js new file mode 100644 index 0000000..34fa59e --- /dev/null +++ b/lib/package/s3/compileIamRoleToS3.js @@ -0,0 +1,77 @@ +'use strict' + +const _ = require('lodash') + +module.exports = { + compileIamRoleToS3() { + const bucketActions = _.flatMap(this.getAllServiceProxies(), (serviceProxy) => { + return _.flatMap(Object.keys(serviceProxy), (serviceName) => { + if (serviceName !== 's3') { + return [] + } + + return { + bucket: serviceProxy.s3.bucket, + action: serviceProxy.s3.action + } + }) + }) + + if (_.isEmpty(bucketActions)) { + return + } + + const permissions = bucketActions.map(({ bucket, action }) => { + return { + Effect: 'Allow', + Action: `s3:${action}`, + Resource: { + 'Fn::Sub': [ + '${bucket}/*', + { + bucket + } + ] + } + } + }) + + const template = { + Type: 'AWS::IAM::Role', + Properties: { + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { + Service: 'apigateway.amazonaws.com' + }, + Action: 'sts:AssumeRole' + } + ] + }, + Policies: [ + { + PolicyName: 'apigatewaytos3', + PolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'], + Resource: '*' + }, + ...permissions + ] + } + } + ] + } + } + + _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, { + ApigatewayToS3Role: template + }) + } +} diff --git a/lib/package/s3/compileIamRoleToS3.test.js b/lib/package/s3/compileIamRoleToS3.test.js new file mode 100644 index 0000000..614a613 --- /dev/null +++ b/lib/package/s3/compileIamRoleToS3.test.js @@ -0,0 +1,172 @@ +'use strict' + +const Serverless = require('serverless/lib/Serverless') +const AwsProvider = require('serverless/lib/plugins/aws/provider/awsProvider') +const ServerlessApigatewayServiceProxy = require('./../../index') + +const expect = require('chai').expect + +describe('#compileIamRoleToS3()', () => { + let serverless + let serverlessApigatewayServiceProxy + + beforeEach(() => { + serverless = new Serverless() + serverless.servicePath = true + serverless.service.service = 'apigw-service-proxy' + const options = { + stage: 'dev', + region: 'us-east-1' + } + serverless.setProvider('aws', new AwsProvider(serverless)) + serverless.service.provider.compiledCloudFormationTemplate = { Resources: {} } + serverlessApigatewayServiceProxy = new ServerlessApigatewayServiceProxy(serverless, options) + }) + + it('should create corresponding resources when S3 proxies are given', () => { + serverlessApigatewayServiceProxy.serverless.service.custom = { + apiGatewayServiceProxies: [ + { + s3: { + path: '/s3/v1', + method: 'post', + bucket: 'myBucket', + action: 'PutObject', + key: 'myKey' + } + }, + { + s3: { + path: '/s3/v1', + method: 'get', + bucket: 'myBucket', + action: 'GetObject', + key: 'myKey' + } + }, + { + s3: { + path: '/s3/v1', + method: 'delete', + bucket: { + Ref: 'MyBucket' + }, + action: 'DeleteObject', + key: 'myKey' + } + }, + { + s3: { + path: '/s3/v2', + method: 'post', + bucket: 'myBucketV2', + action: 'PutObject', + key: 'myKey' + } + } + ] + } + + serverlessApigatewayServiceProxy.compileIamRoleToS3() + expect(serverless.service.provider.compiledCloudFormationTemplate.Resources).to.deep.equal({ + ApigatewayToS3Role: { + Type: 'AWS::IAM::Role', + Properties: { + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { + Service: 'apigateway.amazonaws.com' + }, + Action: 'sts:AssumeRole' + } + ] + }, + Policies: [ + { + PolicyName: 'apigatewaytos3', + PolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'], + Resource: '*' + }, + { + Effect: 'Allow', + Action: 's3:PutObject', + Resource: { + 'Fn::Sub': [ + '${bucket}/*', + { + bucket: 'myBucket' + } + ] + } + }, + { + Effect: 'Allow', + Action: 's3:GetObject', + Resource: { + 'Fn::Sub': [ + '${bucket}/*', + { + bucket: 'myBucket' + } + ] + } + }, + { + Effect: 'Allow', + Action: 's3:DeleteObject', + Resource: { + 'Fn::Sub': [ + '${bucket}/*', + { + bucket: { + Ref: 'MyBucket' + } + } + ] + } + }, + { + Effect: 'Allow', + Action: 's3:PutObject', + Resource: { + 'Fn::Sub': [ + '${bucket}/*', + { + bucket: 'myBucketV2' + } + ] + } + } + ] + } + } + ] + } + } + }) + }) + + it('should not create corresponding resources when other proxies are given', () => { + serverlessApigatewayServiceProxy.serverless.service.custom = { + apiGatewayServiceProxies: [ + { + sqs: { + path: '/sqs', + method: 'post' + } + } + ] + } + + serverlessApigatewayServiceProxy.compileIamRoleToS3() + expect(serverless.service.provider.compiledCloudFormationTemplate.Resources).to.be.empty + }) +}) From 9846c6303cfdb0ddaeb190d3eddefeeade63867f Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Thu, 15 Aug 2019 01:13:08 +0200 Subject: [PATCH 04/19] refactor: removed unnecessary async-await --- lib/apiGateway/methods.js | 4 +- lib/apiGateway/methods.test.js | 50 +++++++++---------- .../kinesis/compileMethodsToKinesis.js | 20 ++++---- lib/package/sqs/compileMethodsToSqs.js | 10 ++-- 4 files changed, 40 insertions(+), 44 deletions(-) diff --git a/lib/apiGateway/methods.js b/lib/apiGateway/methods.js index 0a2f28c..9a031f5 100644 --- a/lib/apiGateway/methods.js +++ b/lib/apiGateway/methods.js @@ -1,7 +1,7 @@ 'use strict' module.exports = { - async getMethodResponses(http) { + getMethodResponses(http) { const methodResponse = { Properties: { MethodResponses: [ @@ -25,7 +25,7 @@ module.exports = { origin = http.cors.origins.join(',') } - methodResponse.Properties.MethodResponses.forEach(async (val, i) => { + methodResponse.Properties.MethodResponses.forEach((val, i) => { methodResponse.Properties.MethodResponses[i].ResponseParameters = { 'method.response.header.Access-Control-Allow-Origin': `'${origin}'` } diff --git a/lib/apiGateway/methods.test.js b/lib/apiGateway/methods.test.js index 9b5e9e7..f9e75cb 100644 --- a/lib/apiGateway/methods.test.js +++ b/lib/apiGateway/methods.test.js @@ -26,39 +26,35 @@ describe('#getAllServiceProxies()', () => { describe('#getMethodResponses()', () => { it('should return a corresponding methodResponses resource', async () => { - await expect( - serverlessApigatewayServiceProxy.getMethodResponses() - ).to.eventually.have.nested.property('Properties.MethodResponses') + expect(serverlessApigatewayServiceProxy.getMethodResponses()).to.have.nested.property( + 'Properties.MethodResponses' + ) }) it('should set Access-Control-Allow-Origin header when cors is true', async () => { - await expect( - serverlessApigatewayServiceProxy.getMethodResponses({ - cors: { - origin: '*' - } - }) - ).to.be.fulfilled.then((json) => { - expect( - json.Properties.MethodResponses[0].ResponseParameters[ - 'method.response.header.Access-Control-Allow-Origin' - ] - ).to.equal("'*'") + const json1 = serverlessApigatewayServiceProxy.getMethodResponses({ + cors: { + origin: '*' + } }) - await expect( - serverlessApigatewayServiceProxy.getMethodResponses({ - cors: { - origins: ['*', 'http://example.com'] - } - }) - ).to.be.fulfilled.then((json) => { - expect( - json.Properties.MethodResponses[0].ResponseParameters[ - 'method.response.header.Access-Control-Allow-Origin' - ] - ).to.equal("'*,http://example.com'") + expect( + json1.Properties.MethodResponses[0].ResponseParameters[ + 'method.response.header.Access-Control-Allow-Origin' + ] + ).to.equal("'*'") + + const json2 = serverlessApigatewayServiceProxy.getMethodResponses({ + cors: { + origins: ['*', 'http://example.com'] + } }) + + expect( + json2.Properties.MethodResponses[0].ResponseParameters[ + 'method.response.header.Access-Control-Allow-Origin' + ] + ).to.equal("'*,http://example.com'") }) }) }) diff --git a/lib/package/kinesis/compileMethodsToKinesis.js b/lib/package/kinesis/compileMethodsToKinesis.js index 96e2232..3af231d 100644 --- a/lib/package/kinesis/compileMethodsToKinesis.js +++ b/lib/package/kinesis/compileMethodsToKinesis.js @@ -24,8 +24,8 @@ module.exports = { _.merge( template, - await this.getKinesisMethodIntegration(event.http), - await this.getMethodResponses(event.http) + this.getKinesisMethodIntegration(event.http), + this.getMethodResponses(event.http) ) const methodLogicalId = this.provider.naming.getMethodLogicalId( @@ -44,7 +44,7 @@ module.exports = { return BbPromise.resolve() }, - async getKinesisMethodIntegration(http) { + getKinesisMethodIntegration(http) { const integration = { IntegrationHttpMethod: 'POST', Type: 'AWS', @@ -64,7 +64,7 @@ module.exports = { ] }, PassthroughBehavior: 'NEVER', - RequestTemplates: await this.getKinesisIntegrationRequestTemplates(http) + RequestTemplates: this.getKinesisIntegrationRequestTemplates(http) } const integrationResponse = { @@ -106,19 +106,19 @@ module.exports = { } }, - async getKinesisIntegrationRequestTemplates(http) { - const defaultRequestTemplates = await this.getDefaultKinesisRequestTemplates(http) + getKinesisIntegrationRequestTemplates(http) { + const defaultRequestTemplates = this.getDefaultKinesisRequestTemplates(http) return Object.assign(defaultRequestTemplates, _.get(http, ['request', 'template'])) }, - async getDefaultKinesisRequestTemplates(http) { + getDefaultKinesisRequestTemplates(http) { return { - 'application/json': await this.buildDefaultKinesisRequestTemplate(http), - 'application/x-www-form-urlencoded': await this.buildDefaultKinesisRequestTemplate(http) + 'application/json': this.buildDefaultKinesisRequestTemplate(http), + 'application/x-www-form-urlencoded': this.buildDefaultKinesisRequestTemplate(http) } }, - async buildDefaultKinesisRequestTemplate(http) { + buildDefaultKinesisRequestTemplate(http) { let streamName if (typeof http.streamName == 'object') { streamName = http.streamName diff --git a/lib/package/sqs/compileMethodsToSqs.js b/lib/package/sqs/compileMethodsToSqs.js index 520b4c0..2aaf331 100644 --- a/lib/package/sqs/compileMethodsToSqs.js +++ b/lib/package/sqs/compileMethodsToSqs.js @@ -5,7 +5,7 @@ const _ = require('lodash') module.exports = { async compileMethodsToSqs() { - this.validated.events.forEach(async (event) => { + this.validated.events.forEach((event) => { if (event.serviceName == 'sqs') { const resourceId = this.getResourceId(event.http.path) const resourceName = this.getResourceName(event.http.path) @@ -24,8 +24,8 @@ module.exports = { _.merge( template, - await this.getSqsMethodIntegration(event.http), - await this.getMethodResponses(event.http) + this.getSqsMethodIntegration(event.http), + this.getMethodResponses(event.http) ) const methodLogicalId = this.provider.naming.getMethodLogicalId( @@ -44,7 +44,7 @@ module.exports = { return BbPromise.resolve() }, - async getSqsMethodIntegration(http) { + getSqsMethodIntegration(http) { let queueName = http.queueName if (typeof http.queueName == 'string') { queueName = `"${queueName}"` @@ -102,7 +102,7 @@ module.exports = { origin = http.cors.origins.join(',') } - integrationResponse.IntegrationResponses.forEach(async (val, i) => { + integrationResponse.IntegrationResponses.forEach((val, i) => { integrationResponse.IntegrationResponses[i].ResponseParameters = { 'method.response.header.Access-Control-Allow-Origin': `'${origin}'` } From 183ae59a9377a6553c874f88041f15536bbfc1d7 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sat, 17 Aug 2019 09:52:59 +0200 Subject: [PATCH 05/19] feat: add compile methods to S3 --- lib/package/s3/compileMethodsToS3.js | 167 ++++++++++ lib/package/s3/compileMethodsToS3.test.js | 356 ++++++++++++++++++++++ 2 files changed, 523 insertions(+) create mode 100644 lib/package/s3/compileMethodsToS3.js create mode 100644 lib/package/s3/compileMethodsToS3.test.js diff --git a/lib/package/s3/compileMethodsToS3.js b/lib/package/s3/compileMethodsToS3.js new file mode 100644 index 0000000..39e3e0e --- /dev/null +++ b/lib/package/s3/compileMethodsToS3.js @@ -0,0 +1,167 @@ +'use strict' + +const _ = require('lodash') + +module.exports = { + compileMethodsToS3() { + this.validated.events.forEach((event) => { + if (event.serviceName == 's3') { + const resourceId = this.getResourceId(event.http.path) + const resourceName = this.getResourceName(event.http.path) + + const template = { + Type: 'AWS::ApiGateway::Method', + Properties: { + HttpMethod: event.http.method.toUpperCase(), + RequestParameters: {}, + AuthorizationType: 'NONE', + ApiKeyRequired: Boolean(event.http.private), + ResourceId: resourceId, + RestApiId: this.provider.getApiGatewayRestApiId() + } + } + + _.merge( + template, + this.getS3MethodIntegration(event.http), + this.getMethodResponses(event.http) + ) + + const methodLogicalId = this.provider.naming.getMethodLogicalId( + resourceName, + event.http.method + ) + + this.apiGatewayMethodLogicalIds.push(methodLogicalId) + + _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, { + [methodLogicalId]: template + }) + } + }) + }, + + getIntegrationHttpMethod(http) { + switch (http.action) { + case 'GetObject': + return 'GET' + case 'PutObject': + return 'PUT' + case 'DeleteObject': + return 'DELETE' + } + }, + + getObjectRequestParameter(http) { + if (http.key.pathParam) { + return `method.request.path.${http.key.pathParam}` + } + + if (http.key.queryStringParam) { + return `method.request.querystring.${http.key.queryStringParam}` + } + + return http.key + }, + + getRequestParameters(http) { + switch (http.action) { + case 'GetObject': + return {} + case 'PutObject': + return { + 'integration.request.header.x-amz-acl': "'authenticated-read'", + 'integration.request.header.Content-Type': 'method.request.header.Content-Type' + } + case 'DeleteObject': + return {} + } + }, + + getResponseParameters(http) { + switch (http.action) { + case 'GetObject': + return { + 'method.response.header.content-type': 'integration.response.header.content-type', + 'method.response.header.Content-Type': 'integration.response.header.Content-Type' + } + case 'PutObject': + return { + 'method.response.header.Content-Type': 'integration.response.header.Content-Type', + 'method.response.header.Content-Length': 'integration.response.header.Content-Length' + } + case 'DeleteObject': + return { + 'method.response.header.Content-Type': 'integration.response.header.Content-Type', + 'method.response.header.Date': 'integration.response.header.Date' + } + } + }, + + getS3MethodIntegration(http) { + const bucket = http.bucket + const httpMethod = this.getIntegrationHttpMethod(http) + const objectRequestParam = this.getObjectRequestParameter(http) + const requestParams = _.merge(this.getRequestParameters(http), { + 'integration.request.path.object': objectRequestParam, + 'integration.request.path.bucket': bucket + }) + const responseParams = this.getResponseParameters(http) + + const integration = { + IntegrationHttpMethod: httpMethod, + Type: 'AWS', + Credentials: { + 'Fn::GetAtt': ['ApigatewayToS3Role', 'Arn'] + }, + Uri: { + 'Fn::Sub': ['arn:aws:apigateway:${AWS::Region}:s3:path/{bucket}/{object}', {}] + }, + PassthroughBehavior: 'NEVER', + RequestParameters: requestParams + } + + const integrationResponse = { + IntegrationResponses: [ + { + StatusCode: 400, + SelectionPattern: '4\\d{2}', + ResponseParameters: {}, + ResponseTemplates: {} + }, + { + StatusCode: 500, + SelectionPattern: '5\\d{2}', + ResponseParameters: {}, + ResponseTemplates: {} + }, + { + StatusCode: 200, + SelectionPattern: '200', + ResponseParameters: responseParams, + ResponseTemplates: {} + } + ] + } + + if (http && http.cors) { + let origin = http.cors.origin + if (http.cors.origins && http.cors.origins.length) { + origin = http.cors.origins.join(',') + } + + const corsKey = 'method.response.header.Access-Control-Allow-Origin' + integrationResponse.IntegrationResponses.forEach((val, i) => { + integrationResponse.IntegrationResponses[i].ResponseParameters[corsKey] = `'${origin}'` + }) + } + + _.merge(integration, integrationResponse) + + return { + Properties: { + Integration: integration + } + } + } +} diff --git a/lib/package/s3/compileMethodsToS3.test.js b/lib/package/s3/compileMethodsToS3.test.js new file mode 100644 index 0000000..e5b4dcb --- /dev/null +++ b/lib/package/s3/compileMethodsToS3.test.js @@ -0,0 +1,356 @@ +'use strict' + +const _ = require('lodash') +const Serverless = require('serverless/lib/Serverless') +const AwsProvider = require('serverless/lib/plugins/aws/provider/awsProvider') +const ServerlessApigatewayServiceProxy = require('./../../index') + +const expect = require('chai').expect + +const template = { + Type: 'AWS::ApiGateway::Method', + Properties: { + RequestParameters: {}, + AuthorizationType: 'NONE', + ApiKeyRequired: false, + ResourceId: { Ref: 'ApiGatewayResourceS3' }, + RestApiId: { Ref: 'ApiGatewayRestApi' }, + Integration: { + Type: 'AWS', + Credentials: { 'Fn::GetAtt': ['ApigatewayToS3Role', 'Arn'] }, + Uri: { + 'Fn::Sub': ['arn:aws:apigateway:${AWS::Region}:s3:path/{bucket}/{object}', {}] + }, + PassthroughBehavior: 'NEVER', + RequestParameters: {}, + IntegrationResponses: [ + { + StatusCode: 400, + SelectionPattern: '4\\d{2}', + ResponseParameters: {}, + ResponseTemplates: {} + }, + { + StatusCode: 500, + SelectionPattern: '5\\d{2}', + ResponseParameters: {}, + ResponseTemplates: {} + }, + { + StatusCode: 200, + SelectionPattern: '200', + ResponseParameters: {}, + ResponseTemplates: {} + } + ] + }, + MethodResponses: [ + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 200 }, + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 }, + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 500 } + ] + } +} + +describe('#compileMethodsToS3()', () => { + let serverless + let serverlessApigatewayServiceProxy + + beforeEach(() => { + serverless = new Serverless() + serverless.servicePath = true + serverless.service.service = 'apigw-service-proxy' + const options = { + stage: 'dev', + region: 'us-east-1' + } + serverless.setProvider('aws', new AwsProvider(serverless)) + serverless.service.provider.compiledCloudFormationTemplate = { Resources: {} } + serverlessApigatewayServiceProxy = new ServerlessApigatewayServiceProxy(serverless, options) + }) + + const testSingleProxy = (http, logicalId, method, intMethod, requestParams, responseParams) => { + serverlessApigatewayServiceProxy.validated = { + events: [ + { + serviceName: 's3', + http + } + ] + } + serverlessApigatewayServiceProxy.apiGatewayRestApiLogicalId = 'ApiGatewayRestApi' + serverlessApigatewayServiceProxy.apiGatewayResources = { + s3: { + name: 's3', + resourceLogicalId: 'ApiGatewayResourceS3' + } + } + + serverlessApigatewayServiceProxy.compileMethodsToS3() + + const diff = { + Properties: { + HttpMethod: method, + Integration: { + IntegrationHttpMethod: intMethod, + RequestParameters: requestParams, + IntegrationResponses: [ + { + StatusCode: 400, + SelectionPattern: '4\\d{2}', + ResponseParameters: {}, + ResponseTemplates: {} + }, + { + StatusCode: 500, + SelectionPattern: '5\\d{2}', + ResponseParameters: {}, + ResponseTemplates: {} + }, + { + StatusCode: 200, + SelectionPattern: '200', + ResponseParameters: responseParams, + ResponseTemplates: {} + } + ] + } + } + } + const resource = _.merge({}, template, diff) + expect(serverless.service.provider.compiledCloudFormationTemplate.Resources).to.deep.equal({ + [logicalId]: resource + }) + } + + const testGetObject = (key, pathRequestParam) => { + const http = { + path: 's3', + method: 'get', + bucket: { + Ref: 'MyBucket' + }, + action: 'GetObject', + key + } + + const requestParams = { + 'integration.request.path.object': pathRequestParam, + 'integration.request.path.bucket': { Ref: 'MyBucket' } + } + + const responseParams = { + 'method.response.header.content-type': 'integration.response.header.content-type', + 'method.response.header.Content-Type': 'integration.response.header.Content-Type' + } + + testSingleProxy(http, 'ApiGatewayMethods3Get', 'GET', 'GET', requestParams, responseParams) + } + + it('should create corresponding resources when s3 GetObject proxy is given with a path key', () => { + testGetObject({ pathParam: 'key' }, 'method.request.path.key') + }) + + it('should create corresponding resources when s3 GetObject proxy is given with a query string key', () => { + testGetObject({ queryStringParam: 'key' }, 'method.request.querystring.key') + }) + + it('should create corresponding resources when s3 GetObject proxy is given with a static key', () => { + testGetObject('myKey', 'myKey') + }) + + const testPutObject = (key, pathRequestParam) => { + const http = { + path: 's3', + method: 'post', + bucket: { + Ref: 'MyBucket' + }, + action: 'PutObject', + key + } + + const requestParams = { + 'integration.request.path.object': pathRequestParam, + 'integration.request.path.bucket': { Ref: 'MyBucket' }, + 'integration.request.header.x-amz-acl': "'authenticated-read'", + 'integration.request.header.Content-Type': 'method.request.header.Content-Type' + } + + const responseParams = { + 'method.response.header.Content-Type': 'integration.response.header.Content-Type', + 'method.response.header.Content-Length': 'integration.response.header.Content-Length' + } + + testSingleProxy(http, 'ApiGatewayMethods3Post', 'POST', 'PUT', requestParams, responseParams) + } + + it('should create corresponding resources when s3 PutObject proxy is given with a path key', () => { + testPutObject({ pathParam: 'key' }, 'method.request.path.key') + }) + + it('should create corresponding resources when s3 PutObject proxy is given with a query string key', () => { + testPutObject({ queryStringParam: 'key' }, 'method.request.querystring.key') + }) + + it('should create corresponding resources when s3 PutObject proxy is given with a static key', () => { + testPutObject('myKey', 'myKey') + }) + + const testDeleteObject = (key, pathRequestParam) => { + const http = { + path: 's3', + method: 'delete', + bucket: { + Ref: 'MyBucket' + }, + action: 'DeleteObject', + key + } + + const requestParams = { + 'integration.request.path.object': pathRequestParam, + 'integration.request.path.bucket': { Ref: 'MyBucket' } + } + + const responseParams = { + 'method.response.header.Content-Type': 'integration.response.header.Content-Type', + 'method.response.header.Date': 'integration.response.header.Date' + } + + testSingleProxy( + http, + 'ApiGatewayMethods3Delete', + 'DELETE', + 'DELETE', + requestParams, + responseParams + ) + } + + it('should create corresponding resources when s3 DeleteObject proxy is given with a path key', () => { + testDeleteObject({ pathParam: 'key' }, 'method.request.path.key') + }) + + it('should create corresponding resources when s3 DeleteObject proxy is given with a query string key', () => { + testDeleteObject({ queryStringParam: 'key' }, 'method.request.querystring.key') + }) + + it('should create corresponding resources when s3 DeleteObject proxy is given with a static key', () => { + testDeleteObject('myKey', 'myKey') + }) + + it('should create corresponding resources when a s3 proxy is given with cors', async () => { + serverlessApigatewayServiceProxy.validated = { + events: [ + { + serviceName: 's3', + http: { + path: 's3', + method: 'post', + bucket: { + Ref: 'MyBucket' + }, + action: 'PutObject', + key: { + pathParam: 'key' + }, + cors: { + origins: ['*'], + origin: '*', + methods: ['OPTIONS', 'POST'], + headers: [ + 'Content-Type', + 'X-Amz-Date', + 'Authorization', + 'X-Api-Key', + 'X-Amz-Security-Token', + 'X-Amz-User-Agent' + ], + allowCredentials: false + } + } + } + ] + } + serverlessApigatewayServiceProxy.apiGatewayRestApiLogicalId = 'ApiGatewayRestApi' + serverlessApigatewayServiceProxy.apiGatewayResources = { + s3: { + name: 's3', + resourceLogicalId: 'ApiGatewayResourceS3' + } + } + + serverlessApigatewayServiceProxy.compileMethodsToS3() + expect(serverless.service.provider.compiledCloudFormationTemplate.Resources).to.deep.equal({ + ApiGatewayMethods3Post: { + Type: 'AWS::ApiGateway::Method', + Properties: { + HttpMethod: 'POST', + RequestParameters: {}, + AuthorizationType: 'NONE', + ApiKeyRequired: false, + ResourceId: { Ref: 'ApiGatewayResourceS3' }, + RestApiId: { Ref: 'ApiGatewayRestApi' }, + Integration: { + Type: 'AWS', + IntegrationHttpMethod: 'PUT', + Credentials: { 'Fn::GetAtt': ['ApigatewayToS3Role', 'Arn'] }, + Uri: { + 'Fn::Sub': ['arn:aws:apigateway:${AWS::Region}:s3:path/{bucket}/{object}', {}] + }, + PassthroughBehavior: 'NEVER', + RequestParameters: { + 'integration.request.header.Content-Type': 'method.request.header.Content-Type', + 'integration.request.header.x-amz-acl': "'authenticated-read'", + 'integration.request.path.bucket': { Ref: 'MyBucket' }, + 'integration.request.path.object': 'method.request.path.key' + }, + IntegrationResponses: [ + { + StatusCode: 400, + SelectionPattern: '4\\d{2}', + ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, + ResponseTemplates: {} + }, + { + StatusCode: 500, + SelectionPattern: '5\\d{2}', + ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, + ResponseTemplates: {} + }, + { + StatusCode: 200, + SelectionPattern: '200', + ResponseParameters: { + 'method.response.header.Content-Type': 'integration.response.header.Content-Type', + 'method.response.header.Content-Length': + 'integration.response.header.Content-Length', + 'method.response.header.Access-Control-Allow-Origin': "'*'" + }, + ResponseTemplates: {} + } + ] + }, + MethodResponses: [ + { + ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, + ResponseModels: {}, + StatusCode: 200 + }, + { + ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, + ResponseModels: {}, + StatusCode: 400 + }, + { + ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, + ResponseModels: {}, + StatusCode: 500 + } + ] + } + } + }) + }) +}) From 923b40ddf9e764209d8bc6f7126099c146302eb5 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sat, 17 Aug 2019 09:53:50 +0200 Subject: [PATCH 06/19] feat: add 500 response mapping --- lib/apiGateway/methods.js | 5 +++++ lib/package/kinesis/compileMethodsToKinesis.test.js | 8 +++++++- lib/package/sqs/compileMethodsToSqs.test.js | 10 ++++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/lib/apiGateway/methods.js b/lib/apiGateway/methods.js index 9a031f5..a17c970 100644 --- a/lib/apiGateway/methods.js +++ b/lib/apiGateway/methods.js @@ -14,6 +14,11 @@ module.exports = { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 + }, + { + ResponseParameters: {}, + ResponseModels: {}, + StatusCode: 500 } ] } diff --git a/lib/package/kinesis/compileMethodsToKinesis.test.js b/lib/package/kinesis/compileMethodsToKinesis.test.js index 7ed03af..95bbd40 100644 --- a/lib/package/kinesis/compileMethodsToKinesis.test.js +++ b/lib/package/kinesis/compileMethodsToKinesis.test.js @@ -118,7 +118,8 @@ describe('#compileMethodsToKinesis()', () => { }, MethodResponses: [ { ResponseParameters: {}, ResponseModels: {}, StatusCode: 200 }, - { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 } + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 }, + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 500 } ] } } @@ -238,6 +239,11 @@ describe('#compileMethodsToKinesis()', () => { ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, ResponseModels: {}, StatusCode: 400 + }, + { + ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, + ResponseModels: {}, + StatusCode: 500 } ] } diff --git a/lib/package/sqs/compileMethodsToSqs.test.js b/lib/package/sqs/compileMethodsToSqs.test.js index d4db5de..564adf8 100644 --- a/lib/package/sqs/compileMethodsToSqs.test.js +++ b/lib/package/sqs/compileMethodsToSqs.test.js @@ -8,7 +8,7 @@ const ServerlessApigatewayServiceProxy = require('./../../index') chai.use(require('chai-as-promised')) const expect = require('chai').expect -describe('#compileIamRoleToSqs()', () => { +describe('#compileMethodsToSqs()', () => { let serverless let serverlessApigatewayServiceProxy @@ -96,7 +96,8 @@ describe('#compileIamRoleToSqs()', () => { }, MethodResponses: [ { ResponseParameters: {}, ResponseModels: {}, StatusCode: 200 }, - { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 } + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 }, + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 500 } ] } } @@ -196,6 +197,11 @@ describe('#compileIamRoleToSqs()', () => { ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, ResponseModels: {}, StatusCode: 400 + }, + { + ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, + ResponseModels: {}, + StatusCode: 500 } ] } From ff3f89d8a4e44f005a70946c41f11828123e9711 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sat, 17 Aug 2019 09:54:47 +0200 Subject: [PATCH 07/19] feat: add s3 proxy to index --- lib/index.js | 12 ++++++ lib/index.test.js | 11 ++++++ lib/package/s3/compileS3ServiceProxy.js | 9 +++++ package-lock.json | 51 ++++++++++++++++++++++--- package.json | 1 + 5 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 lib/package/s3/compileS3ServiceProxy.js diff --git a/lib/index.js b/lib/index.js index 93e9e47..5d564cc 100644 --- a/lib/index.js +++ b/lib/index.js @@ -21,6 +21,11 @@ const compileMethodsToSqs = require('./package/sqs/compileMethodsToSqs') const compileIamRoleToSqs = require('./package/sqs/compileIamRoleToSqs') const validateSqsServiceProxy = require('./package/sqs/validateSqsServiceProxy') const compileSqsServiceProxy = require('./package/sqs/compileSqsServiceProxy') +// S3 +const compileMethodsToS3 = require('./package/s3/compileMethodsToS3') +const compileIamRoleToS3 = require('./package/s3/compileIamRoleToS3') +const validateS3ServiceProxy = require('./package/s3/validateS3ServiceProxy') +const compileS3ServiceProxy = require('./package/s3/compileS3ServiceProxy') class ServerlessApigatewayServiceProxy { constructor(serverless, options) { @@ -45,6 +50,10 @@ class ServerlessApigatewayServiceProxy { compileIamRoleToSqs, compileSqsServiceProxy, validateSqsServiceProxy, + compileMethodsToS3, + compileIamRoleToS3, + compileS3ServiceProxy, + validateS3ServiceProxy, getStackInfo, validate, methods, @@ -66,6 +75,9 @@ class ServerlessApigatewayServiceProxy { // SQS getProxy await this.compileSqsServiceProxy() + // S3 getProxy + await this.compileS3ServiceProxy() + await this.mergeDeployment() } }, diff --git a/lib/index.test.js b/lib/index.test.js index b1060bc..76dc69b 100644 --- a/lib/index.test.js +++ b/lib/index.test.js @@ -64,6 +64,12 @@ describe('#index()', () => { path: '/sqs', method: 'post' } + }, + { + s3: { + path: '/s3', + method: 'post' + } } ] } @@ -86,6 +92,9 @@ describe('#index()', () => { const compileSqsServiceProxyStub = sinon .stub(serverlessApigatewayServiceProxy, 'compileSqsServiceProxy') .returns(BbPromise.resolve()) + const compileS3ServiceProxyStub = sinon + .stub(serverlessApigatewayServiceProxy, 'compileS3ServiceProxy') + .returns(BbPromise.resolve()) const mergeDeploymentStub = sinon .stub(serverlessApigatewayServiceProxy, 'mergeDeployment') .returns(BbPromise.resolve()) @@ -99,6 +108,7 @@ describe('#index()', () => { expect(compileCorsStub.calledOnce).to.be.equal(true) expect(compileKinesisServiceProxyStub.calledOnce).to.be.equal(true) expect(compileSqsServiceProxyStub.calledOnce).to.be.equal(true) + expect(compileS3ServiceProxyStub.calledOnce).to.be.equal(true) expect(mergeDeploymentStub.calledOnce).to.be.equal(true) }) @@ -108,6 +118,7 @@ describe('#index()', () => { serverlessApigatewayServiceProxy.compileCors.restore() serverlessApigatewayServiceProxy.compileKinesisServiceProxy.restore() serverlessApigatewayServiceProxy.compileSqsServiceProxy.restore() + serverlessApigatewayServiceProxy.compileS3ServiceProxy.restore() serverlessApigatewayServiceProxy.mergeDeployment.restore() }) diff --git a/lib/package/s3/compileS3ServiceProxy.js b/lib/package/s3/compileS3ServiceProxy.js new file mode 100644 index 0000000..687fb72 --- /dev/null +++ b/lib/package/s3/compileS3ServiceProxy.js @@ -0,0 +1,9 @@ +'use strict' + +module.exports = { + async compileS3ServiceProxy() { + this.validateS3ServiceProxy() + this.compileIamRoleToS3() + this.compileMethodsToS3() + } +} diff --git a/package-lock.json b/package-lock.json index d45912d..fa2f280 100644 --- a/package-lock.json +++ b/package-lock.json @@ -207,6 +207,47 @@ "minimist": "^1.2.0" } }, + "@hapi/address": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-2.0.0.tgz", + "integrity": "sha512-mV6T0IYqb0xL1UALPFplXYQmR0twnXG0M6jUswpquqT2sD12BOiCiLy3EvMp/Fy7s3DZElC4/aPjEjo2jeZpvw==" + }, + "@hapi/hoek": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-6.2.4.tgz", + "integrity": "sha512-HOJ20Kc93DkDVvjwHyHawPwPkX44sIrbXazAUDiUXaY2R9JwQGo2PhFfnQtdrsIe4igjG2fPgMra7NYw7qhy0A==" + }, + "@hapi/joi": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-15.1.0.tgz", + "integrity": "sha512-n6kaRQO8S+kepUTbXL9O/UOL788Odqs38/VOfoCrATDtTvyfiO3fgjlSRaNkHabpTLgM7qru9ifqXlXbXk8SeQ==", + "requires": { + "@hapi/address": "2.x.x", + "@hapi/hoek": "6.x.x", + "@hapi/marker": "1.x.x", + "@hapi/topo": "3.x.x" + } + }, + "@hapi/marker": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@hapi/marker/-/marker-1.0.0.tgz", + "integrity": "sha512-JOfdekTXnJexfE8PyhZFyHvHjt81rBFSAbTIRAhF2vv/2Y1JzoKsGqxH/GpZJoF7aEfYok8JVcAHmSz1gkBieA==" + }, + "@hapi/topo": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-3.1.3.tgz", + "integrity": "sha512-JmS9/vQK6dcUYn7wc2YZTqzIKubAQcJKu2KCKAru6es482U5RT5fP1EXCPtlXpiK7PR0On/kpQKI4fRKkzpZBQ==", + "requires": { + "@hapi/hoek": "8.x.x" + }, + "dependencies": { + "@hapi/hoek": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-8.2.1.tgz", + "integrity": "sha512-JPiBy+oSmsq3St7XlipfN5pNA6bDJ1kpa73PrK/zR29CVClDVqy04AanM/M/qx5bSF+I61DdCfAvRrujau+zRg==" + } + } + }, "@jest/console": { "version": "24.7.1", "resolved": "https://registry.npmjs.org/@jest/console/-/console-24.7.1.tgz", @@ -9591,6 +9632,11 @@ "version": "5.7.0", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==" + }, + "uuid": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.3.tgz", + "integrity": "sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=" } } }, @@ -10921,11 +10967,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, - "uuid": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.3.tgz", - "integrity": "sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=" - }, "validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", diff --git a/package.json b/package.json index 6733c2a..5c2e09a 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ ] }, "dependencies": { + "@hapi/joi": "^15.1.0", "serverless": "^1.48.2" }, "author": "horike37", From 0122a965982bd967ff5749fe0785599643a154bf Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sat, 17 Aug 2019 09:55:11 +0200 Subject: [PATCH 08/19] chore: add .vscode to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index fb6f4fc..5db3250 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ tmp tmpdirs-serverless .eslintcache .serverless +.vscode \ No newline at end of file From ca8dcf0d8ee6a4e03e717698838eb5a6cc899ecd Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sat, 17 Aug 2019 23:49:19 +0200 Subject: [PATCH 09/19] feat: kinesis and sqs covers 500 response --- .../kinesis/compileMethodsToKinesis.js | 6 +++ .../kinesis/compileMethodsToKinesis.test.js | 30 +++++++++++++- lib/package/sqs/compileMethodsToSqs.js | 6 +++ lib/package/sqs/compileMethodsToSqs.test.js | 39 +++++++++++++++++-- 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/lib/package/kinesis/compileMethodsToKinesis.js b/lib/package/kinesis/compileMethodsToKinesis.js index 522284e..336d1f7 100644 --- a/lib/package/kinesis/compileMethodsToKinesis.js +++ b/lib/package/kinesis/compileMethodsToKinesis.js @@ -82,6 +82,12 @@ module.exports = { SelectionPattern: 400, ResponseParameters: {}, ResponseTemplates: {} + }, + { + StatusCode: 500, + SelectionPattern: 500, + ResponseParameters: {}, + ResponseTemplates: {} } ] } diff --git a/lib/package/kinesis/compileMethodsToKinesis.test.js b/lib/package/kinesis/compileMethodsToKinesis.test.js index b7a3aa8..0b12520 100644 --- a/lib/package/kinesis/compileMethodsToKinesis.test.js +++ b/lib/package/kinesis/compileMethodsToKinesis.test.js @@ -118,6 +118,12 @@ describe('#compileMethodsToKinesis()', () => { SelectionPattern: 400, ResponseParameters: {}, ResponseTemplates: {} + }, + { + StatusCode: 500, + SelectionPattern: 500, + ResponseParameters: {}, + ResponseTemplates: {} } ] }, @@ -236,6 +242,12 @@ describe('#compileMethodsToKinesis()', () => { SelectionPattern: 400, ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, ResponseTemplates: {} + }, + { + StatusCode: 500, + SelectionPattern: 500, + ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, + ResponseTemplates: {} } ] }, @@ -491,12 +503,19 @@ describe('#compileMethodsToKinesis()', () => { SelectionPattern: 400, ResponseParameters: {}, ResponseTemplates: {} + }, + { + StatusCode: 500, + SelectionPattern: 500, + ResponseParameters: {}, + ResponseTemplates: {} } ] }, MethodResponses: [ { ResponseParameters: {}, ResponseModels: {}, StatusCode: 200 }, - { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 } + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 }, + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 500 } ] } } @@ -595,12 +614,19 @@ describe('#compileMethodsToKinesis()', () => { SelectionPattern: 400, ResponseParameters: {}, ResponseTemplates: {} + }, + { + StatusCode: 500, + SelectionPattern: 500, + ResponseParameters: {}, + ResponseTemplates: {} } ] }, MethodResponses: [ { ResponseParameters: {}, ResponseModels: {}, StatusCode: 200 }, - { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 } + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 }, + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 500 } ] } } diff --git a/lib/package/sqs/compileMethodsToSqs.js b/lib/package/sqs/compileMethodsToSqs.js index 8149bae..d15f6ee 100644 --- a/lib/package/sqs/compileMethodsToSqs.js +++ b/lib/package/sqs/compileMethodsToSqs.js @@ -97,6 +97,12 @@ module.exports = { SelectionPattern: 400, ResponseParameters: {}, ResponseTemplates: {} + }, + { + StatusCode: 500, + SelectionPattern: 500, + ResponseParameters: {}, + ResponseTemplates: {} } ] } diff --git a/lib/package/sqs/compileMethodsToSqs.test.js b/lib/package/sqs/compileMethodsToSqs.test.js index 7058a3b..69cb09a 100644 --- a/lib/package/sqs/compileMethodsToSqs.test.js +++ b/lib/package/sqs/compileMethodsToSqs.test.js @@ -96,6 +96,12 @@ describe('#compileMethodsToSqs()', () => { SelectionPattern: 400, ResponseParameters: {}, ResponseTemplates: {} + }, + { + StatusCode: 500, + SelectionPattern: 500, + ResponseParameters: {}, + ResponseTemplates: {} } ] }, @@ -194,6 +200,12 @@ describe('#compileMethodsToSqs()', () => { SelectionPattern: 400, ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, ResponseTemplates: {} + }, + { + StatusCode: 500, + SelectionPattern: 500, + ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, + ResponseTemplates: {} } ] }, @@ -291,12 +303,19 @@ describe('#compileMethodsToSqs()', () => { SelectionPattern: 400, ResponseParameters: {}, ResponseTemplates: {} + }, + { + StatusCode: 500, + SelectionPattern: 500, + ResponseParameters: {}, + ResponseTemplates: {} } ] }, MethodResponses: [ { ResponseParameters: {}, ResponseModels: {}, StatusCode: 200 }, - { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 } + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 }, + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 500 } ] } } @@ -375,12 +394,19 @@ describe('#compileMethodsToSqs()', () => { SelectionPattern: 400, ResponseParameters: {}, ResponseTemplates: {} + }, + { + StatusCode: 500, + SelectionPattern: 500, + ResponseParameters: {}, + ResponseTemplates: {} } ] }, MethodResponses: [ { ResponseParameters: {}, ResponseModels: {}, StatusCode: 200 }, - { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 } + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 }, + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 500 } ] } } @@ -461,12 +487,19 @@ describe('#compileMethodsToSqs()', () => { SelectionPattern: 400, ResponseParameters: {}, ResponseTemplates: {} + }, + { + StatusCode: 500, + SelectionPattern: 500, + ResponseParameters: {}, + ResponseTemplates: {} } ] }, MethodResponses: [ { ResponseParameters: {}, ResponseModels: {}, StatusCode: 200 }, - { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 } + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 400 }, + { ResponseParameters: {}, ResponseModels: {}, StatusCode: 500 } ] } } From a9ff5db37567a0a88fdcea5345faea3eddcec432 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sat, 17 Aug 2019 23:49:55 +0200 Subject: [PATCH 10/19] fix: s3 perm requires ARN not bucket name --- lib/package/s3/compileIamRoleToS3.js | 14 +++++++++++++- lib/package/s3/compileIamRoleToS3.test.js | 8 ++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/lib/package/s3/compileIamRoleToS3.js b/lib/package/s3/compileIamRoleToS3.js index 34fa59e..8424cb4 100644 --- a/lib/package/s3/compileIamRoleToS3.js +++ b/lib/package/s3/compileIamRoleToS3.js @@ -2,6 +2,18 @@ const _ = require('lodash') +function convertToArn(bucket) { + // bucket can be either a Ref, or a string (bucket name) + if (bucket.Ref) { + const logicalId = bucket.Ref + return { + 'Fn::GetAtt': [logicalId, 'Arn'] + } + } else { + return `arn:aws:s3:::${bucket}` + } +} + module.exports = { compileIamRoleToS3() { const bucketActions = _.flatMap(this.getAllServiceProxies(), (serviceProxy) => { @@ -29,7 +41,7 @@ module.exports = { 'Fn::Sub': [ '${bucket}/*', { - bucket + bucket: convertToArn(bucket) } ] } diff --git a/lib/package/s3/compileIamRoleToS3.test.js b/lib/package/s3/compileIamRoleToS3.test.js index 614a613..983d75e 100644 --- a/lib/package/s3/compileIamRoleToS3.test.js +++ b/lib/package/s3/compileIamRoleToS3.test.js @@ -102,7 +102,7 @@ describe('#compileIamRoleToS3()', () => { 'Fn::Sub': [ '${bucket}/*', { - bucket: 'myBucket' + bucket: 'arn:aws:s3:::myBucket' } ] } @@ -114,7 +114,7 @@ describe('#compileIamRoleToS3()', () => { 'Fn::Sub': [ '${bucket}/*', { - bucket: 'myBucket' + bucket: 'arn:aws:s3:::myBucket' } ] } @@ -127,7 +127,7 @@ describe('#compileIamRoleToS3()', () => { '${bucket}/*', { bucket: { - Ref: 'MyBucket' + 'Fn::GetAtt': ['MyBucket', 'Arn'] } } ] @@ -140,7 +140,7 @@ describe('#compileIamRoleToS3()', () => { 'Fn::Sub': [ '${bucket}/*', { - bucket: 'myBucketV2' + bucket: 'arn:aws:s3:::myBucketV2' } ] } From c9dbb59e4ea6ef13f8f66a0787150fb080a00896 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sat, 17 Aug 2019 23:50:12 +0200 Subject: [PATCH 11/19] feat: include s3 in validate.js --- lib/apiGateway/validate.js | 2 +- lib/apiGateway/validate.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/apiGateway/validate.js b/lib/apiGateway/validate.js index 170d90f..fa0598a 100644 --- a/lib/apiGateway/validate.js +++ b/lib/apiGateway/validate.js @@ -51,7 +51,7 @@ module.exports = { }, async checkAllowedService(serviceName) { - const allowedProxies = ['kinesis', 'sqs'] + const allowedProxies = ['kinesis', 'sqs', 's3'] if (allowedProxies.indexOf(serviceName) === NOT_FOUND) { const errorMessage = [ `Invalid APIG proxy "${serviceName}".`, diff --git a/lib/apiGateway/validate.test.js b/lib/apiGateway/validate.test.js index bb52998..81683b6 100644 --- a/lib/apiGateway/validate.test.js +++ b/lib/apiGateway/validate.test.js @@ -37,7 +37,7 @@ describe('#validateServiceProxies()', () => { } await expect(serverlessApigatewayServiceProxy.validateServiceProxies()).to.be.rejectedWith( - 'Invalid APIG proxy "xxxxx". This plugin supported Proxies are: kinesis, sqs.' + 'Invalid APIG proxy "xxxxx". This plugin supported Proxies are: kinesis, sqs, s3.' ) }) From 9ed8ef31a5a955d77bb45d6228ad4bd115202066 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sat, 17 Aug 2019 23:50:42 +0200 Subject: [PATCH 12/19] fix: remove cors from schema as it's already covered --- lib/package/s3/schema.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/package/s3/schema.js b/lib/package/s3/schema.js index 0744bc2..908222f 100644 --- a/lib/package/s3/schema.js +++ b/lib/package/s3/schema.js @@ -24,8 +24,7 @@ const key = Joi.alternatives().try([ const schema = Joi.object().keys({ action: action.required(), bucket: bucket.required(), - key: key.required(), - cors: Joi.boolean().default(false) + key: key.required() }) module.exports = schema From 4598829dea9dc51ceea05ec266d79650ce6f16b6 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sun, 18 Aug 2019 01:20:06 +0200 Subject: [PATCH 13/19] fix: give Get/Put/DeleteObject* perms --- lib/package/s3/compileIamRoleToS3.js | 2 +- lib/package/s3/compileIamRoleToS3.test.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/package/s3/compileIamRoleToS3.js b/lib/package/s3/compileIamRoleToS3.js index 8424cb4..c2669fa 100644 --- a/lib/package/s3/compileIamRoleToS3.js +++ b/lib/package/s3/compileIamRoleToS3.js @@ -36,7 +36,7 @@ module.exports = { const permissions = bucketActions.map(({ bucket, action }) => { return { Effect: 'Allow', - Action: `s3:${action}`, + Action: `s3:${action}*`, // e.g. PutObject*, GetObject*, DeleteObject* Resource: { 'Fn::Sub': [ '${bucket}/*', diff --git a/lib/package/s3/compileIamRoleToS3.test.js b/lib/package/s3/compileIamRoleToS3.test.js index 983d75e..681f727 100644 --- a/lib/package/s3/compileIamRoleToS3.test.js +++ b/lib/package/s3/compileIamRoleToS3.test.js @@ -97,7 +97,7 @@ describe('#compileIamRoleToS3()', () => { }, { Effect: 'Allow', - Action: 's3:PutObject', + Action: 's3:PutObject*', Resource: { 'Fn::Sub': [ '${bucket}/*', @@ -109,7 +109,7 @@ describe('#compileIamRoleToS3()', () => { }, { Effect: 'Allow', - Action: 's3:GetObject', + Action: 's3:GetObject*', Resource: { 'Fn::Sub': [ '${bucket}/*', @@ -121,7 +121,7 @@ describe('#compileIamRoleToS3()', () => { }, { Effect: 'Allow', - Action: 's3:DeleteObject', + Action: 's3:DeleteObject*', Resource: { 'Fn::Sub': [ '${bucket}/*', @@ -135,7 +135,7 @@ describe('#compileIamRoleToS3()', () => { }, { Effect: 'Allow', - Action: 's3:PutObject', + Action: 's3:PutObject*', Resource: { 'Fn::Sub': [ '${bucket}/*', From 17ac6a235efdffb20d78d5ccc94f57c7497b9914 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sun, 18 Aug 2019 01:46:17 +0200 Subject: [PATCH 14/19] fix: fixed bugs identified by int test --- lib/package/s3/compileMethodsToS3.js | 35 ++++- lib/package/s3/compileMethodsToS3.test.js | 174 ++++++++++++++++++---- 2 files changed, 170 insertions(+), 39 deletions(-) diff --git a/lib/package/s3/compileMethodsToS3.js b/lib/package/s3/compileMethodsToS3.js index 39e3e0e..f1de3a6 100644 --- a/lib/package/s3/compileMethodsToS3.js +++ b/lib/package/s3/compileMethodsToS3.js @@ -27,6 +27,25 @@ module.exports = { this.getMethodResponses(event.http) ) + // ensure every integration request and response param mapping is + // also configured in the method request and response param mappings + Object.values(template.Properties.Integration.RequestParameters) + .filter((x) => typeof x === 'string' && x.startsWith('method.')) + .forEach((x) => { + template.Properties.RequestParameters[x] = true + }) + + template.Properties.Integration.IntegrationResponses.forEach((resp) => { + Object.keys(resp.ResponseParameters) + .filter((x) => x.startsWith('method.')) + .forEach((x) => { + const methodResp = template.Properties.MethodResponses.find( + (y) => y.StatusCode === resp.StatusCode + ) + methodResp.ResponseParameters[x] = true + }) + }) + const methodLogicalId = this.provider.naming.getMethodLogicalId( resourceName, event.http.method @@ -61,10 +80,10 @@ module.exports = { return `method.request.querystring.${http.key.queryStringParam}` } - return http.key + return `'${http.key}'` }, - getRequestParameters(http) { + getIntegrationRequestParameters(http) { switch (http.action) { case 'GetObject': return {} @@ -78,7 +97,7 @@ module.exports = { } }, - getResponseParameters(http) { + getIntegrationResponseParameters(http) { switch (http.action) { case 'GetObject': return { @@ -102,11 +121,13 @@ module.exports = { const bucket = http.bucket const httpMethod = this.getIntegrationHttpMethod(http) const objectRequestParam = this.getObjectRequestParameter(http) - const requestParams = _.merge(this.getRequestParameters(http), { + const requestParams = _.merge(this.getIntegrationRequestParameters(http), { 'integration.request.path.object': objectRequestParam, - 'integration.request.path.bucket': bucket + 'integration.request.path.bucket': { + 'Fn::Sub': ["'${bucket}'", { bucket }] + } }) - const responseParams = this.getResponseParameters(http) + const responseParams = this.getIntegrationResponseParameters(http) const integration = { IntegrationHttpMethod: httpMethod, @@ -117,7 +138,7 @@ module.exports = { Uri: { 'Fn::Sub': ['arn:aws:apigateway:${AWS::Region}:s3:path/{bucket}/{object}', {}] }, - PassthroughBehavior: 'NEVER', + PassthroughBehavior: 'WHEN_NO_MATCH', RequestParameters: requestParams } diff --git a/lib/package/s3/compileMethodsToS3.test.js b/lib/package/s3/compileMethodsToS3.test.js index e5b4dcb..265da67 100644 --- a/lib/package/s3/compileMethodsToS3.test.js +++ b/lib/package/s3/compileMethodsToS3.test.js @@ -21,7 +21,7 @@ const template = { Uri: { 'Fn::Sub': ['arn:aws:apigateway:${AWS::Region}:s3:path/{bucket}/{object}', {}] }, - PassthroughBehavior: 'NEVER', + PassthroughBehavior: 'WHEN_NO_MATCH', RequestParameters: {}, IntegrationResponses: [ { @@ -69,7 +69,18 @@ describe('#compileMethodsToS3()', () => { serverlessApigatewayServiceProxy = new ServerlessApigatewayServiceProxy(serverless, options) }) - const testSingleProxy = (http, logicalId, method, intMethod, requestParams, responseParams) => { + const testSingleProxy = (opts) => { + const { + http, + logicalId, + method, + intMethod, + requestParams, + intRequestParams, + responseParams, + intResponseParams + } = opts + serverlessApigatewayServiceProxy.validated = { events: [ { @@ -91,9 +102,10 @@ describe('#compileMethodsToS3()', () => { const diff = { Properties: { HttpMethod: method, + RequestParameters: requestParams, Integration: { IntegrationHttpMethod: intMethod, - RequestParameters: requestParams, + RequestParameters: intRequestParams, IntegrationResponses: [ { StatusCode: 400, @@ -110,7 +122,7 @@ describe('#compileMethodsToS3()', () => { { StatusCode: 200, SelectionPattern: '200', - ResponseParameters: responseParams, + ResponseParameters: intResponseParams, ResponseTemplates: {} } ] @@ -118,12 +130,15 @@ describe('#compileMethodsToS3()', () => { } } const resource = _.merge({}, template, diff) + const methodResponse = resource.Properties.MethodResponses.find((x) => x.StatusCode === 200) + methodResponse.ResponseParameters = responseParams + expect(serverless.service.provider.compiledCloudFormationTemplate.Resources).to.deep.equal({ [logicalId]: resource }) } - const testGetObject = (key, pathRequestParam) => { + const testGetObject = (key, keyRequestParam) => { const http = { path: 's3', method: 'get', @@ -134,17 +149,45 @@ describe('#compileMethodsToS3()', () => { key } - const requestParams = { - 'integration.request.path.object': pathRequestParam, - 'integration.request.path.bucket': { Ref: 'MyBucket' } + const requestParams = {} + if (keyRequestParam.startsWith('method.')) { + requestParams[keyRequestParam] = true + } + + const intRequestParams = { + 'integration.request.path.object': keyRequestParam, + 'integration.request.path.bucket': { + 'Fn::Sub': [ + "'${bucket}'", + { + bucket: { + Ref: 'MyBucket' + } + } + ] + } } const responseParams = { + 'method.response.header.content-type': true, + 'method.response.header.Content-Type': true + } + + const intResponseParams = { 'method.response.header.content-type': 'integration.response.header.content-type', 'method.response.header.Content-Type': 'integration.response.header.Content-Type' } - testSingleProxy(http, 'ApiGatewayMethods3Get', 'GET', 'GET', requestParams, responseParams) + testSingleProxy({ + http, + logicalId: 'ApiGatewayMethods3Get', + method: 'GET', + intMethod: 'GET', + requestParams, + intRequestParams, + responseParams, + intResponseParams + }) } it('should create corresponding resources when s3 GetObject proxy is given with a path key', () => { @@ -156,10 +199,10 @@ describe('#compileMethodsToS3()', () => { }) it('should create corresponding resources when s3 GetObject proxy is given with a static key', () => { - testGetObject('myKey', 'myKey') + testGetObject('myKey', "'myKey'") }) - const testPutObject = (key, pathRequestParam) => { + const testPutObject = (key, keyRequestParam) => { const http = { path: 's3', method: 'post', @@ -171,18 +214,48 @@ describe('#compileMethodsToS3()', () => { } const requestParams = { - 'integration.request.path.object': pathRequestParam, - 'integration.request.path.bucket': { Ref: 'MyBucket' }, + 'method.request.header.Content-Type': true + } + if (keyRequestParam.startsWith('method.')) { + requestParams[keyRequestParam] = true + } + + const intRequestParams = { + 'integration.request.path.object': keyRequestParam, + 'integration.request.path.bucket': { + 'Fn::Sub': [ + "'${bucket}'", + { + bucket: { + Ref: 'MyBucket' + } + } + ] + }, 'integration.request.header.x-amz-acl': "'authenticated-read'", 'integration.request.header.Content-Type': 'method.request.header.Content-Type' } const responseParams = { + 'method.response.header.Content-Type': true, + 'method.response.header.Content-Length': true + } + + const intResponseParams = { 'method.response.header.Content-Type': 'integration.response.header.Content-Type', 'method.response.header.Content-Length': 'integration.response.header.Content-Length' } - testSingleProxy(http, 'ApiGatewayMethods3Post', 'POST', 'PUT', requestParams, responseParams) + testSingleProxy({ + http, + logicalId: 'ApiGatewayMethods3Post', + method: 'POST', + intMethod: 'PUT', + requestParams, + intRequestParams, + responseParams, + intResponseParams + }) } it('should create corresponding resources when s3 PutObject proxy is given with a path key', () => { @@ -194,10 +267,10 @@ describe('#compileMethodsToS3()', () => { }) it('should create corresponding resources when s3 PutObject proxy is given with a static key', () => { - testPutObject('myKey', 'myKey') + testPutObject('myKey', "'myKey'") }) - const testDeleteObject = (key, pathRequestParam) => { + const testDeleteObject = (key, keyRequestParam) => { const http = { path: 's3', method: 'delete', @@ -208,24 +281,45 @@ describe('#compileMethodsToS3()', () => { key } - const requestParams = { - 'integration.request.path.object': pathRequestParam, - 'integration.request.path.bucket': { Ref: 'MyBucket' } + const requestParams = {} + if (keyRequestParam.startsWith('method.')) { + requestParams[keyRequestParam] = true + } + + const intRequestParams = { + 'integration.request.path.object': keyRequestParam, + 'integration.request.path.bucket': { + 'Fn::Sub': [ + "'${bucket}'", + { + bucket: { + Ref: 'MyBucket' + } + } + ] + } } const responseParams = { + 'method.response.header.Content-Type': true, + 'method.response.header.Date': true + } + + const intResponseParams = { 'method.response.header.Content-Type': 'integration.response.header.Content-Type', 'method.response.header.Date': 'integration.response.header.Date' } - testSingleProxy( + testSingleProxy({ http, - 'ApiGatewayMethods3Delete', - 'DELETE', - 'DELETE', + logicalId: 'ApiGatewayMethods3Delete', + method: 'DELETE', + intMethod: 'DELETE', requestParams, - responseParams - ) + intRequestParams, + responseParams, + intResponseParams + }) } it('should create corresponding resources when s3 DeleteObject proxy is given with a path key', () => { @@ -237,7 +331,7 @@ describe('#compileMethodsToS3()', () => { }) it('should create corresponding resources when s3 DeleteObject proxy is given with a static key', () => { - testDeleteObject('myKey', 'myKey') + testDeleteObject('myKey', "'myKey'") }) it('should create corresponding resources when a s3 proxy is given with cors', async () => { @@ -287,7 +381,10 @@ describe('#compileMethodsToS3()', () => { Type: 'AWS::ApiGateway::Method', Properties: { HttpMethod: 'POST', - RequestParameters: {}, + RequestParameters: { + 'method.request.header.Content-Type': true, + 'method.request.path.key': true + }, AuthorizationType: 'NONE', ApiKeyRequired: false, ResourceId: { Ref: 'ApiGatewayResourceS3' }, @@ -299,11 +396,20 @@ describe('#compileMethodsToS3()', () => { Uri: { 'Fn::Sub': ['arn:aws:apigateway:${AWS::Region}:s3:path/{bucket}/{object}', {}] }, - PassthroughBehavior: 'NEVER', + PassthroughBehavior: 'WHEN_NO_MATCH', RequestParameters: { 'integration.request.header.Content-Type': 'method.request.header.Content-Type', 'integration.request.header.x-amz-acl': "'authenticated-read'", - 'integration.request.path.bucket': { Ref: 'MyBucket' }, + 'integration.request.path.bucket': { + 'Fn::Sub': [ + "'${bucket}'", + { + bucket: { + Ref: 'MyBucket' + } + } + ] + }, 'integration.request.path.object': 'method.request.path.key' }, IntegrationResponses: [ @@ -334,17 +440,21 @@ describe('#compileMethodsToS3()', () => { }, MethodResponses: [ { - ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, + ResponseParameters: { + 'method.response.header.Access-Control-Allow-Origin': true, + 'method.response.header.Content-Type': true, + 'method.response.header.Content-Length': true + }, ResponseModels: {}, StatusCode: 200 }, { - ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, + ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': true }, ResponseModels: {}, StatusCode: 400 }, { - ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': "'*'" }, + ResponseParameters: { 'method.response.header.Access-Control-Allow-Origin': true }, ResponseModels: {}, StatusCode: 500 } From f5c9ce311cf81c4367433773501e600466af3cac Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sun, 18 Aug 2019 09:28:57 +0200 Subject: [PATCH 15/19] test: added single-integration s3 test --- .../single-integration/service/serverless.yml | 32 +++++++++++++ .../s3/single-integration/tests.js | 46 +++++++++++++++++++ __tests__/utils.js | 45 ++++++++++++++---- 3 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 __tests__/integration/s3/single-integration/service/serverless.yml create mode 100644 __tests__/integration/s3/single-integration/tests.js diff --git a/__tests__/integration/s3/single-integration/service/serverless.yml b/__tests__/integration/s3/single-integration/service/serverless.yml new file mode 100644 index 0000000..cae3e64 --- /dev/null +++ b/__tests__/integration/s3/single-integration/service/serverless.yml @@ -0,0 +1,32 @@ +service: s3-proxy + +provider: + name: aws + runtime: nodejs10.x + +plugins: + localPath: './../../../../../../' + modules: + - serverless-apigateway-service-proxy + +custom: + apiGatewayServiceProxies: + - s3: + path: /s3/{key} + method: post + action: PutObject + bucket: + Ref: S3Bucket + key: + pathParam: key + cors: true + +resources: + Resources: + S3Bucket: + Type: 'AWS::S3::Bucket' + + Outputs: + S3BucketName: + Value: + Ref: S3Bucket diff --git a/__tests__/integration/s3/single-integration/tests.js b/__tests__/integration/s3/single-integration/tests.js new file mode 100644 index 0000000..21266eb --- /dev/null +++ b/__tests__/integration/s3/single-integration/tests.js @@ -0,0 +1,46 @@ +'use strict' + +const expect = require('chai').expect +const fetch = require('node-fetch') +const { + deployWithRandomStage, + removeService, + getS3Object, + deleteS3Object +} = require('../../../utils') + +describe('Single S3 Proxy Integration Test', () => { + let endpoint + let stage + let bucket + const config = '__tests__/integration/s3/single-integration/service/serverless.yml' + const key = 'my-test-object.json' + + beforeAll(async () => { + const result = await deployWithRandomStage(config) + + stage = result.stage + endpoint = result.endpoint + bucket = result.outputs.S3BucketName + }) + + afterAll(async () => { + await deleteS3Object(bucket, key) + removeService(stage, config) + }) + + it('should get correct response from s3 proxy endpoint', async () => { + const testEndpoint = `${endpoint}/s3/${key}` + + const response = await fetch(testEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'test' }) + }) + expect(response.headers.get('access-control-allow-origin')).to.deep.equal('*') + expect(response.status).to.be.equal(200) + + const uploadedObject = await getS3Object(bucket, key) + expect(uploadedObject.toString()).to.equal(JSON.stringify({ message: 'test' })) + }) +}) diff --git a/__tests__/utils.js b/__tests__/utils.js index 5e18559..80a1eb1 100644 --- a/__tests__/utils.js +++ b/__tests__/utils.js @@ -6,14 +6,41 @@ const fs = require('fs') const path = require('path') const execSync = require('child_process').execSync const aws = require('aws-sdk') +const s3 = new aws.S3() const cloudformation = new aws.CloudFormation({ region: 'us-east-1' }) -async function getApiGatewayEndpoint(stackName) { +function getApiGatewayEndpoint(outputs) { + return outputs.ServiceEndpoint.match(/https:\/\/.+\.execute-api\..+\.amazonaws\.com.+/)[0] +} + +async function getStackOutputs(stackName) { const result = await cloudformation.describeStacks({ StackName: stackName }).promise() + const stack = result.Stacks[0] + + const keys = stack.Outputs.map((x) => x.OutputKey) + const values = stack.Outputs.map((x) => x.OutputValue) + + return _.zipObject(keys, values) +} + +async function getS3Object(bucket, key) { + const resp = await s3 + .getObject({ + Bucket: bucket, + Key: key + }) + .promise() + + return resp.Body +} - const endpointOutput = _.find(result.Stacks[0].Outputs, { OutputKey: 'ServiceEndpoint' }) - .OutputValue - return endpointOutput.match(/https:\/\/.+\.execute-api\..+\.amazonaws\.com.+/)[0] +async function deleteS3Object(bucket, key) { + await s3 + .deleteObject({ + Bucket: bucket, + Key: key + }) + .promise() } function deployService(stage, config) { @@ -37,14 +64,16 @@ async function deployWithRandomStage(config) { .substring(2) const stackName = `${serviceName}-${stage}` deployService(stage, config) - const endpoint = await getApiGatewayEndpoint(stackName) + const outputs = await getStackOutputs(stackName) + const endpoint = getApiGatewayEndpoint(outputs) - return { stage, endpoint } + return { stackName, stage, outputs, endpoint } } module.exports = { - getApiGatewayEndpoint, deployService, removeService, - deployWithRandomStage + deployWithRandomStage, + getS3Object, + deleteS3Object } From 05760c555fe7a3a57abe40503318c0553a9defd0 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sun, 18 Aug 2019 10:31:55 +0200 Subject: [PATCH 16/19] fix: map 204 to 200 --- lib/package/s3/compileMethodsToS3.js | 2 +- lib/package/s3/compileMethodsToS3.test.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/package/s3/compileMethodsToS3.js b/lib/package/s3/compileMethodsToS3.js index f1de3a6..b4afc15 100644 --- a/lib/package/s3/compileMethodsToS3.js +++ b/lib/package/s3/compileMethodsToS3.js @@ -158,7 +158,7 @@ module.exports = { }, { StatusCode: 200, - SelectionPattern: '200', + SelectionPattern: '2\\d{2}', ResponseParameters: responseParams, ResponseTemplates: {} } diff --git a/lib/package/s3/compileMethodsToS3.test.js b/lib/package/s3/compileMethodsToS3.test.js index 265da67..d820179 100644 --- a/lib/package/s3/compileMethodsToS3.test.js +++ b/lib/package/s3/compileMethodsToS3.test.js @@ -38,7 +38,7 @@ const template = { }, { StatusCode: 200, - SelectionPattern: '200', + SelectionPattern: '2\\d{2}', ResponseParameters: {}, ResponseTemplates: {} } @@ -121,7 +121,7 @@ describe('#compileMethodsToS3()', () => { }, { StatusCode: 200, - SelectionPattern: '200', + SelectionPattern: '2\\d{2}', ResponseParameters: intResponseParams, ResponseTemplates: {} } @@ -427,7 +427,7 @@ describe('#compileMethodsToS3()', () => { }, { StatusCode: 200, - SelectionPattern: '200', + SelectionPattern: '2\\d{2}', ResponseParameters: { 'method.response.header.Content-Type': 'integration.response.header.Content-Type', 'method.response.header.Content-Length': From 894682fb4d7884fd48397adfa4900e9749c28b44 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sun, 18 Aug 2019 10:32:41 +0200 Subject: [PATCH 17/19] test: added multiple proxies s3 int test --- .../service/serverless.yml | 51 ++++++++++++++ .../s3/multiple-integrations/tests.js | 69 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 __tests__/integration/s3/multiple-integrations/service/serverless.yml create mode 100644 __tests__/integration/s3/multiple-integrations/tests.js diff --git a/__tests__/integration/s3/multiple-integrations/service/serverless.yml b/__tests__/integration/s3/multiple-integrations/service/serverless.yml new file mode 100644 index 0000000..a908bfe --- /dev/null +++ b/__tests__/integration/s3/multiple-integrations/service/serverless.yml @@ -0,0 +1,51 @@ +service: multiple-s3-proxy + +provider: + name: aws + runtime: nodejs10.x + +plugins: + localPath: './../../../../../../' + modules: + - serverless-apigateway-service-proxy + +custom: + apiGatewayServiceProxies: + - s3: + path: /s3 + method: post + action: PutObject + bucket: + Ref: S3Bucket + key: my-test-object.json # static key + cors: true + + - s3: + path: /s3/{key} # path param + method: get + action: GetObject + bucket: + Ref: S3Bucket + key: + pathParam: key + cors: true + + - s3: + path: /s3 + method: delete + action: DeleteObject + bucket: + Ref: S3Bucket + key: + queryStringParam: key # query string param + cors: true + +resources: + Resources: + S3Bucket: + Type: 'AWS::S3::Bucket' + + Outputs: + S3BucketName: + Value: + Ref: S3Bucket diff --git a/__tests__/integration/s3/multiple-integrations/tests.js b/__tests__/integration/s3/multiple-integrations/tests.js new file mode 100644 index 0000000..360e173 --- /dev/null +++ b/__tests__/integration/s3/multiple-integrations/tests.js @@ -0,0 +1,69 @@ +'use strict' + +const expect = require('chai').expect +const fetch = require('node-fetch') +const { + deployWithRandomStage, + removeService, + getS3Object, + deleteS3Object +} = require('../../../utils') + +describe('Multiple S3 Proxies Integration Test', () => { + let endpoint + let stage + let bucket + const config = '__tests__/integration/s3/multiple-integrations/service/serverless.yml' + const key = 'my-test-object.json' + + beforeAll(async () => { + const result = await deployWithRandomStage(config) + + stage = result.stage + endpoint = result.endpoint + bucket = result.outputs.S3BucketName + }) + + afterAll(async () => { + await deleteS3Object(bucket, key) + removeService(stage, config) + }) + + it('should get correct response from s3 put endpoint', async () => { + const putEndpoint = `${endpoint}/s3` + + const putResponse = await fetch(putEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'test' }) + }) + expect(putResponse.headers.get('access-control-allow-origin')).to.deep.equal('*') + expect(putResponse.status).to.be.equal(200) + + const uploadedObject = await getS3Object(bucket, key) + expect(uploadedObject.toString()).to.equal(JSON.stringify({ message: 'test' })) + }) + + it('should get correct response from s3 get endpoint', async () => { + const getEndpoint = `${endpoint}/s3/${key}` + + const getResponse = await fetch(getEndpoint, { + method: 'GET', + headers: { Accept: 'application/json' } + }) + expect(getResponse.headers.get('access-control-allow-origin')).to.deep.equal('*') + expect(getResponse.status).to.be.equal(200) + + const body = await getResponse.json() + expect(body).to.deep.equal({ message: 'test' }) + }) + + it('should get correct response from s3 delete endpoint', async () => { + const deleteEndpoint = `${endpoint}/s3?key=${key}` + + const deleteResponse = await fetch(deleteEndpoint, { + method: 'DELETE' + }) + expect(deleteResponse.status).to.be.equal(200) + }) +}) From 4fa75ef0163ca8ac9fa4855522e38d92348c8639 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sun, 18 Aug 2019 10:44:44 +0200 Subject: [PATCH 18/19] fix: fixed package-lock.json --- package-lock.json | 83 +++++++++++++++++++++++------------------------ 1 file changed, 41 insertions(+), 42 deletions(-) diff --git a/package-lock.json b/package-lock.json index f5a6c53..8e11fac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -207,48 +207,6 @@ "minimist": "^1.2.0" } }, - "@hapi/address": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@hapi/address/-/address-2.0.0.tgz", - "integrity": "sha512-mV6T0IYqb0xL1UALPFplXYQmR0twnXG0M6jUswpquqT2sD12BOiCiLy3EvMp/Fy7s3DZElC4/aPjEjo2jeZpvw==" - }, - "@hapi/hoek": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-6.2.4.tgz", - "integrity": "sha512-HOJ20Kc93DkDVvjwHyHawPwPkX44sIrbXazAUDiUXaY2R9JwQGo2PhFfnQtdrsIe4igjG2fPgMra7NYw7qhy0A==" - }, - "@hapi/joi": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-15.1.0.tgz", - "integrity": "sha512-n6kaRQO8S+kepUTbXL9O/UOL788Odqs38/VOfoCrATDtTvyfiO3fgjlSRaNkHabpTLgM7qru9ifqXlXbXk8SeQ==", - "requires": { - "@hapi/address": "2.x.x", - "@hapi/hoek": "6.x.x", - "@hapi/marker": "1.x.x", - "@hapi/topo": "3.x.x" - } - }, - "@hapi/marker": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@hapi/marker/-/marker-1.0.0.tgz", - "integrity": "sha512-JOfdekTXnJexfE8PyhZFyHvHjt81rBFSAbTIRAhF2vv/2Y1JzoKsGqxH/GpZJoF7aEfYok8JVcAHmSz1gkBieA==" - }, - "@hapi/topo": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-3.1.3.tgz", - "integrity": "sha512-JmS9/vQK6dcUYn7wc2YZTqzIKubAQcJKu2KCKAru6es482U5RT5fP1EXCPtlXpiK7PR0On/kpQKI4fRKkzpZBQ==", - "requires": { - "@hapi/hoek": "8.x.x" - }, - "dependencies": { - "@hapi/hoek": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-8.2.1.tgz", - "integrity": "sha512-JPiBy+oSmsq3St7XlipfN5pNA6bDJ1kpa73PrK/zR29CVClDVqy04AanM/M/qx5bSF+I61DdCfAvRrujau+zRg==" - } - } - }, -======= "@commitlint/cli": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-8.1.0.tgz", @@ -506,6 +464,47 @@ "find-up": "^4.0.0" } }, + "@hapi/address": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-2.0.0.tgz", + "integrity": "sha512-mV6T0IYqb0xL1UALPFplXYQmR0twnXG0M6jUswpquqT2sD12BOiCiLy3EvMp/Fy7s3DZElC4/aPjEjo2jeZpvw==" + }, + "@hapi/hoek": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-6.2.4.tgz", + "integrity": "sha512-HOJ20Kc93DkDVvjwHyHawPwPkX44sIrbXazAUDiUXaY2R9JwQGo2PhFfnQtdrsIe4igjG2fPgMra7NYw7qhy0A==" + }, + "@hapi/joi": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-15.1.0.tgz", + "integrity": "sha512-n6kaRQO8S+kepUTbXL9O/UOL788Odqs38/VOfoCrATDtTvyfiO3fgjlSRaNkHabpTLgM7qru9ifqXlXbXk8SeQ==", + "requires": { + "@hapi/address": "2.x.x", + "@hapi/hoek": "6.x.x", + "@hapi/marker": "1.x.x", + "@hapi/topo": "3.x.x" + } + }, + "@hapi/marker": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@hapi/marker/-/marker-1.0.0.tgz", + "integrity": "sha512-JOfdekTXnJexfE8PyhZFyHvHjt81rBFSAbTIRAhF2vv/2Y1JzoKsGqxH/GpZJoF7aEfYok8JVcAHmSz1gkBieA==" + }, + "@hapi/topo": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-3.1.3.tgz", + "integrity": "sha512-JmS9/vQK6dcUYn7wc2YZTqzIKubAQcJKu2KCKAru6es482U5RT5fP1EXCPtlXpiK7PR0On/kpQKI4fRKkzpZBQ==", + "requires": { + "@hapi/hoek": "8.x.x" + }, + "dependencies": { + "@hapi/hoek": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-8.2.1.tgz", + "integrity": "sha512-JPiBy+oSmsq3St7XlipfN5pNA6bDJ1kpa73PrK/zR29CVClDVqy04AanM/M/qx5bSF+I61DdCfAvRrujau+zRg==" + } + } + }, "@jest/console": { "version": "24.7.1", "resolved": "https://registry.npmjs.org/@jest/console/-/console-24.7.1.tgz", From bed787cb71a9eff9a8b0f71cc99e5af0ed7f322b Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Mon, 19 Aug 2019 11:23:12 +0200 Subject: [PATCH 19/19] docs: updated README --- README.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 570abfa..fc534aa 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Please pull request if you are intersted in it. - Kinesis Streams - SQS +- S3 ## How to use @@ -27,7 +28,7 @@ Define settings of the AWS services you want to integrate under `custom > apiGat ### Kinesis -Sample syntax for Kinesis proxy in serverless.yml. +Sample syntax for Kinesis proxy in `serverless.yml`. ```yaml custom: @@ -49,12 +50,12 @@ resources: Sample request after deploying. ```bash -curl -XPOST https://xxxxxxx.execute-api.us-east-1.amazonaws.com/dev/kinesis -d '{"Data": "some data","PartitionKey": "some key"}' -H 'Content-Type:application/json' +curl -X POST https://xxxxxxx.execute-api.us-east-1.amazonaws.com/dev/kinesis -d '{"Data": "some data","PartitionKey": "some key"}' -H 'Content-Type:application/json' ``` ### SQS -Sample syntax for SQS proxy in serverless.yml. +Sample syntax for SQS proxy in `serverless.yml`. ```yaml custom: @@ -74,7 +75,55 @@ resources: Sample request after deploying. ```bash -curl -XPOST https://xxxxxx.execute-api.us-east-1.amazonaws.com/dev/sqs -d '{"message": "testtest"}' -H 'Content-Type:application/json' +curl -X POST https://xxxxxx.execute-api.us-east-1.amazonaws.com/dev/sqs -d '{"message": "testtest"}' -H 'Content-Type:application/json' +``` + +### S3 + +Sample syntax for S3 proxy in `serverless.yml`. + +```yaml +custom: + apiGatewayServiceProxies: + - s3: + path: /s3 + method: post + action: PutObject + bucket: + Ref: S3Bucket + key: static-key.json # use static key + cors: true + + - s3: + path: /s3/{myKey} # use path param + method: get + action: GetObject + bucket: + Ref: S3Bucket + key: + pathParam: myKey + cors: true + + - s3: + path: /s3 + method: delete + action: DeleteObject + bucket: + Ref: S3Bucket + key: + queryStringParam: key # use query string param + cors: true + +resources: + Resources: + S3Bucket: + Type: 'AWS::S3::Bucket' +``` + +Sample request after deploying. + +```bash +curl -X POST https://xxxxxx.execute-api.us-east-1.amazonaws.com/dev/s3 -d '{"message": "testtest"}' -H 'Content-Type:application/json' ``` ## Common API Gateway features