From f47b340e4fbe5163595225d450e857ae36211d98 Mon Sep 17 00:00:00 2001 From: Mariusz Nowak Date: Wed, 26 Feb 2020 17:35:14 +1300 Subject: [PATCH] feat(AWS HTTP API): Support attachment to externally created API --- docs/providers/aws/events/http-api.md | 12 ++++ lib/plugins/aws/info/getStackInfo.js | 12 ++++ .../package/compile/events/httpApi/index.js | 27 +++++-- .../compile/events/httpApi/index.test.js | 49 +++++++++++++ tests/fixtures/httpApiExport/serverless.yml | 24 +++++++ tests/integration-all/http-api/tests.js | 72 +++++++++++++++++++ 6 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/httpApiExport/serverless.yml diff --git a/docs/providers/aws/events/http-api.md b/docs/providers/aws/events/http-api.md index 4365ddba8fe..75682d9042e 100644 --- a/docs/providers/aws/events/http-api.md +++ b/docs/providers/aws/events/http-api.md @@ -178,3 +178,15 @@ provider: ``` See [AWS HTTP API Logging](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-logging-variables.html) documentation for more info on variables that can be used + +### Resuing HTTP API in different services + +We may attach configured endpoints to HTTP API creted externally. For that provide HTTP API id in provider settings as follows: + +```yaml +provider: + httpApi: + id: xxxx # id of externally created HTTP API to which endpoints should be attached. +``` + +In such case no API and stage resources are created, therefore extending HTTP API with CORS or access logs settings is not supported. diff --git a/lib/plugins/aws/info/getStackInfo.js b/lib/plugins/aws/info/getStackInfo.js index dbbf27c44b1..f2a016e00df 100644 --- a/lib/plugins/aws/info/getStackInfo.js +++ b/lib/plugins/aws/info/getStackInfo.js @@ -28,6 +28,15 @@ module.exports = { if (result) stackData.outputs = result.Stacks[0].Outputs; }), ]; + if (this.serverless.service.provider.httpApi && this.serverless.service.provider.httpApi.id) { + sdkRequests.push( + this.provider + .request('ApiGatewayV2', 'getApi', { ApiId: this.serverless.service.provider.httpApi.id }) + .then(result => { + if (result) stackData.externalHttpApiEndpoint = result.ApiEndpoint; + }) + ); + } // Get info from CloudFormation Outputs return BbPromise.all(sdkRequests).then(() => { @@ -89,6 +98,9 @@ module.exports = { } }); } + if (stackData.externalHttpApiEndpoint) { + this.gatheredData.info.endpoints.push(`httpApi: ${stackData.externalHttpApiEndpoint}`); + } return BbPromise.resolve(); }); diff --git a/lib/plugins/aws/package/compile/events/httpApi/index.js b/lib/plugins/aws/package/compile/events/httpApi/index.js index 71262a2af46..c8558045a2b 100644 --- a/lib/plugins/aws/package/compile/events/httpApi/index.js +++ b/lib/plugins/aws/package/compile/events/httpApi/index.js @@ -47,7 +47,11 @@ class HttpApiEvents { }, }; } + getApiIdConfig() { + return this.config.id || { Ref: this.provider.naming.getHttpApiLogicalId() }; + } compileApi() { + if (this.config.id) return; const properties = { Name: this.provider.naming.getHttpApiName(), ProtocolType: 'HTTP', @@ -76,6 +80,7 @@ class HttpApiEvents { }; } compileStage() { + if (this.config.id) return; const properties = { ApiId: { Ref: this.provider.naming.getHttpApiLogicalId() }, StageName: '$default', @@ -118,7 +123,7 @@ class HttpApiEvents { ] = { Type: 'AWS::ApiGatewayV2::Authorizer', Properties: { - ApiId: { Ref: this.provider.naming.getHttpApiLogicalId() }, + ApiId: this.getApiIdConfig(), AuthorizerType: 'JWT', IdentitySource: [authorizer.identitySource], JwtConfiguration: { @@ -139,7 +144,7 @@ class HttpApiEvents { ] = { Type: 'AWS::ApiGatewayV2::Route', Properties: { - ApiId: { Ref: this.provider.naming.getHttpApiLogicalId() }, + ApiId: this.getApiIdConfig(), RouteKey: routeKey === '*' ? '$default' : routeKey, Target: { 'Fn::Join': [ @@ -173,13 +178,19 @@ Object.defineProperties( memoizeeMethods({ resolveConfiguration: d(function() { const routes = new Map(); - this.config = { routes }; const providerConfig = this.serverless.service.provider; const userConfig = providerConfig.httpApi || {}; + this.config = { routes, id: userConfig.id }; let cors = null; let shouldFillCorsMethods = false; const userCors = userConfig.cors; if (userCors) { + if (userConfig.id) { + throw new this.serverless.classes.Error( + 'Cannot setup CORS rules for externally confugured HTTP API', + 'EXTERNAL_HTTP_API_CORS_CONFIG' + ); + } cors = this.config.cors = {}; if (userConfig.cors === true) { Object.assign(cors, defaultCors); @@ -217,6 +228,12 @@ Object.defineProperties( const userLogsConfig = providerConfig.logs && providerConfig.logs.httpApi; if (userLogsConfig) { + if (userConfig.id) { + throw new this.serverless.classes.Error( + 'Cannot setup access logs for externally confugured HTTP API', + 'EXTERNAL_HTTP_API_LOGS_CONFIG' + ); + } this.config.accessLogFormat = userLogsConfig.format || `{${JSON.stringify({ @@ -355,7 +372,7 @@ Object.defineProperties( ] = { Type: 'AWS::ApiGatewayV2::Integration', Properties: { - ApiId: { Ref: this.provider.naming.getHttpApiLogicalId() }, + ApiId: this.getApiIdConfig(), IntegrationType: 'AWS_PROXY', IntegrationUri: resolveTargetConfig(routeTargetData), PayloadFormatVersion: '1.0', @@ -382,7 +399,7 @@ Object.defineProperties( ':', { Ref: 'AWS::AccountId' }, ':', - { Ref: this.provider.naming.getHttpApiLogicalId() }, + this.getApiIdConfig(), '/*', ], ], diff --git a/lib/plugins/aws/package/compile/events/httpApi/index.test.js b/lib/plugins/aws/package/compile/events/httpApi/index.test.js index 59d678f6c6a..dfdeaa15542 100644 --- a/lib/plugins/aws/package/compile/events/httpApi/index.test.js +++ b/lib/plugins/aws/package/compile/events/httpApi/index.test.js @@ -403,4 +403,53 @@ describe('HttpApiEvents', () => { expect(stageResourceProps.AccessLogSettings).to.have.property('Format'); }); }); + + describe('External HTTP API', () => { + let cfResources; + let cfOutputs; + let naming; + const apiId = 'external-api-id'; + + before(() => + fixtures.extend('httpApi', { provider: { httpApi: { id: apiId } } }).then(fixturePath => + runServerless({ + cwd: fixturePath, + cliArgs: ['package'], + }).then(serverless => { + ({ + Resources: cfResources, + Outputs: cfOutputs, + } = serverless.service.provider.compiledCloudFormationTemplate); + naming = serverless.getProvider('aws').naming; + }) + ) + ); + + it('Should not configure API resource', () => { + expect(cfResources).to.not.have.property(naming.getHttpApiLogicalId()); + }); + it('Should not configure stage resource', () => { + expect(cfResources).to.not.have.property(naming.getHttpApiStageLogicalId()); + }); + it('Should not configure output', () => { + expect(cfOutputs).to.not.have.property('HttpApiUrl'); + }); + it('Should configure endpoint that attaches to external API', () => { + const routeKey = 'POST /some-post'; + const resource = cfResources[naming.getHttpApiRouteLogicalId(routeKey)]; + expect(resource.Type).to.equal('AWS::ApiGatewayV2::Route'); + expect(resource.Properties.RouteKey).to.equal(routeKey); + expect(resource.Properties.ApiId).to.equal(apiId); + }); + it('Should configure endpoint integration', () => { + const resource = cfResources[naming.getHttpApiIntegrationLogicalId('foo')]; + expect(resource.Type).to.equal('AWS::ApiGatewayV2::Integration'); + expect(resource.Properties.IntegrationType).to.equal('AWS_PROXY'); + }); + it('Should configure lambda permissions', () => { + const resource = cfResources[naming.getLambdaHttpApiPermissionLogicalId('foo')]; + expect(resource.Type).to.equal('AWS::Lambda::Permission'); + expect(resource.Properties.Action).to.equal('lambda:InvokeFunction'); + }); + }); }); diff --git a/tests/fixtures/httpApiExport/serverless.yml b/tests/fixtures/httpApiExport/serverless.yml new file mode 100644 index 00000000000..75b23ac141b --- /dev/null +++ b/tests/fixtures/httpApiExport/serverless.yml @@ -0,0 +1,24 @@ +service: service +provider: + name: aws + +resources: + Resources: + HttpApi: + Type: AWS::ApiGatewayV2::Api + Properties: + Name: dev-${self:service} + ProtocolType: HTTP + HttpApiStage: + Type: AWS::ApiGatewayV2::Stage + Properties: + ApiId: + Ref: HttpApi + StageName: $default + AutoDeploy: true + Outputs: + HttpApiId: + Value: + Ref: HttpApi + Export: + Name: TestHttpApiExportId diff --git a/tests/integration-all/http-api/tests.js b/tests/integration-all/http-api/tests.js index 827f3c08cba..9793ceb9c58 100644 --- a/tests/integration-all/http-api/tests.js +++ b/tests/integration-all/http-api/tests.js @@ -222,4 +222,76 @@ describe('HTTP API Integration Test', function() { expect(json).to.deep.equal({ method: 'PATCH', path: '/foo' }); }); }); + + describe('Shared API', () => { + let exportServicePath; + + before(async () => { + exportServicePath = getTmpDirPath(); + log.debug('service #1 path %s', exportServicePath); + + const exportServiceConfig = await createTestService(exportServicePath, { + templateDir: fixtures.map.httpApiExport, + }); + log.notice('deploying %s service', exportServiceConfig.service); + await deployService(exportServicePath); + const httpApiId = ( + await awsRequest('CloudFormation', 'describeStacks', { + StackName: `${exportServiceConfig.service}-${stage}`, + }) + ).Stacks[0].Outputs[0].OutputValue; + + tmpDirPath = getTmpDirPath(); + log.debug('sevice #2 path %s', tmpDirPath); + const serverlessConfig = await createTestService(tmpDirPath, { + templateDir: await fixtures.extend('httpApi', { + provider: { httpApi: { id: httpApiId } }, + }), + }); + serviceName = serverlessConfig.service; + stackName = `${serviceName}-${stage}`; + log.notice('deploying %s service', serviceName); + await deployService(tmpDirPath); + endpoint = (await awsRequest('ApiGatewayV2', 'getApi', { ApiId: httpApiId })).ApiEndpoint; + }); + + after(async () => { + if (serviceName) { + log.notice('removing service #2'); + await removeService(tmpDirPath); + } + log.notice('removing service #1'); + await removeService(exportServicePath); + }); + + it('should expose an accessible POST HTTP endpoint', async () => { + const testEndpoint = `${endpoint}/some-post`; + + const response = await fetch(testEndpoint, { method: 'POST' }); + const json = await response.json(); + expect(json).to.deep.equal({ method: 'POST', path: '/some-post' }); + }); + + it('should expose an accessible paramed GET HTTP endpoint', async () => { + const testEndpoint = `${endpoint}/bar/whatever`; + + const response = await fetch(testEndpoint, { method: 'GET' }); + const json = await response.json(); + expect(json).to.deep.equal({ method: 'GET', path: '/bar/whatever' }); + }); + + it('should return 404 on not supported method', async () => { + const testEndpoint = `${endpoint}/foo`; + + const response = await fetch(testEndpoint, { method: 'POST' }); + expect(response.status).to.equal(404); + }); + + it('should return 404 on not configured path', async () => { + const testEndpoint = `${endpoint}/not-configured`; + + const response = await fetch(testEndpoint, { method: 'GET' }); + expect(response.status).to.equal(404); + }); + }); });