diff --git a/docs/providers/aws/cli-reference/deploy.md b/docs/providers/aws/cli-reference/deploy.md index 66204c690..8bdeced09 100644 --- a/docs/providers/aws/cli-reference/deploy.md +++ b/docs/providers/aws/cli-reference/deploy.md @@ -25,7 +25,8 @@ serverless deploy - `--verbose` or `-v` Shows all stack events during deployment, and display any Stack Output. - `--function` or `-f` Invoke `deploy function` (see above). Convenience shortcut - cannot be used with `--package`. - `--conceal` Hides secrets from the output (e.g. API Gateway key values). -- `--aws-s3-accelerate` Enables S3 Transfer Acceleration making uploading artifacts much faster. You can read more about it [here](http://docs.aws.amazon.com/AmazonS3/latest/dev/transfer-acceleration.html). **Note: When using Transfer Acceleration, additional data transfer charges may apply** +- `--aws-s3-accelerate` Enables S3 Transfer Acceleration making uploading artifacts much faster. You can read more about it [here](http://docs.aws.amazon.com/AmazonS3/latest/dev/transfer-acceleration.html). It requires additional `s3:PutAccelerateConfiguration` permissions. **Note: When using Transfer Acceleration, additional data transfer charges may apply.** +- `--no-aws-s3-accelerate` Explicitly disables S3 Transfer Acceleration). It also requires additional `s3:PutAccelerateConfiguration` permissions. ## Artifacts diff --git a/docs/providers/aws/events/apigateway.md b/docs/providers/aws/events/apigateway.md index 13f057c34..cd281ac7b 100644 --- a/docs/providers/aws/events/apigateway.md +++ b/docs/providers/aws/events/apigateway.md @@ -633,7 +633,39 @@ See the [api gateway documentation](https://docs.aws.amazon.com/apigateway/lates **Notes:** - A missing/empty request Content-Type is considered to be the API Gateway default (`application/json`) -- API Gateway docs refer to "WHEN_NO_TEMPLATE" (singular), but this will fail during creation as the actual value should be "WHEN_NO_TEMPLATES" (plural) + - [[Read this on the main serverless docs site](https://www.serverless.com/framework/docs/providers/aws/events/apigateway)](#read-this-on-the-main-serverless-docs-sitehttpswwwserverlesscomframeworkdocsprovidersawseventsapigateway) + - [[Read this on the main serverless docs site](https://www.serverless.com/framework/docs/providers/aws/events/apigateway)](#read-this-on-the-main-serverless-docs-sitehttpswwwserverlesscomframeworkdocsprovidersawseventsapigateway) + - [[Read this on the main serverless docs site](https://www.serverless.com/framework/docs/providers/aws/events/apigateway)](#read-this-on-the-main-serverless-docs-sitehttpswwwserverlesscomframeworkdocsprovidersawseventsapigateway) + - [[Read this on the main serverless docs site](https://www.serverless.com/framework/docs/providers/aws/events/apigateway)](#read-this-on-the-main-serverless-docs-sitehttpswwwserverlesscomframeworkdocsprovidersawseventsapigateway) + - [[Read this on the main serverless docs site](https://www.serverless.com/framework/docs/providers/aws/events/apigateway)](#read-this-on-the-main-serverless-docs-sitehttpswwwserverlesscomframeworkdocsprovidersawseventsapigateway) + - [[Read this on the main serverless docs site](https://www.serverless.com/framework/docs/providers/aws/events/apigateway)](#read-this-on-the-main-serverless-docs-sitehttpswwwserverlesscomframeworkdocsprovidersawseventsapigateway) + - [[Read this on the main serverless docs site](https://www.serverless.com/framework/docs/providers/aws/events/apigateway)](#read-this-on-the-main-serverless-docs-sitehttpswwwserverlesscomframeworkdocsprovidersawseventsapigateway) +- [API Gateway](#api-gateway) + - [Lambda Proxy Integration](#lambda-proxy-integration) + - [Simple HTTP Endpoint](#simple-http-endpoint) + - [Example "LAMBDA-PROXY" event (default)](#example-lambda-proxy-event-default) + - [HTTP Endpoint with Extended Options](#http-endpoint-with-extended-options) + - [Enabling CORS](#enabling-cors) + - [HTTP Endpoints with `AWS_IAM` Authorizers](#http-endpoints-with-awsiam-authorizers) + - [HTTP Endpoints with Custom Authorizers](#http-endpoints-with-custom-authorizers) + - [Catching Exceptions In Your Lambda Function](#catching-exceptions-in-your-lambda-function) + - [Setting API keys for your Rest API](#setting-api-keys-for-your-rest-api) + - [Request Parameters](#request-parameters) + - [Lambda Integration](#lambda-integration) + - [Example "LAMBDA" event (before customization)](#example-lambda-event-before-customization) + - [Request templates](#request-templates) + - [Default Request Templates](#default-request-templates) + - [Custom Request Templates](#custom-request-templates) + - [Pass Through Behavior](#pass-through-behavior) + - [Responses](#responses) + - [Custom Response Headers](#custom-response-headers) + - [Custom Response Templates](#custom-response-templates) + - [Status codes](#status-codes) + - [Available Status Codes](#available-status-codes) + - [Using Status Codes](#using-status-codes) + - [Custom Status Codes](#custom-status-codes) + - [Setting an HTTP Proxy on API Gateway](#setting-an-http-proxy-on-api-gateway) + - [Share API Gateway and API Resources](#share-api-gateway-and-api-resources) ### Responses @@ -828,3 +860,116 @@ endpoint of your proxy, and the URI you want to set a proxy to. Now that you have these two CloudFormation templates defined in your `serverless.yml` file, you can simply run `serverless deploy` and that will deploy these custom resources for you along with your service and set up a proxy on your Rest API. + +## Share API Gateway and API Resources + +As you application grows, you will have idea to break it out into multiple services. However, each serverless project generates new API Gateway by default. If you want to share same API Gateway for muliple projects, you 'll need to reference REST API ID and Root Resource ID into serverless.yml files + +```yml +service: service-name +provider: + name: aws + apiGateway: + restApiId: xxxxxxxxxx # REST API resource ID. Default is generated by the framework + restApiRootResourceId: xxxxxxxxxx # Root resource, represent as / path + +functions: + ... + +``` + +In case the application has many chilren and grandchildren paths, you also want to break them out into smaller services. + +```yml +service: service-a +provider: + apiGateway: + restApiId: xxxxxxxxxx + restApiRootResourceId: xxxxxxxxxx + +functions: + create: + handler: posts.create + events: + - http: + method: post + path: /posts +``` + +```yml +service: service-b +provider: + apiGateway: + restApiId: xxxxxxxxxx + restApiRootResourceId: xxxxxxxxxx + +functions: + create: + handler: posts.createComment + events: + - http: + method: post + path: /posts/{id}/comments +``` + +They reference the same parent path `/posts`. Cloudformation will throw error if we try to generate existed one. To avoid that, we must reference source ID of `/posts`. + +```yml +service: service-a +provider: + apiGateway: + restApiId: xxxxxxxxxx + restApiRootResourceId: xxxxxxxxxx + restApiResources: + /posts: xxxxxxxxxx + +functions: + ... + +``` + +```yml +service: service-b +provider: + apiGateway: + restApiId: xxxxxxxxxx + restApiRootResourceId: xxxxxxxxxx + restApiResources: + /posts: xxxxxxxxxx + +functions: + ... + +``` + +You can define more than one path resource. Otherwise, serverless will generate paths from root resource. `restApiRootResourceId` can be optional if there isn't path that need to be generated from the root + +```yml +service: service-a +provider: + apiGateway: + restApiId: xxxxxxxxxx + # restApiRootResourceId: xxxxxxxxxx # Optional + restApiResources: + /posts: xxxxxxxxxx + /categories: xxxxxxxxx + + +functions: + listPosts: + handler: posts.list + events: + - http: + method: get + path: /posts + + listCategories: + handler: categories.list + events: + - http: + method: get + path: /categories + +``` + +For best practice and CI, CD friendly, we should define Cloudformation resources from early service, then use Cross-Stack References for another ones. diff --git a/docs/providers/aws/guide/serverless.yml.md b/docs/providers/aws/guide/serverless.yml.md index 30f1c1374..c9f1c35a7 100644 --- a/docs/providers/aws/guide/serverless.yml.md +++ b/docs/providers/aws/guide/serverless.yml.md @@ -45,6 +45,13 @@ provider: - myFirstKey - ${opt:stage}-myFirstKey - ${env:MY_API_KEY} # you can hide it in a serverless variable + apiGateway: # Optional API Gateway global config + restApiId: xxxxxxxxxx # REST API resource ID. Default is generated by the framework + restApiRootResourceId: xxxxxxxxxx # Root resource ID, represent as / path + restApiResources: # List of existing resources that were created in the REST API. This is required or the stack will be conflicted + '/users': xxxxxxxxxx + '/users/create': xxxxxxxxxx + usagePlan: # Optional usage plan configuration quota: limit: 5000 @@ -95,6 +102,7 @@ package: # Optional deployment packaging configuration - .travis.yml excludeDevDependencies: false # Config if Serverless should automatically exclude dev dependencies in the deployment package. Defaults to true + functions: usersCreate: # A Function handler: users.create # The file and module for this specific function. diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/apiKeys.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/apiKeys.js index fe635fd84..069455a3e 100644 --- a/lib/plugins/aws/package/compile/events/apiGateway/lib/apiKeys.js +++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/apiKeys.js @@ -27,7 +27,7 @@ module.exports = { Enabled: true, Name: apiKey, StageKeys: [{ - RestApiId: { Ref: this.apiGatewayRestApiLogicalId }, + RestApiId: this.provider.getApiGatewayRestApiId(), StageName: this.provider.getStage(), }], }, diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/authorizers.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/authorizers.js index e041f45fb..5d15ad887 100644 --- a/lib/plugins/aws/package/compile/events/apiGateway/lib/authorizers.js +++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/authorizers.js @@ -12,7 +12,7 @@ module.exports = { AuthorizerResultTtlInSeconds: authorizer.resultTtlInSeconds, IdentitySource: authorizer.identitySource, Name: authorizer.name, - RestApiId: { Ref: this.apiGatewayRestApiLogicalId }, + RestApiId: this.provider.getApiGatewayRestApiId(), }; if (typeof authorizer.identityValidationExpression === 'string') { diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.js index 48f93378a..be99eea38 100644 --- a/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.js +++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.js @@ -52,7 +52,7 @@ module.exports = { IntegrationResponses: this.generateCorsIntegrationResponses(preflightHeaders), }, ResourceId: resourceRef, - RestApiId: { Ref: this.apiGatewayRestApiLogicalId }, + RestApiId: this.provider.getApiGatewayRestApiId(), }, }, }); diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.test.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.test.js index 59858a730..db2d06c87 100644 --- a/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.test.js +++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/cors.test.js @@ -34,19 +34,28 @@ describe('#compileCors()', () => { }; awsCompileApigEvents = new AwsCompileApigEvents(serverless, options); awsCompileApigEvents.apiGatewayRestApiLogicalId = 'ApiGatewayRestApi'; - awsCompileApigEvents.apiGatewayResourceLogicalIds = { - 'users/create': 'ApiGatewayResourceUsersCreate', - 'users/list': 'ApiGatewayResourceUsersList', - 'users/update': 'ApiGatewayResourceUsersUpdate', - 'users/delete': 'ApiGatewayResourceUsersDelete', - 'users/any': 'ApiGatewayResourceUsersAny', - }; - awsCompileApigEvents.apiGatewayResourceNames = { - 'users/create': 'UsersCreate', - 'users/list': 'UsersList', - 'users/update': 'UsersUpdate', - 'users/delete': 'UsersDelete', - 'users/any': 'UsersAny', + awsCompileApigEvents.apiGatewayResources = { + 'users/create': { + name: 'UsersCreate', + resourceLogicalId: 'ApiGatewayResourceUsersCreate', + }, + + 'users/list': { + name: 'UsersList', + resourceLogicalId: 'ApiGatewayResourceUsersList', + }, + 'users/update': { + name: 'UsersUpdate', + resourceLogicalId: 'ApiGatewayResourceUsersUpdate', + }, + 'users/delete': { + name: 'UsersDelete', + resourceLogicalId: 'ApiGatewayResourceUsersDelete', + }, + 'users/any': { + name: 'UsersAny', + resourceLogicalId: 'ApiGatewayResourceUsersAny', + }, }; awsCompileApigEvents.validated = {}; }); diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/deployment.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/deployment.js index 0f6b6b83c..dd1d804a9 100644 --- a/lib/plugins/aws/package/compile/events/apiGateway/lib/deployment.js +++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/deployment.js @@ -12,7 +12,7 @@ module.exports = { [this.apiGatewayDeploymentLogicalId]: { Type: 'AWS::ApiGateway::Deployment', Properties: { - RestApiId: { Ref: this.apiGatewayRestApiLogicalId }, + RestApiId: this.provider.getApiGatewayRestApiId(), StageName: this.provider.getStage(), }, DependsOn: this.apiGatewayMethodLogicalIds, @@ -27,7 +27,7 @@ module.exports = { 'Fn::Join': ['', [ 'https://', - { Ref: this.apiGatewayRestApiLogicalId }, + this.provider.getApiGatewayRestApiId(), `.execute-api.${ this.provider.getRegion() }.amazonaws.com/${ diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/method/index.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/method/index.js index ac0147d42..c0c084852 100644 --- a/lib/plugins/aws/package/compile/events/apiGateway/lib/method/index.js +++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/method/index.js @@ -20,7 +20,7 @@ module.exports = { HttpMethod: event.http.method.toUpperCase(), RequestParameters: requestParameters, ResourceId: resourceId, - RestApiId: { Ref: this.apiGatewayRestApiLogicalId }, + RestApiId: this.provider.getApiGatewayRestApiId(), }, }; diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/method/index.test.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/method/index.test.js index 046312d2e..2f47e5c50 100644 --- a/lib/plugins/aws/package/compile/events/apiGateway/lib/method/index.test.js +++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/method/index.test.js @@ -35,17 +35,24 @@ describe('#compileMethods()', () => { awsCompileApigEvents = new AwsCompileApigEvents(serverless, options); awsCompileApigEvents.validated = {}; awsCompileApigEvents.apiGatewayRestApiLogicalId = 'ApiGatewayRestApi'; - awsCompileApigEvents.apiGatewayResourceLogicalIds = { - 'users/create': 'ApiGatewayResourceUsersCreate', - 'users/list': 'ApiGatewayResourceUsersList', - 'users/update': 'ApiGatewayResourceUsersUpdate', - 'users/delete': 'ApiGatewayResourceUsersDelete', - }; - awsCompileApigEvents.apiGatewayResourceNames = { - 'users/create': 'UsersCreate', - 'users/list': 'UsersList', - 'users/update': 'UsersUpdate', - 'users/delete': 'UsersDelete', + awsCompileApigEvents.apiGatewayResources = { + 'users/create': { + name: 'UsersCreate', + resourceLogicalId: 'ApiGatewayResourceUsersCreate', + }, + + 'users/list': { + name: 'UsersList', + resourceLogicalId: 'ApiGatewayResourceUsersList', + }, + 'users/update': { + name: 'UsersUpdate', + resourceLogicalId: 'ApiGatewayResourceUsersUpdate', + }, + 'users/delete': { + name: 'UsersDelete', + resourceLogicalId: 'ApiGatewayResourceUsersDelete', + }, }; }); diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/permissions.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/permissions.js index a25640581..2c09c0c7b 100644 --- a/lib/plugins/aws/package/compile/events/apiGateway/lib/permissions.js +++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/permissions.js @@ -26,7 +26,7 @@ module.exports = { ':', { Ref: 'AWS::AccountId' }, ':', - { Ref: this.apiGatewayRestApiLogicalId }, + this.provider.getApiGatewayRestApiId(), '/*/*', ], ] }, diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/resources.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/resources.js index ea714f170..fac0b36ca 100644 --- a/lib/plugins/aws/package/compile/events/apiGateway/lib/resources.js +++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/resources.js @@ -6,67 +6,248 @@ const _ = require('lodash'); module.exports = { compileResources() { - const resourcePaths = this.getResourcePaths(); - - this.apiGatewayResourceNames = {}; - this.apiGatewayResourceLogicalIds = {}; + this.apiGatewayResources = this.getResourcePaths(); // ['users', 'users/create', 'users/create/something'] - resourcePaths.forEach(path => { - const pathArray = path.split('/'); - const resourceName = this.provider.naming.normalizePath(path); - const resourceLogicalId = this.provider.naming.getResourceLogicalId(path); - const pathPart = pathArray.pop(); - const parentPath = pathArray.join('/'); - const parentRef = this.getResourceId(parentPath); + _.keys(this.apiGatewayResources).forEach((path) => { + const resource = this.apiGatewayResources[path]; + if (resource.resourceId) { + return; + } - this.apiGatewayResourceNames[path] = resourceName; - this.apiGatewayResourceLogicalIds[path] = resourceLogicalId; + resource.resourceLogicalId = this.provider.naming.getResourceLogicalId(path); + resource.resourceId = { Ref: resource.resourceLogicalId }; + + const parentRef = resource.parent + ? resource.parent.resourceId : this.getResourceId(); _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, { - [resourceLogicalId]: { + [resource.resourceLogicalId]: { Type: 'AWS::ApiGateway::Resource', Properties: { ParentId: parentRef, - PathPart: pathPart, - RestApiId: { Ref: this.apiGatewayRestApiLogicalId }, + PathPart: resource.pathPart, + RestApiId: this.provider.getApiGatewayRestApiId(), }, }, }); }); + return BbPromise.resolve(); }, - getResourcePaths() { - const paths = _.reduce(this.validated.events, (resourcePaths, event) => { - let path = event.http.path; + combineResourceTrees(trees) { + const self = this; - while (path !== '') { - if (resourcePaths.indexOf(path) === -1) { - resourcePaths.push(path); + function getNodePaths(result, node) { + const r = result; + r[node.path] = node; + if (!node.name) { + r[node.path].name = self.provider.naming.normalizePath(node.path); + } + + node.children.forEach((child) => getNodePaths(result, child)); + } + + return _.reduce(trees, (result, tree) => { + getNodePaths(result, tree); + return result; + }, {}); + }, + + getResourcePaths() { + const trees = []; + const predefinedResourceNodes = []; + const methodNodes = []; + const predefinedResources = this.provider.getApiGatewayPredefinedResources(); + + + function cutBranch(node) { + if (!node.parent) { + return; + } + + const n = node; + if (node.parent.children.length <= 1) { + n.parent.children = []; + } else { + n.parent.children = node.parent.children.filter((c) => c.path !== n.path); + n.parent.isCut = true; + } + n.parent = null; + } + + // organize all resource paths into N-ary tree + function applyResource(resource, isMethod) { + let root; + let parent; + let currentPath; + const path = resource.path.replace(/^\//, '').replace(/\/$/, ''); + const pathParts = path.split('/'); + + function applyNodeResource(node, parts, index) { + const n = node; + if (index === parts.length - 1) { + n.name = resource.name; + if (resource.resourceId) { + n.resourceId = resource.resourceId; + if (_.every(predefinedResourceNodes, (iter) => iter.path !== n.path)) { + predefinedResourceNodes.push(node); + } + } + if (isMethod && !node.hasMethod) { + n.hasMethod = true; + if (_.every(methodNodes, (iter) => iter.path !== n.path)) { + methodNodes.push(node); + } + } } - const splittedPath = path.split('/'); - splittedPath.pop(); - path = splittedPath.join('/'); + parent = node; } - return resourcePaths; - }, []); - // (stable) sort so that parents get processed before children - return _.sortBy(paths, path => path.split('/').length); + + pathParts.forEach((pathPart, index) => { + currentPath = currentPath ? `${currentPath}/${pathPart}` : pathPart; + root = root || _.find(trees, (node) => node.path === currentPath); + parent = parent || root; + + let node; + if (parent) { + if (parent.path === currentPath) { + applyNodeResource(parent, pathParts, index); + return; + } else if (parent.children.length > 0) { + node = _.find(parent.children, (n) => n.path === currentPath); + if (node) { + applyNodeResource(node, pathParts, index); + return; + } + } + } + + node = { + path: currentPath, + pathPart, + parent, + + level: index, + children: [], + }; + + if (parent) { + parent.children.push(node); + } + + if (!root) { + root = node; + trees.push(root); + } + + applyNodeResource(node, pathParts, index); + }); + } + + predefinedResources.forEach(applyResource); + this.validated.events.forEach((event) => { + if (event.http.path) { + applyResource(event.http, true); + } + }); + + // if predefinedResources array is empty, return all paths + if (predefinedResourceNodes.length === 0) { + return this.combineResourceTrees(trees); + } + + // if all methods have resource ID already, no need to validate resource trees + if (_.every(this.validated.events, (event) => + _.some(predefinedResourceNodes, (node) => + node.path === event.http.path))) { + return _.reduce(predefinedResources, (resourceMap, resource) => { + const r = resourceMap; + r[resource.path] = resource; + + if (!resource.name) { + r[resource.path].name = this.provider.naming.normalizePath(resource.path); + } + return r; + }, {}); + } + + // cut resource branches from trees + const sortedResourceNodes = _.sortBy(predefinedResourceNodes, + node => node.level); + const validatedTrees = []; + + for (let i = sortedResourceNodes.length - 1; i >= 0; i--) { + const node = sortedResourceNodes[i]; + let parent = node; + + while (parent && parent.parent) { + if (parent.parent.hasMethod && !parent.parent.resourceId) { + throw new Error(`Resource ID for path ${parent.parent.path} is required`); + } + + if (parent.parent.resourceId || parent.parent.children.length > 1) { + cutBranch(parent); + break; + } + + parent = parent.parent; + } + } + + // get branches that begin from root resource + methodNodes.forEach((node) => { + let iter = node; + while (iter) { + if (iter.resourceId) { + cutBranch(iter); + if (_.every(validatedTrees, (t) => t.path !== node.path)) { + validatedTrees.push(iter); + } + + break; + } + + if (iter.isCut || (!iter.parent && iter.level > 0)) { + throw new Error(`Resource ID for path ${iter.path} is required`); + } + + if (!iter.parent) { + validatedTrees.push(iter); + break; + } + + iter = iter.parent; + } + }); + + return this.combineResourceTrees(validatedTrees); }, getResourceId(path) { - if (path === '') { - return { 'Fn::GetAtt': [this.apiGatewayRestApiLogicalId, 'RootResourceId'] }; + if (!path) { + return this.provider.getApiGatewayRestApiRootResourceId(); } - return { Ref: this.apiGatewayResourceLogicalIds[path] }; + + if (!this.apiGatewayResources || !this.apiGatewayResources[path]) { + throw new Error(`Can not find API Gateway resource from path ${path}`); + } + + if (!this.apiGatewayResources[path].resourceId + && this.apiGatewayResources[path].resourceLogicalId) { + this.apiGatewayResources[path].resourceId = + { Ref: this.apiGatewayResources[path].resourceLogicalId }; + } + return this.apiGatewayResources[path].resourceId; }, getResourceName(path) { - if (path === '') { + if (path === '' || !this.apiGatewayResources) { return ''; } - return this.apiGatewayResourceNames[path]; + + return this.apiGatewayResources[path].name; }, }; diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/resources.test.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/resources.test.js index 61c4eb46a..18daad469 100644 --- a/lib/plugins/aws/package/compile/events/apiGateway/lib/resources.test.js +++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/resources.test.js @@ -70,15 +70,15 @@ describe('#compileResources()', () => { }, }, ]; - expect(awsCompileApigEvents.getResourcePaths()).to.deep.equal([ + expect(Object.keys(awsCompileApigEvents.getResourcePaths())).to.deep.equal([ 'foo', - 'bar', 'foo/bar', + 'bar', 'bar/-', 'bar/foo', 'bar/{id}', - 'bar/{foo_id}', 'bar/{id}/foobar', + 'bar/{foo_id}', 'bar/{foo_id}/foobar', ]); }); @@ -160,12 +160,16 @@ describe('#compileResources()', () => { }, ]; return awsCompileApigEvents.compileResources().then(() => { - expect(awsCompileApigEvents.apiGatewayResourceLogicalIds).to.deep.equal({ + const expectedResourceLogicalIds = { baz: 'ApiGatewayResourceBaz', 'baz/foo': 'ApiGatewayResourceBazFoo', foo: 'ApiGatewayResourceFoo', 'foo/{foo_id}': 'ApiGatewayResourceFooFooidVar', 'foo/{foo_id}/bar': 'ApiGatewayResourceFooFooidVarBar', + }; + Object.keys(expectedResourceLogicalIds).forEach((path) => { + expect(awsCompileApigEvents.apiGatewayResources[path].resourceLogicalId) + .equal(expectedResourceLogicalIds[path]); }); }); }); @@ -186,10 +190,14 @@ describe('#compileResources()', () => { }, ]; return awsCompileApigEvents.compileResources().then(() => { - expect(awsCompileApigEvents.apiGatewayResourceLogicalIds).to.deep.equal({ + const expectedResourceLogicalIds = { foo: 'ApiGatewayResourceFoo', 'foo/bar': 'ApiGatewayResourceFooBar', 'foo/{bar}': 'ApiGatewayResourceFooBarVar', + }; + Object.keys(expectedResourceLogicalIds).forEach((path) => { + expect(awsCompileApigEvents.apiGatewayResources[path].resourceLogicalId) + .equal(expectedResourceLogicalIds[path]); }); }); }); @@ -242,4 +250,239 @@ describe('#compileResources()', () => { .Resources).to.deep.equal({}); }); }); + + it('should create child resources only if there are predefined parent resources', () => { + awsCompileApigEvents.serverless.service.provider.apiGateway = { + restApiId: '6fyzt1pfpk', + restApiRootResourceId: 'z5d4qh4oqi', + restApiResources: { + '/foo': 'axcybf2i39', + '/users': 'zxcvbnmasd', + '/users/friends': 'fcasdoojp1', + '/groups': 'iuoyiusduo', + }, + }; + + awsCompileApigEvents.validated.events = [ + { + http: { + path: 'foo/bar', + method: 'GET', + }, + }, + { + http: { + path: 'foo/bar', + method: 'POST', + }, + }, + { + http: { + path: 'foo/bar', + method: 'DELETE', + }, + }, + { + http: { + path: 'bar/-', + method: 'GET', + }, + }, + { + http: { + path: 'bar/foo', + method: 'GET', + }, + }, + { + http: { + path: 'bar/{id}/foobar', + method: 'GET', + }, + }, + { + http: { + path: 'bar/{id}', + method: 'GET', + }, + }, + { + http: { + path: 'users/friends/comments', + method: 'GET', + }, + }, + { + http: { + path: 'users/me/posts', + method: 'GET', + }, + }, + { + http: { + path: 'groups/categories', + method: 'GET', + }, + }, + ]; + return awsCompileApigEvents.compileResources().then(() => { + try { + awsCompileApigEvents.getResourceId('users/{userId}'); + throw new Error('Expected API Gateway resource not found error, got success'); + } catch (e) { + expect(e.message).to.equal('Can not find API Gateway resource from path users/{userId}'); + } + + expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate + .Resources.ApiGatewayResourceFoo).to.equal(undefined); + expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate + .Resources.ApiGatewayResourceBar.Properties.RestApiId) + .to.equal('6fyzt1pfpk'); + expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate + .Resources.ApiGatewayResourceBar.Properties.ParentId) + .to.equal('z5d4qh4oqi'); + expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate + .Resources.ApiGatewayResourceFooBar.Properties.ParentId) + .to.equal('axcybf2i39'); + expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate + .Resources.ApiGatewayResourceBarIdVar.Properties.ParentId.Ref) + .to.equal('ApiGatewayResourceBar'); + expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate + .Resources.ApiGatewayResourceUsersMePosts).not.equal(undefined); + expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate + .Resources.ApiGatewayResourceUsersFriendsComments.Properties.ParentId) + .to.equal('fcasdoojp1'); + }); + }); + + it('should not create any child resources if all resources exists', () => { + awsCompileApigEvents.serverless.service.provider.apiGateway = { + restApiId: '6fyzt1pfpk', + restApiRootResourceId: 'z5d4qh4oqi', + restApiResources: { + foo: 'axcybf2i39', + users: 'zxcvbnmasd', + 'users/friends': 'fcasdoojp1', + 'users/is/this/a/long/path': 'sadvgpoujk', + }, + }; + + awsCompileApigEvents.validated.events = [ + { + http: { + path: 'foo', + method: 'GET', + }, + }, + { + http: { + path: 'users', + method: 'GET', + }, + }, + { + http: { + path: 'users/friends', + method: 'GET', + }, + }, + { + http: { + path: 'users/is/this/a/long/path', + method: 'GET', + }, + }, + ]; + + return awsCompileApigEvents.compileResources().then(() => { + expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate + .Resources.ApiGatewayResourceFoo).to.equal(undefined); + expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate + .Resources.ApiGatewayResourceUsers).to.equal(undefined); + expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate + .Resources.ApiGatewayResourceUsersFriends).to.equal(undefined); + expect(awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate + .Resources.ApiGatewayResourceUsersIsThis).to.equal(undefined); + }); + }); + + it('should throw error if parent of existing resources is required', () => { + awsCompileApigEvents.serverless.service.provider.apiGateway = { + restApiId: '6fyzt1pfpk', + restApiRootResourceId: 'z5d4qh4oqi', + restApiResources: { + 'users/friends': 'fcasdoojp1', + }, + }; + + awsCompileApigEvents.validated.events = [ + { + http: { + path: 'users', + method: 'GET', + }, + }, + { + http: { + path: 'users/friends/{friendId}', + method: 'GET', + }, + }, + ]; + + expect(() => awsCompileApigEvents.compileResources()) + .to.throw(Error, 'Resource ID for path users is required'); + }); + + it('should named all method paths if all resources are predefined', () => { + awsCompileApigEvents.serverless.service.provider.apiGateway = { + restApiId: '6fyzt1pfpk', + restApiRootResourceId: 'z5d4qh4oqi', + restApiResources: { + 'users/friends': 'fcasdoojp1', + 'users/friends/{id}': 'fcasdoojp1', + }, + }; + + awsCompileApigEvents.validated.events = [ + { + http: { + path: 'users/friends', + method: 'GET', + }, + }, + { + http: { + path: 'users/friends', + method: 'POST', + }, + }, + { + http: { + path: 'users/friends', + method: 'DELETE', + }, + }, + { + http: { + path: 'users/friends/{id}', + method: 'GET', + }, + }, + { + http: { + path: 'users/friends/{id}', + method: 'POST', + }, + }, + ]; + + return awsCompileApigEvents.compileResources().then(() => { + expect(Object.keys(awsCompileApigEvents.serverless + .service.provider.compiledCloudFormationTemplate + .Resources).every((k) => ['ApiGatewayMethodundefinedGet', + 'ApiGatewayMethodundefinedPost'].indexOf(k) === -1)) + .to.equal(true); + }); + }); }); diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/restApi.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/restApi.js index 50cbeb1f8..fc3dd0af4 100644 --- a/lib/plugins/aws/package/compile/events/apiGateway/lib/restApi.js +++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/restApi.js @@ -5,6 +5,11 @@ const BbPromise = require('bluebird'); module.exports = { compileRestApi() { + if (this.serverless.service.provider.apiGateway && + this.serverless.service.provider.apiGateway.restApiId) { + return BbPromise.resolve(); + } + this.apiGatewayRestApiLogicalId = this.provider.naming.getRestApiLogicalId(); let endpointType = 'EDGE'; diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/restApi.test.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/restApi.test.js index 5dbbcfc66..eb3aa7e8a 100644 --- a/lib/plugins/aws/package/compile/events/apiGateway/lib/restApi.test.js +++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/restApi.test.js @@ -60,6 +60,22 @@ describe('#compileRestApi()', () => { }) ); + it('should ignore REST API resource creation if there is predefined restApi config', + () => { + awsCompileApigEvents.serverless.service.provider.apiGateway = { + restApiId: '6fyzt1pfpk', + restApiRootResourceId: 'z5d4qh4oqi', + }; + return awsCompileApigEvents + .compileRestApi().then(() => { + expect( + awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate + .Resources + ).to.deep.equal({}); + }); + } + ); + it('throw error if endpointType property is not a string', () => { awsCompileApigEvents.serverless.service.provider.endpointType = ['EDGE']; expect(() => awsCompileApigEvents.compileRestApi()).to.throw(Error); diff --git a/lib/plugins/aws/package/lib/generateCoreTemplate.js b/lib/plugins/aws/package/lib/generateCoreTemplate.js index 09a20d4dd..fae3c2000 100644 --- a/lib/plugins/aws/package/lib/generateCoreTemplate.js +++ b/lib/plugins/aws/package/lib/generateCoreTemplate.js @@ -25,6 +25,14 @@ module.exports = { const bucketName = this.serverless.service.provider.deploymentBucket; const isS3TransferAccelerationEnabled = this.provider.isS3TransferAccelerationEnabled(); + const isS3TransferAccelerationDisabled = this.provider.isS3TransferAccelerationDisabled(); + + if (isS3TransferAccelerationEnabled && isS3TransferAccelerationDisabled) { + const errorMessage = [ + 'You cannot enable and disable S3 Transfer Acceleration at the same time', + ].join(''); + return BbPromise.reject(new this.serverless.classes.Error(errorMessage)); + } if (bucketName) { return BbPromise.bind(this) @@ -45,17 +53,25 @@ module.exports = { }); } - this.serverless.service.provider.compiledCloudFormationTemplate - .Resources.ServerlessDeploymentBucket.Properties = { - AccelerateConfiguration: { - AccelerationStatus: - isS3TransferAccelerationEnabled ? 'Enabled' : 'Suspended', - }, - }; - if (isS3TransferAccelerationEnabled) { + // enable acceleration via CloudFormation + this.serverless.service.provider.compiledCloudFormationTemplate + .Resources.ServerlessDeploymentBucket.Properties = { + AccelerateConfiguration: { + AccelerationStatus: 'Enabled', + }, + }; + // keep track of acceleration status via CloudFormation Output this.serverless.service.provider.compiledCloudFormationTemplate .Outputs.ServerlessDeploymentBucketAccelerated = { Value: true }; + } else if (isS3TransferAccelerationDisabled) { + // explicitly disable acceleration via CloudFormation + this.serverless.service.provider.compiledCloudFormationTemplate + .Resources.ServerlessDeploymentBucket.Properties = { + AccelerateConfiguration: { + AccelerationStatus: 'Suspended', + }, + }; } const coreTemplateFileName = this.provider.naming.getCoreTemplateFileName(); diff --git a/lib/plugins/aws/package/lib/generateCoreTemplate.test.js b/lib/plugins/aws/package/lib/generateCoreTemplate.test.js index a089d5369..e4cb5eba2 100644 --- a/lib/plugins/aws/package/lib/generateCoreTemplate.test.js +++ b/lib/plugins/aws/package/lib/generateCoreTemplate.test.js @@ -61,14 +61,45 @@ describe('#generateCoreTemplate()', () => { return expect(awsPlugin.generateCoreTemplate()).to.be.fulfilled .then(() => { + const template = awsPlugin.serverless.service.provider.compiledCloudFormationTemplate; expect( - awsPlugin.serverless.service.provider.compiledCloudFormationTemplate - .Outputs.ServerlessDeploymentBucketName.Value + template.Outputs.ServerlessDeploymentBucketName.Value ).to.equal(bucketName); // eslint-disable-next-line no-unused-expressions expect( - awsPlugin.serverless.service.provider.compiledCloudFormationTemplate - .Resources.ServerlessDeploymentBucket + template.Resources.ServerlessDeploymentBucket + ).to.not.exist; + }); + }); + + it('should use a custom bucket if specified, even with S3 transfer acceleration', () => { + const bucketName = 'com.serverless.deploys'; + + awsPlugin.serverless.service.provider.deploymentBucket = bucketName; + awsPlugin.provider.options['aws-s3-accelerate'] = true; + + const coreCloudFormationTemplate = awsPlugin.serverless.utils.readFileSync( + path.join( + __dirname, + 'core-cloudformation-template.json' + ) + ); + awsPlugin.serverless.service.provider + .compiledCloudFormationTemplate = coreCloudFormationTemplate; + + return expect(awsPlugin.generateCoreTemplate()).to.be.fulfilled + .then(() => { + const template = awsPlugin.serverless.service.provider.compiledCloudFormationTemplate; + expect( + template.Outputs.ServerlessDeploymentBucketName.Value + ).to.equal(bucketName); + // eslint-disable-next-line no-unused-expressions + expect( + template.Resources.ServerlessDeploymentBucket + ).to.not.exist; + // eslint-disable-next-line no-unused-expressions + expect( + template.Outputs.ServerlessDeploymentBucketAccelerated ).to.not.exist; }); }); @@ -80,11 +111,6 @@ describe('#generateCoreTemplate()', () => { .Resources.ServerlessDeploymentBucket ).to.be.deep.equal({ Type: 'AWS::S3::Bucket', - Properties: { - AccelerateConfiguration: { - AccelerationStatus: 'Suspended', - }, - }, }); }) ); @@ -114,4 +140,36 @@ describe('#generateCoreTemplate()', () => { expect(template.Outputs.ServerlessDeploymentBucketAccelerated.Value).to.equal(true); }); }); + + it('should explicitly disable S3 Transfer Acceleration, if requested', () => { + sinon.stub(awsPlugin.provider, 'request').resolves(); + sinon.stub(serverless.utils, 'writeFileSync').resolves(); + serverless.config.servicePath = './'; + awsPlugin.provider.options['no-aws-s3-accelerate'] = true; + + return awsPlugin.generateCoreTemplate() + .then(() => { + const template = serverless.service.provider.coreCloudFormationTemplate; + expect(template.Resources.ServerlessDeploymentBucket).to.be.deep.equal({ + Type: 'AWS::S3::Bucket', + Properties: { + AccelerateConfiguration: { + AccelerationStatus: 'Suspended', + }, + }, + }); + }); + }); + + it('should explode if transfer acceleration is both enabled and disabled', () => { + sinon.stub(awsPlugin.provider, 'request').resolves(); + sinon.stub(serverless.utils, 'writeFileSync').resolves(); + serverless.config.servicePath = './'; + awsPlugin.provider.options['aws-s3-accelerate'] = true; + awsPlugin.provider.options['no-aws-s3-accelerate'] = true; + + return expect( + awsPlugin.generateCoreTemplate() + ).to.be.rejectedWith(serverless.classes.Error, /at the same time/); + }); }); diff --git a/lib/plugins/aws/provider/awsProvider.js b/lib/plugins/aws/provider/awsProvider.js index 7f5c453bd..bcc78e583 100644 --- a/lib/plugins/aws/provider/awsProvider.js +++ b/lib/plugins/aws/provider/awsProvider.js @@ -317,6 +317,10 @@ class AwsProvider { return !!this.options['aws-s3-accelerate']; } + isS3TransferAccelerationDisabled() { + return !!this.options['no-aws-s3-accelerate']; + } + disableTransferAccelerationForCurrentDeploy() { delete this.options['aws-s3-accelerate']; } @@ -363,6 +367,53 @@ class AwsProvider { return this.request('STS', 'getCallerIdentity', {}) .then((result) => result.Account); } + + /** + * Get API Gateway Rest API ID from serverless config + */ + getApiGatewayRestApiId() { + if (this.serverless.service.provider.apiGateway + && this.serverless.service.provider.apiGateway.restApiId) { + return this.serverless.service.provider.apiGateway.restApiId; + } + + return { Ref: this.naming.getRestApiLogicalId() }; + } + + /** + * Get Rest API Root Resource ID from serverless config + */ + getApiGatewayRestApiRootResourceId() { + if (this.serverless.service.provider.apiGateway + && this.serverless.service.provider.apiGateway.restApiRootResourceId) { + return this.serverless.service.provider.apiGateway.restApiRootResourceId; + } + return { 'Fn::GetAtt': [this.naming.getRestApiLogicalId(), 'RootResourceId'] }; + } + + /** + * Get Rest API Predefined Resources from serverless config + */ + getApiGatewayPredefinedResources() { + if (!this.serverless.service.provider.apiGateway + || !this.serverless.service.provider.apiGateway.restApiResources) { + return []; + } + + if (Array.isArray(this.serverless.service.provider.apiGateway.restApiResources)) { + return this.serverless.service.provider.apiGateway.restApiResources; + } + + if (typeof this.serverless.service.provider.apiGateway.restApiResources !== 'object') { + throw new Error('REST API resource must be an array of object'); + } + + return Object.keys(this.serverless.service.provider.apiGateway.restApiResources) + .map((key) => ({ + path: key, + resourceId: this.serverless.service.provider.apiGateway.restApiResources[key], + })); + } } module.exports = AwsProvider;