diff --git a/packages/@aws-cdk/aws-apigateway/README.md b/packages/@aws-cdk/aws-apigateway/README.md index a44ad5b5bee21..771dfe0d6cce8 100644 --- a/packages/@aws-cdk/aws-apigateway/README.md +++ b/packages/@aws-cdk/aws-apigateway/README.md @@ -29,6 +29,45 @@ book.addMethod('GET'); book.addMethod('DELETE'); ``` +### AWS Lambda-backed APIs + +A very common practice is to use Amazon API Gateway with AWS Lambda as the +backend integration. The `LambdaRestApi` construct makes it easy: + +The following code defines a REST API that uses a greedy `{proxy+}` resource +mounted under `/api/v1` and integrates all methods (`"ANY"`) with the specified +AWS Lambda function: + +```ts +const backend = new lambda.Function(...); +new apigateway.LambdaRestApi(this, 'myapi', { + handler: backend, + proxyPath: '/api/v1' +}); +``` + +If `proxyPath` is not defined, you will have to explicitly define the API model: + +```ts +const backend = new lambda.Function(...); +const api = new apigateway.LambdaRestApi(this, 'myapi', { + handler: backend +}); + +const items = api.root.addResource('items'); +items.addMethod('GET'); // GET /items +items.addMethod('POST'); // POST /items + +const item = items.addResource('{item}'); +item.addMethod('GET'); // GET /items/{item} + +// the default integration for methods is "handler", but one can +// customize this behavior per method or even a sub path. +item.addMethod('DELETE', { + integration: new apigateway.HttpIntegration('http://amazon.com') +}); +``` + ### Integration Targets Methods are associated with backend integrations, which are invoked when this @@ -95,6 +134,20 @@ const book = books.addResource('{book_id}'); book.addMethod('GET'); // integrated with `booksBackend` ``` +### Proxy Routes + +The `addProxy` method can be used to install a greedy `{proxy+}` resource +on a path. By default, this also installs an `"ANY"` method: + +```ts +const proxy = resource.addProxy({ + defaultIntegration: new LambdaIntegration(handler), + + // "false" will require explicitly adding methods on the `proxy` resource + anyMethod: true // "true" is the default +}); +``` + ### Deployments By default, the `RestApi` construct will automatically create an API Gateway diff --git a/packages/@aws-cdk/aws-apigateway/lib/index.ts b/packages/@aws-cdk/aws-apigateway/lib/index.ts index a3b1ae3e2e5ec..b36594885a3d4 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/index.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/index.ts @@ -6,6 +6,7 @@ export * from './integration'; export * from './deployment'; export * from './stage'; export * from './integrations'; +export * from './lambda-api'; // AWS::ApiGateway CloudFormation Resources: export * from './apigateway.generated'; diff --git a/packages/@aws-cdk/aws-apigateway/lib/lambda-api.ts b/packages/@aws-cdk/aws-apigateway/lib/lambda-api.ts new file mode 100644 index 0000000000000..1b08b2533b165 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/lambda-api.ts @@ -0,0 +1,64 @@ +import lambda = require('@aws-cdk/aws-lambda'); +import cdk = require('@aws-cdk/cdk'); +import { LambdaIntegration } from './integrations'; +import { RestApi, RestApiProps } from './restapi'; + +export interface LambdaRestApiProps { + /** + * The default Lambda function that handles all requests from this API. + * + * This handler will be used as a the default integration for all methods in + * this API, unless specified otherwise in `addMethod`. + */ + handler: lambda.Function; + + /** + * An API path for a greedy proxy with an "ANY" method, which will route all + * requests under that path to the defined handler. + * + * If not defined, you will need to explicitly define the API model using + * `addResource` and `addMethod` (or `addProxy`). + * + * @default undefined + */ + proxyPath?: string; + + /** + * Further customization of the REST API. + * + * @default defaults + */ + options?: RestApiProps; +} + +/** + * Defines an API Gateway REST API with AWS Lambda proxy integration. + * + * Use the `proxyPath` property to define a greedy proxy ("{proxy+}") and "ANY" + * method from the specified path. If not defined, you will need to explicity + * add resources and methods to the API. + */ +export class LambdaRestApi extends RestApi { + constructor(parent: cdk.Construct, id: string, props: LambdaRestApiProps) { + if (props.options && props.options.defaultIntegration) { + throw new Error(`Cannot specify "options.defaultIntegration" since Lambda integration is automatically defined`); + } + + super(parent, id, { + defaultIntegration: new LambdaIntegration(props.handler), + ...props.options + }); + + // if proxyPath is specified, add a proxy at the specified path + // we will need to create all resources along the path. + const proxyPath = props.proxyPath; + if (proxyPath) { + const route = proxyPath.split('/').filter(x => x); + let curr = this.root; + for (const part of route) { + curr = curr.addResource(part); + } + curr.addProxy(); + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/lib/method.ts b/packages/@aws-cdk/aws-apigateway/lib/method.ts index 380b8048115e2..8d6306963a9ac 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/method.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/method.ts @@ -117,7 +117,9 @@ export class Method extends cdk.Construct { */ public get methodArn(): string { if (!this.restApi.deploymentStage) { - throw new Error('There is no stage associated with this restApi. Either use `autoDeploy` or explicitly assign `deploymentStage`'); + throw new Error( + `Unable to determine ARN for method "${this.id}" since there is no stage associated with this API.\n` + + 'Either use the `deploy` prop or explicitly assign `deploymentStage` on the RestApi'); } const stage = this.restApi.deploymentStage.stageName.toString(); diff --git a/packages/@aws-cdk/aws-apigateway/lib/resource.ts b/packages/@aws-cdk/aws-apigateway/lib/resource.ts index 59d9fd980c5a8..ca469e7f5bb04 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/resource.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/resource.ts @@ -45,6 +45,12 @@ export interface IRestApiResource { */ addResource(pathPart: string, options?: ResourceOptions): Resource; + /** + * Adds a greedy proxy resource ("{proxy+}") and an ANY method to this route. + * @param options Default integration and method options. + */ + addProxy(options?: ResourceOptions): ProxyResource; + /** * Defines a new method for this resource. * @param httpMethod The HTTP method @@ -132,6 +138,52 @@ export class Resource extends cdk.Construct implements IRestApiResource { public addMethod(httpMethod: string, integration?: Integration, options?: MethodOptions): Method { return new Method(this, httpMethod, { resource: this, httpMethod, integration, options }); } + + public addProxy(options?: ResourceOptions): ProxyResource { + return new ProxyResource(this, '{proxy+}', { parent: this, ...options }); + } +} + +export interface ProxyResourceProps extends ResourceOptions { + /** + * The parent resource of this resource. You can either pass another + * `Resource` object or a `RestApi` object here. + */ + parent: IRestApiResource; + + /** + * Adds an "ANY" method to this resource. If set to `false`, you will have to explicitly + * add methods to this resource after it's created. + * + * @default true + */ + anyMethod?: boolean; +} + +/** + * Defines a {proxy+} greedy resource and an ANY method on a route. + * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-set-up-simple-proxy.html + */ +export class ProxyResource extends Resource { + /** + * If `props.anyMethod` is `true`, this will be the reference to the 'ANY' + * method associated with this proxy resource. + */ + public readonly anyMethod?: Method; + + constructor(parent: cdk.Construct, id: string, props: ProxyResourceProps) { + super(parent, id, { + parent: props.parent, + pathPart: '{proxy+}', + defaultIntegration: props.defaultIntegration, + defaultMethodOptions: props.defaultMethodOptions, + }); + + const anyMethod = props.anyMethod !== undefined ? props.anyMethod : true; + if (anyMethod) { + this.anyMethod = this.addMethod('ANY'); + } + } } function validateResourcePathPart(part: string) { diff --git a/packages/@aws-cdk/aws-apigateway/lib/restapi.ts b/packages/@aws-cdk/aws-apigateway/lib/restapi.ts index 6ad39ed4bbc4e..e9bd84f2422c9 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/restapi.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/restapi.ts @@ -4,7 +4,7 @@ import { cloudformation } from './apigateway.generated'; import { Deployment } from './deployment'; import { Integration } from './integration'; import { Method, MethodOptions } from './method'; -import { IRestApiResource, Resource, ResourceOptions } from './resource'; +import { IRestApiResource, ProxyResource, Resource, ResourceOptions } from './resource'; import { RestApiRef } from './restapi-ref'; import { Stage, StageOptions } from './stage'; @@ -213,6 +213,9 @@ export class RestApi extends RestApiRef implements cdk.IDependable { addMethod: (httpMethod: string, integration?: Integration, options?: MethodOptions) => { return new Method(this, httpMethod, { resource: this.root, httpMethod, integration, options }); }, + addProxy: (options?: ResourceOptions) => { + return new ProxyResource(this, '{proxy+}', { parent: this.root, ...options }); + }, defaultIntegration: props.defaultIntegration, defaultMethodOptions: props.defaultMethodOptions, resourceApi: this, diff --git a/packages/@aws-cdk/aws-apigateway/test/test.lambda-api.ts b/packages/@aws-cdk/aws-apigateway/test/test.lambda-api.ts new file mode 100644 index 0000000000000..2c9ebfb2f21a8 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/test.lambda-api.ts @@ -0,0 +1,166 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import lambda = require('@aws-cdk/aws-lambda'); +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import apigw = require('../lib'); + +// tslint:disable:object-literal-key-quotes + +export = { + 'LambdaRestApi defines a REST API with Lambda proxy integration'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + const handler = new lambda.Function(stack, 'handler', { + handler: 'index.handler', + code: lambda.Code.inline('boom'), + runtime: lambda.Runtime.NodeJS610, + }); + + // WHEN + new apigw.LambdaRestApi(stack, 'lambda-rest-api', { handler, proxyPath: '/' }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::Resource', { + "PathPart": "{proxy+}" + })); + + expect(stack).to(haveResource('AWS::ApiGateway::Method', { + "HttpMethod": "ANY", + "ResourceId": { + "Ref": "lambdarestapiproxyE3AE07E3" + }, + "RestApiId": { + "Ref": "lambdarestapiAAD10924" + }, + "AuthorizationType": "NONE", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn", + ":", + { + "Ref": "AWS::Partition" + }, + ":", + "apigateway", + ":", + { + "Ref": "AWS::Region" + }, + ":", + "lambda", + ":", + "path", + "/", + { + "Fn::Join": [ + "", + [ + "2015-03-31/functions/", + { + "Fn::GetAtt": [ + "handlerE1533BD5", + "Arn" + ] + }, + "/invocations" + ] + ] + } + ] + ] + } + } + })); + + test.done(); + }, + + 'proxyPath can be used to attach the proxy to any route'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + const handler = new lambda.Function(stack, 'handler', { + handler: 'index.handler', + code: lambda.Code.inline('boom'), + runtime: lambda.Runtime.NodeJS610, + }); + + // WHEN + new apigw.LambdaRestApi(stack, 'lambda-rest-api', { + handler, + proxyPath: '/backend/v2' + }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::Method', { + "ResourceId": { + "Ref": "lambdarestapibackendv2proxyC4980BD5" + } + })); + + test.done(); + }, + + 'when "proxyPath" is not specified, users need to define the model'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + const handler = new lambda.Function(stack, 'handler', { + handler: 'index.handler', + code: lambda.Code.inline('boom'), + runtime: lambda.Runtime.NodeJS610, + }); + + // WHEN + const api = new apigw.LambdaRestApi(stack, 'lambda-rest-api', { handler }); + + const tasks = api.root.addResource('tasks'); + tasks.addMethod('GET'); + tasks.addMethod('POST'); + + // THEN + expect(stack).notTo(haveResource('AWS::ApiGateway::Resource', { + "PathPart": "{proxy+}" + })); + + expect(stack).to(haveResource('AWS::ApiGateway::Resource', { + PathPart: 'tasks' + })); + + expect(stack).to(haveResource('AWS::ApiGateway::Method', { + HttpMethod: 'GET', + ResourceId: { Ref: 'lambdarestapitasks224418C8' } + })); + + expect(stack).to(haveResource('AWS::ApiGateway::Method', { + HttpMethod: 'POST', + ResourceId: { Ref: 'lambdarestapitasks224418C8' } + })); + + test.done(); + }, + + 'fails if options.defaultIntegration is also set'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + const handler = new lambda.Function(stack, 'handler', { + handler: 'index.handler', + code: lambda.Code.inline('boom'), + runtime: lambda.Runtime.NodeJS610, + }); + + test.throws(() => new apigw.LambdaRestApi(stack, 'lambda-rest-api', { + handler, + options: { defaultIntegration: new apigw.HttpIntegration('https://foo/bar') } + }), /Cannot specify \"options\.defaultIntegration\" since Lambda integration is automatically defined/); + + test.done(); + } +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/test.method.ts b/packages/@aws-cdk/aws-apigateway/test/test.method.ts index 99877d8041d54..031d4f748741a 100644 --- a/packages/@aws-cdk/aws-apigateway/test/test.method.ts +++ b/packages/@aws-cdk/aws-apigateway/test/test.method.ts @@ -197,7 +197,7 @@ export = { // WHEN + THEN test.throws(() => method.methodArn, - /There is no stage associated with this restApi. Either use `autoDeploy` or explicitly assign `deploymentStage`/); + /Unable to determine ARN for method "my-method" since there is no stage associated with this API./); test.done(); }, diff --git a/packages/@aws-cdk/aws-apigateway/test/test.resource.ts b/packages/@aws-cdk/aws-apigateway/test/test.resource.ts new file mode 100644 index 0000000000000..aa1441868a08f --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/test.resource.ts @@ -0,0 +1,138 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import apigw = require('../lib'); + +// tslint:disable:object-literal-key-quotes + +export = { + 'ProxyResource defines a "{proxy+}" resource with ANY method'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const api = new apigw.RestApi(stack, 'api'); + + // WHEN + new apigw.ProxyResource(stack, 'proxy', { + parent: api.root, + }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::Resource', { + "ParentId": { + "Fn::GetAtt": [ + "apiC8550315", + "RootResourceId" + ] + }, + "PathPart": "{proxy+}", + "RestApiId": { + "Ref": "apiC8550315" + } + })); + + expect(stack).to(haveResource('AWS::ApiGateway::Method', { + "HttpMethod": "ANY", + "ResourceId": { + "Ref": "proxy3A1DA9C7" + }, + "RestApiId": { + "Ref": "apiC8550315" + }, + "AuthorizationType": "NONE", + "Integration": { + "Type": "MOCK" + } + })); + + test.done(); + }, + + 'if "anyMethod" is false, then an ANY method will not be defined'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const api = new apigw.RestApi(stack, 'api'); + + // WHEN + const proxy = new apigw.ProxyResource(stack, 'proxy', { + parent: api.root, + anyMethod: false + }); + + proxy.addMethod('GET'); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::Resource')); + expect(stack).to(haveResource('AWS::ApiGateway::Method', { "HttpMethod": "GET" })); + expect(stack).notTo(haveResource('AWS::ApiGateway::Method', { "HttpMethod": "ANY" })); + + test.done(); + }, + + 'addProxy can be used on any resource to attach a proxy from that route'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const api = new apigw.RestApi(stack, 'api', { + deploy: false, + cloudWatchRole: false, + }); + + const v2 = api.root.addResource('v2'); + v2.addProxy(); + + expect(stack).toMatch({ + "Resources": { + "apiC8550315": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "api" + } + }, + "apiv25206B108": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Fn::GetAtt": [ + "apiC8550315", + "RootResourceId" + ] + }, + "PathPart": "v2", + "RestApiId": { + "Ref": "apiC8550315" + } + } + }, + "apiv2proxyAEA4DAC8": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Ref": "apiv25206B108" + }, + "PathPart": "{proxy+}", + "RestApiId": { + "Ref": "apiC8550315" + } + } + }, + "apiv2proxyANY889F4CE1": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "ResourceId": { + "Ref": "apiv2proxyAEA4DAC8" + }, + "RestApiId": { + "Ref": "apiC8550315" + }, + "AuthorizationType": "NONE", + "Integration": { + "Type": "MOCK" + } + } + } + } + }); + + test.done(); + } +}; \ No newline at end of file