diff --git a/packages/@aws-cdk/aws-apigateway/README.md b/packages/@aws-cdk/aws-apigateway/README.md index 3be9cea704d17..6045d52aa95d3 100644 --- a/packages/@aws-cdk/aws-apigateway/README.md +++ b/packages/@aws-cdk/aws-apigateway/README.md @@ -19,6 +19,7 @@ running on AWS Lambda, or any web application. ## Table of Contents - [Defining APIs](#defining-apis) + - [Breaking up Methods and Resources across Stacks](#breaking-up-methods-and-resources-across-stacks) - [AWS Lambda-backed APIs](#aws-lambda-backed-apis) - [Integration Targets](#integration-targets) - [Working with models](#working-with-models) @@ -99,6 +100,18 @@ item.addMethod('GET'); // GET /items/{item} item.addMethod('DELETE', new apigateway.HttpIntegration('http://amazon.com')); ``` +### Breaking up Methods and Resources across Stacks + +It is fairly common for REST APIs with a large number of Resources and Methods to hit the [CloudFormation +limit](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cloudformation-limits.html) of 200 resources per +stack. + +To help with this, Resources and Methods for the same REST API can be re-organized across multiple stacks. A common +way to do this is to have a stack per Resource or groups of Resources, but this is not the only possible way. +The following example uses sets up two Resources '/pets' and '/books' in separate stacks using nested stacks: + +[Resources grouped into nested stacks](test/integ.restapi-import.lit.ts) + ## Integration Targets Methods are associated with backend integrations, which are invoked when this @@ -956,8 +969,20 @@ The following code creates a REST API using an external OpenAPI definition JSON const api = new apigateway.SpecRestApi(this, 'books-api', { apiDefinition: apigateway.ApiDefinition.fromAsset('path-to-file.json') }); + +const booksResource = api.root.addResource('books') +booksResource.addMethod('GET', ...); ``` +It is possible to use the `addResource()` API to define additional API Gateway Resources. + +**Note:** Deployment will fail if a Resource of the same name is already defined in the Open API specification. + +**Note:** Any default properties configured, such as `defaultIntegration`, `defaultMethodOptions`, etc. will only be +applied to Resources and Methods defined in the CDK, and not the ones defined in the spec. Use the [API Gateway +extensions to OpenAPI](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions.html) +to configure these. + There are a number of limitations in using OpenAPI definitions in API Gateway. Read the [Amazon API Gateway important notes for REST APIs](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-known-issues.html#api-gateway-known-issues-rest-apis) for more details. @@ -965,8 +990,6 @@ for more details. **Note:** When starting off with an OpenAPI definition using `SpecRestApi`, it is not possible to configure some properties that can be configured directly in the OpenAPI specification file. This is to prevent people duplication of these properties and potential confusion. -Further, it is currently also not possible to configure Methods and Resources in addition to the ones in the -specification file. ## APIGateway v2 diff --git a/packages/@aws-cdk/aws-apigateway/lib/authorizer.ts b/packages/@aws-cdk/aws-apigateway/lib/authorizer.ts index a51f30e14514c..ea414a1c43584 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/authorizer.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/authorizer.ts @@ -1,6 +1,6 @@ import { Construct, Resource, ResourceProps } from '@aws-cdk/core'; import { AuthorizationType } from './method'; -import { RestApi } from './restapi'; +import { IRestApi } from './restapi'; const AUTHORIZER_SYMBOL = Symbol.for('@aws-cdk/aws-apigateway.Authorizer'); @@ -28,7 +28,7 @@ export abstract class Authorizer extends Resource implements IAuthorizer { * Called when the authorizer is used from a specific REST API. * @internal */ - public abstract _attachToApi(restApi: RestApi): void; + public abstract _attachToApi(restApi: IRestApi): void; } /** diff --git a/packages/@aws-cdk/aws-apigateway/lib/authorizers/lambda.ts b/packages/@aws-cdk/aws-apigateway/lib/authorizers/lambda.ts index 9215c28de1e61..f79d675af1e7f 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/authorizers/lambda.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/authorizers/lambda.ts @@ -3,7 +3,7 @@ import * as lambda from '@aws-cdk/aws-lambda'; import { Construct, Duration, Lazy, Stack } from '@aws-cdk/core'; import { CfnAuthorizer } from '../apigateway.generated'; import { Authorizer, IAuthorizer } from '../authorizer'; -import { RestApi } from '../restapi'; +import { IRestApi } from '../restapi'; /** * Base properties for all lambda authorizers @@ -83,7 +83,7 @@ abstract class LambdaAuthorizer extends Authorizer implements IAuthorizer { * Attaches this authorizer to a specific REST API. * @internal */ - public _attachToApi(restApi: RestApi) { + public _attachToApi(restApi: IRestApi) { if (this.restApiId && this.restApiId !== restApi.restApiId) { throw new Error('Cannot attach authorizer to two different rest APIs'); } diff --git a/packages/@aws-cdk/aws-apigateway/lib/method.ts b/packages/@aws-cdk/aws-apigateway/lib/method.ts index 58c504ab4aa8a..a7af625a4d121 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/method.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/method.ts @@ -7,7 +7,7 @@ import { MethodResponse } from './methodresponse'; import { IModel } from './model'; import { IRequestValidator, RequestValidatorOptions } from './requestvalidator'; import { IResource } from './resource'; -import { RestApi } from './restapi'; +import { IRestApi, RestApi, RestApiBase } from './restapi'; import { validateHttpMethod } from './util'; export interface MethodOptions { @@ -159,13 +159,16 @@ export class Method extends Resource { public readonly httpMethod: string; public readonly resource: IResource; - public readonly restApi: RestApi; + /** + * The API Gateway RestApi associated with this method. + */ + public readonly api: IRestApi; constructor(scope: Construct, id: string, props: MethodProps) { super(scope, id); this.resource = props.resource; - this.restApi = props.resource.restApi; + this.api = props.resource.api; this.httpMethod = props.httpMethod.toUpperCase(); validateHttpMethod(this.httpMethod); @@ -186,12 +189,12 @@ export class Method extends Resource { } if (Authorizer.isAuthorizer(authorizer)) { - authorizer._attachToApi(this.restApi); + authorizer._attachToApi(this.api); } const methodProps: CfnMethodProps = { resourceId: props.resource.resourceId, - restApiId: this.restApi.restApiId, + restApiId: this.api.restApiId, httpMethod: this.httpMethod, operationName: options.operationName || defaultMethodOptions.operationName, apiKeyRequired: options.apiKeyRequired || defaultMethodOptions.apiKeyRequired, @@ -209,15 +212,25 @@ export class Method extends Resource { this.methodId = resource.ref; - props.resource.restApi._attachMethod(this); + if (RestApiBase._isRestApiBase(props.resource.api)) { + props.resource.api._attachMethod(this); + } - const deployment = props.resource.restApi.latestDeployment; + const deployment = props.resource.api.latestDeployment; if (deployment) { deployment.node.addDependency(resource); deployment.addToLogicalId({ method: methodProps }); } } + /** + * The RestApi associated with this Method + * @deprecated - Throws an error if this Resource is not associated with an instance of `RestApi`. Use `api` instead. + */ + public get restApi(): RestApi { + return this.resource.restApi; + } + /** * Returns an execute-api ARN for this method: * diff --git a/packages/@aws-cdk/aws-apigateway/lib/resource.ts b/packages/@aws-cdk/aws-apigateway/lib/resource.ts index 2d916780bf3e2..102a17a5cdc27 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/resource.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/resource.ts @@ -4,7 +4,7 @@ import { Cors, CorsOptions } from './cors'; import { Integration } from './integration'; import { MockIntegration } from './integrations'; import { Method, MethodOptions } from './method'; -import { RestApi } from './restapi'; +import { IRestApi, RestApi } from './restapi'; export interface IResource extends IResourceBase { /** @@ -12,6 +12,13 @@ export interface IResource extends IResourceBase { */ readonly parentResource?: IResource; + /** + * The rest API that this resource is part of. + * + * @deprecated - Throws an error if this Resource is not associated with an instance of `RestApi`. Use `api` instead. + */ + readonly restApi: RestApi; + /** * The rest API that this resource is part of. * @@ -20,7 +27,7 @@ export interface IResource extends IResourceBase { * hash to determine the ID of the deployment. This allows us to automatically update * the deployment when the model of the REST API changes. */ - readonly restApi: RestApi; + readonly api: IRestApi; /** * The ID of the resource. @@ -154,7 +161,11 @@ export interface ResourceProps extends ResourceOptions { export abstract class ResourceBase extends ResourceConstruct implements IResource { public abstract readonly parentResource?: IResource; + /** + * @deprecated - Throws an error if this Resource is not associated with an instance of `RestApi`. Use `api` instead. + */ public abstract readonly restApi: RestApi; + public abstract readonly api: IRestApi; public abstract readonly resourceId: string; public abstract readonly path: string; public abstract readonly defaultIntegration?: Integration; @@ -353,6 +364,9 @@ export abstract class ResourceBase extends ResourceConstruct implements IResourc return resource.resourceForPath(parts.join('/')); } + /** + * @deprecated - Throws error in some use cases that have been enabled since this deprecation notice. Use `RestApi.urlForPath()` instead. + */ public get url(): string { return this.restApi.urlForPath(this.path); } @@ -360,7 +374,7 @@ export abstract class ResourceBase extends ResourceConstruct implements IResourc export class Resource extends ResourceBase { public readonly parentResource?: IResource; - public readonly restApi: RestApi; + public readonly api: IRestApi; public readonly resourceId: string; public readonly path: string; @@ -380,21 +394,21 @@ export class Resource extends ResourceBase { } const resourceProps: CfnResourceProps = { - restApiId: props.parent.restApi.restApiId, + restApiId: props.parent.api.restApiId, parentId: props.parent.resourceId, pathPart: props.pathPart, }; const resource = new CfnResource(this, 'Resource', resourceProps); this.resourceId = resource.ref; - this.restApi = props.parent.restApi; + this.api = props.parent.api; // render resource path (special case for root) this.path = props.parent.path; if (!this.path.endsWith('/')) { this.path += '/'; } this.path += props.pathPart; - const deployment = props.parent.restApi.latestDeployment; + const deployment = props.parent.api.latestDeployment; if (deployment) { deployment.node.addDependency(resource); deployment.addToLogicalId({ resource: resourceProps }); @@ -413,6 +427,17 @@ export class Resource extends ResourceBase { this.addCorsPreflight(this.defaultCorsPreflightOptions); } } + + /** + * The RestApi associated with this Resource + * @deprecated - Throws an error if this Resource is not associated with an instance of `RestApi`. Use `api` instead. + */ + public get restApi(): RestApi { + if (!this.parentResource) { + throw new Error('parentResource was unexpectedly not defined'); + } + return this.parentResource.restApi; + } } export interface ProxyResourceOptions extends ResourceOptions { diff --git a/packages/@aws-cdk/aws-apigateway/lib/restapi.ts b/packages/@aws-cdk/aws-apigateway/lib/restapi.ts index 5a43b562ff279..4d08a0b01ce36 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/restapi.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/restapi.ts @@ -16,12 +16,36 @@ import { IResource, ResourceBase, ResourceOptions } from './resource'; import { Stage, StageOptions } from './stage'; import { UsagePlan, UsagePlanProps } from './usage-plan'; +const RESTAPI_SYMBOL = Symbol.for('@aws-cdk/aws-apigateway.RestApiBase'); + export interface IRestApi extends IResourceBase { /** * The ID of this API Gateway RestApi. * @attribute */ readonly restApiId: string; + + /** + * The resource ID of the root resource. + * @attribute + */ + readonly restApiRootResourceId: string; + + /** + * API Gateway deployment that represents the latest changes of the API. + * This resource will be automatically updated every time the REST API model changes. + * `undefined` when no deployment is configured. + */ + readonly latestDeployment?: Deployment; + + /** + * Represents the root resource ("/") of this API. Use it to define the API model: + * + * api.root.addMethod('ANY', redirectToHomePage); // "ANY /" + * api.root.addResource('friends').addMethod('GET', getFriendsHandler); // "GET /friends" + * + */ + readonly root: IResource; } /** @@ -197,7 +221,36 @@ export interface SpecRestApiProps extends RestApiOptions { readonly apiDefinition: ApiDefinition; } -abstract class RestApiBase extends Resource implements IRestApi { +/** + * Base implementation that are common to various implementations of IRestApi + */ +export abstract class RestApiBase extends Resource implements IRestApi { + + /** + * Checks if the given object is an instance of RestApiBase. + * @internal + */ + public static _isRestApiBase(x: any): x is RestApiBase { + return x !== null && typeof(x) === 'object' && RESTAPI_SYMBOL in x; + } + + /** + * API Gateway deployment that represents the latest changes of the API. + * This resource will be automatically updated every time the REST API model changes. + * This will be undefined if `deploy` is false. + */ + public get latestDeployment() { + return this._latestDeployment; + } + + /** + * The first domain name mapped to this API, if defined through the `domainName` + * configuration prop, or added via `addDomainName` + */ + public get domainName() { + return this._domainName; + } + /** * The ID of this API Gateway RestApi. */ @@ -210,6 +263,12 @@ abstract class RestApiBase extends Resource implements IRestApi { */ public abstract readonly restApiRootResourceId: string; + /** + * Represents the root resource of this API endpoint ('/'). + * Resources and Methods are added to this resource. + */ + public abstract readonly root: IResource; + /** * API Gateway stage that points to the latest deployment (if defined). * @@ -225,6 +284,8 @@ abstract class RestApiBase extends Resource implements IRestApi { super(scope, id, { physicalName: props.restApiName || id, }); + + Object.defineProperty(this, RESTAPI_SYMBOL, { value: true }); } /** @@ -240,15 +301,6 @@ abstract class RestApiBase extends Resource implements IRestApi { return this.deploymentStage.urlForPath(path); } - /** - * API Gateway deployment that represents the latest changes of the API. - * This resource will be automatically updated every time the REST API model changes. - * This will be undefined if `deploy` is false. - */ - public get latestDeployment() { - return this._latestDeployment; - } - /** * Defines an API Gateway domain name and maps it to this API. * @param id The construct id @@ -272,14 +324,6 @@ abstract class RestApiBase extends Resource implements IRestApi { return new UsagePlan(this, id, props); } - /** - * The first domain name mapped to this API, if defined through the `domainName` - * configuration prop, or added via `addDomainName` - */ - public get domainName() { - return this._domainName; - } - /** * Gets the "execute-api" ARN * @returns The "execute-api" ARN. @@ -316,6 +360,16 @@ abstract class RestApiBase extends Resource implements IRestApi { }); } + /** + * Internal API used by `Method` to keep an inventory of methods at the API + * level for validation purposes. + * + * @internal + */ + public _attachMethod(method: Method) { + ignore(method); + } + protected configureCloudWatchRole(apiResource: CfnRestApi) { const role = new iam.Role(this, 'CloudWatchRole', { assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'), @@ -384,6 +438,8 @@ export class SpecRestApi extends RestApiBase { */ public readonly restApiRootResourceId: string; + public readonly root: IResource; + constructor(scope: Construct, id: string, props: SpecRestApiProps) { super(scope, id, props); const apiDefConfig = props.apiDefinition.bind(this); @@ -398,6 +454,7 @@ export class SpecRestApi extends RestApiBase { this.node.defaultChild = resource; this.restApiId = resource.ref; this.restApiRootResourceId = resource.attrRootResourceId; + this.root = new RootResource(this, props, this.restApiRootResourceId); this.configureDeployment(props); if (props.domainName) { @@ -411,6 +468,21 @@ export class SpecRestApi extends RestApiBase { } } +/** + * Attributes that can be specified when importing a RestApi + */ +export interface RestApiAttributes { + /** + * The ID of the API Gateway RestApi. + */ + readonly restApiId: string; + + /** + * The resource ID of the root resource. + */ + readonly rootResourceId: string; +} + /** * Represents a REST API in Amazon API Gateway. * @@ -419,34 +491,44 @@ export class SpecRestApi extends RestApiBase { * By default, the API will automatically be deployed and accessible from a * public endpoint. */ -export class RestApi extends RestApiBase implements IRestApi { +export class RestApi extends RestApiBase { + /** + * Import an existing RestApi. + */ public static fromRestApiId(scope: Construct, id: string, restApiId: string): IRestApi { class Import extends Resource implements IRestApi { public readonly restApiId = restApiId; + + public get root(): IResource { + throw new Error('root is not configured when imported using `fromRestApiId()`. Use `fromRestApiAttributes()` API instead.'); + } + + public get restApiRootResourceId(): string { + throw new Error('restApiRootResourceId is not configured when imported using `fromRestApiId()`. Use `fromRestApiAttributes()` API instead.'); + } } return new Import(scope, id); } /** - * The ID of this API Gateway RestApi. + * Import an existing RestApi that can be configured with additional Methods and Resources. + * @experimental */ + public static fromRestApiAttributes(scope: Construct, id: string, attrs: RestApiAttributes): IRestApi { + class Import extends RestApiBase { + public readonly restApiId = attrs.restApiId; + public readonly restApiRootResourceId = attrs.rootResourceId; + public readonly root: IResource = new RootResource(this, {}, this.restApiRootResourceId); + } + + return new Import(scope, id); + } + public readonly restApiId: string; - /** - * Represents the root resource ("/") of this API. Use it to define the API model: - * - * api.root.addMethod('ANY', redirectToHomePage); // "ANY /" - * api.root.addResource('friends').addMethod('GET', getFriendsHandler); // "GET /friends" - * - */ public readonly root: IResource; - /** - * The resource ID of the root resource. - * - * @attribute - */ public readonly restApiRootResourceId: string; /** @@ -613,26 +695,47 @@ export enum EndpointType { class RootResource extends ResourceBase { public readonly parentResource?: IResource; - public readonly restApi: RestApi; + public readonly api: RestApiBase; public readonly resourceId: string; public readonly path: string; public readonly defaultIntegration?: Integration | undefined; public readonly defaultMethodOptions?: MethodOptions | undefined; public readonly defaultCorsPreflightOptions?: CorsOptions | undefined; - constructor(api: RestApi, props: RestApiProps, resourceId: string) { + private readonly _restApi?: RestApi; + + constructor(api: RestApiBase, props: ResourceOptions, resourceId: string) { super(api, 'Default'); this.parentResource = undefined; this.defaultIntegration = props.defaultIntegration; this.defaultMethodOptions = props.defaultMethodOptions; this.defaultCorsPreflightOptions = props.defaultCorsPreflightOptions; - this.restApi = api; + this.api = api; this.resourceId = resourceId; this.path = '/'; + if (api instanceof RestApi) { + this._restApi = api; + } + if (this.defaultCorsPreflightOptions) { this.addCorsPreflight(this.defaultCorsPreflightOptions); } } + + /** + * Get the RestApi associated with this Resource. + * @deprecated - Throws an error if this Resource is not associated with an instance of `RestApi`. Use `api` instead. + */ + public get restApi(): RestApi { + if (!this._restApi) { + throw new Error('RestApi is not available on Resource not connected to an instance of RestApi. Use `api` instead'); + } + return this._restApi; + } } + +function ignore(_x: any) { + return; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/package.json b/packages/@aws-cdk/aws-apigateway/package.json index c3003c458ea57..6816f6cc02ab7 100644 --- a/packages/@aws-cdk/aws-apigateway/package.json +++ b/packages/@aws-cdk/aws-apigateway/package.json @@ -176,14 +176,12 @@ "docs-public-apis:@aws-cdk/aws-apigateway.Method.httpMethod", "docs-public-apis:@aws-cdk/aws-apigateway.Method.methodId", "docs-public-apis:@aws-cdk/aws-apigateway.Method.resource", - "docs-public-apis:@aws-cdk/aws-apigateway.Method.restApi", "docs-public-apis:@aws-cdk/aws-apigateway.Model", "docs-public-apis:@aws-cdk/aws-apigateway.Model.fromModelName", "docs-public-apis:@aws-cdk/aws-apigateway.RequestValidator", "docs-public-apis:@aws-cdk/aws-apigateway.RequestValidator.fromRequestValidatorId", "docs-public-apis:@aws-cdk/aws-apigateway.Resource", "docs-public-apis:@aws-cdk/aws-apigateway.ResourceBase", - "docs-public-apis:@aws-cdk/aws-apigateway.RestApi.fromRestApiId", "docs-public-apis:@aws-cdk/aws-apigateway.RestApi.arnForExecuteApi", "docs-public-apis:@aws-cdk/aws-apigateway.Stage", "docs-public-apis:@aws-cdk/aws-apigateway.Stage.restApi", diff --git a/packages/@aws-cdk/aws-apigateway/test/integ.api-definition.asset.expected.json b/packages/@aws-cdk/aws-apigateway/test/integ.api-definition.asset.expected.json index bcf74c12601fa..8946e415c6874 100644 --- a/packages/@aws-cdk/aws-apigateway/test/integ.api-definition.asset.expected.json +++ b/packages/@aws-cdk/aws-apigateway/test/integ.api-definition.asset.expected.json @@ -44,14 +44,63 @@ "Name": "my-api" } }, - "myapiDeployment92F2CB49eb6b0027bfbdb20b09988607569e06bd": { + "myapibooks51D54548": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Fn::GetAtt": [ + "myapi4C7BF186", + "RootResourceId" + ] + }, + "PathPart": "books", + "RestApiId": { + "Ref": "myapi4C7BF186" + } + } + }, + "myapibooksGETD6B2F597": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "GET", + "ResourceId": { + "Ref": "myapibooks51D54548" + }, + "RestApiId": { + "Ref": "myapi4C7BF186" + }, + "AuthorizationType": "NONE", + "Integration": { + "IntegrationResponses": [ + { + "StatusCode": "200" + } + ], + "PassthroughBehavior": "NEVER", + "RequestTemplates": { + "application/json": "{ \"statusCode\": 200 }" + }, + "Type": "MOCK" + }, + "MethodResponses": [ + { + "StatusCode": "200" + } + ] + } + }, + "myapiDeployment92F2CB49fe116fef7f552ff0fc433c9aa3930d2f": { "Type": "AWS::ApiGateway::Deployment", "Properties": { "RestApiId": { "Ref": "myapi4C7BF186" }, "Description": "Automatically created by the RestApi construct" - } + }, + "DependsOn": [ + "myapibooksGETD6B2F597", + "myapibooks51D54548" + ] }, "myapiDeploymentStageprod298F01AF": { "Type": "AWS::ApiGateway::Stage", @@ -60,7 +109,7 @@ "Ref": "myapi4C7BF186" }, "DeploymentId": { - "Ref": "myapiDeployment92F2CB49eb6b0027bfbdb20b09988607569e06bd" + "Ref": "myapiDeployment92F2CB49fe116fef7f552ff0fc433c9aa3930d2f" }, "StageName": "prod" } @@ -163,6 +212,32 @@ ] ] } + }, + "BooksURL": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "myapi4C7BF186" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "myapiDeploymentStageprod298F01AF" + }, + "/books" + ] + ] + } } }, "Parameters": { diff --git a/packages/@aws-cdk/aws-apigateway/test/integ.api-definition.asset.ts b/packages/@aws-cdk/aws-apigateway/test/integ.api-definition.asset.ts index 1b8531ccad8d5..63e6343f4de26 100644 --- a/packages/@aws-cdk/aws-apigateway/test/integ.api-definition.asset.ts +++ b/packages/@aws-cdk/aws-apigateway/test/integ.api-definition.asset.ts @@ -4,7 +4,8 @@ import * as apigateway from '../lib'; /* * Stack verification steps: - * * `curl -i ` should return HTTP code 200 + * * `curl -s -o /dev/null -w "%{http_code}" ` should return HTTP code 200 + * * `curl -s -o /dev/null -w "%{http_code}" ` should return HTTP code 200 */ const app = new cdk.App(); @@ -14,8 +15,24 @@ const api = new apigateway.SpecRestApi(stack, 'my-api', { apiDefinition: apigateway.ApiDefinition.fromAsset(path.join(__dirname, 'sample-definition.yaml')), }); +api.root.addResource('books').addMethod('GET', new apigateway.MockIntegration({ + integrationResponses: [{ + statusCode: '200', + }], + passthroughBehavior: apigateway.PassthroughBehavior.NEVER, + requestTemplates: { + 'application/json': '{ "statusCode": 200 }', + }, +}), { + methodResponses: [ { statusCode: '200' } ], +}); + new cdk.CfnOutput(stack, 'PetsURL', { value: api.urlForPath('/pets'), }); +new cdk.CfnOutput(stack, 'BooksURL', { + value: api.urlForPath('/books'), +}); + app.synth(); diff --git a/packages/@aws-cdk/aws-apigateway/test/integ.restapi-import.lit.expected.json b/packages/@aws-cdk/aws-apigateway/test/integ.restapi-import.lit.expected.json new file mode 100644 index 0000000000000..349ae37ce27c8 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/integ.restapi-import.lit.expected.json @@ -0,0 +1,334 @@ +{ + "Resources": { + "RestApi0C43BF4B": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "RestApi" + } + }, + "RestApiCloudWatchRoleE3ED6605": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" + ] + ] + } + ] + } + }, + "RestApiAccount7C83CF5A": { + "Type": "AWS::ApiGateway::Account", + "Properties": { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + "RestApiCloudWatchRoleE3ED6605", + "Arn" + ] + } + }, + "DependsOn": [ + "RestApi0C43BF4B" + ] + }, + "RestApiANYA7C1DC94": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "ResourceId": { + "Fn::GetAtt": [ + "RestApi0C43BF4B", + "RootResourceId" + ] + }, + "RestApiId": { + "Ref": "RestApi0C43BF4B" + }, + "AuthorizationType": "NONE", + "Integration": { + "Type": "MOCK" + } + } + }, + "integrestapiimportPetsStackNestedStackintegrestapiimportPetsStackNestedStackResource2B31898B": { + "Type": "AWS::CloudFormation::Stack", + "Properties": { + "TemplateURL": { + "Fn::Join": [ + "", + [ + "https://s3.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "AssetParametersc6464ef3a9925cfe5c28d912ee7fc0952eb5135b281419c8d450a3aa8825e1efS3BucketFE7B8A1B" + }, + "/", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersc6464ef3a9925cfe5c28d912ee7fc0952eb5135b281419c8d450a3aa8825e1efS3VersionKeyB80604FE" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersc6464ef3a9925cfe5c28d912ee7fc0952eb5135b281419c8d450a3aa8825e1efS3VersionKeyB80604FE" + } + ] + } + ] + } + ] + ] + }, + "Parameters": { + "referencetointegrestapiimportRootStackRestApi2647DA4CRootResourceId": { + "Fn::GetAtt": [ + "RestApi0C43BF4B", + "RootResourceId" + ] + }, + "referencetointegrestapiimportRootStackRestApi2647DA4CRef": { + "Ref": "RestApi0C43BF4B" + } + } + } + }, + "integrestapiimportBooksStackNestedStackintegrestapiimportBooksStackNestedStackResource395C2C9B": { + "Type": "AWS::CloudFormation::Stack", + "Properties": { + "TemplateURL": { + "Fn::Join": [ + "", + [ + "https://s3.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "AssetParameters480caddfb9aa669df64905982e75c672d967ce9d9ed261ee8c73f6bdcaf97141S3Bucket74F8A623" + }, + "/", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters480caddfb9aa669df64905982e75c672d967ce9d9ed261ee8c73f6bdcaf97141S3VersionKeyC855AC3B" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters480caddfb9aa669df64905982e75c672d967ce9d9ed261ee8c73f6bdcaf97141S3VersionKeyC855AC3B" + } + ] + } + ] + } + ] + ] + }, + "Parameters": { + "referencetointegrestapiimportRootStackRestApi2647DA4CRootResourceId": { + "Fn::GetAtt": [ + "RestApi0C43BF4B", + "RootResourceId" + ] + }, + "referencetointegrestapiimportRootStackRestApi2647DA4CRef": { + "Ref": "RestApi0C43BF4B" + } + } + } + }, + "integrestapiimportDeployStackNestedStackintegrestapiimportDeployStackNestedStackResource0D0EE737": { + "Type": "AWS::CloudFormation::Stack", + "Properties": { + "TemplateURL": { + "Fn::Join": [ + "", + [ + "https://s3.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "AssetParameters04407a85c5bf6d4da110e25ee35b1f67903f760cd7835965518b0f7ad37e86abS3BucketADE4C6AE" + }, + "/", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters04407a85c5bf6d4da110e25ee35b1f67903f760cd7835965518b0f7ad37e86abS3VersionKeyF36B0062" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters04407a85c5bf6d4da110e25ee35b1f67903f760cd7835965518b0f7ad37e86abS3VersionKeyF36B0062" + } + ] + } + ] + } + ] + ] + }, + "Parameters": { + "referencetointegrestapiimportRootStackRestApi2647DA4CRef": { + "Ref": "RestApi0C43BF4B" + } + } + }, + "DependsOn": [ + "integrestapiimportBooksStackNestedStackintegrestapiimportBooksStackNestedStackResource395C2C9B", + "integrestapiimportPetsStackNestedStackintegrestapiimportPetsStackNestedStackResource2B31898B" + ] + } + }, + "Outputs": { + "PetsURL": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "RestApi0C43BF4B" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".amazonaws.com/prod/pets" + ] + ] + } + }, + "BooksURL": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "RestApi0C43BF4B" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".amazonaws.com/prod/books" + ] + ] + } + } + }, + "Parameters": { + "AssetParametersc6464ef3a9925cfe5c28d912ee7fc0952eb5135b281419c8d450a3aa8825e1efS3BucketFE7B8A1B": { + "Type": "String", + "Description": "S3 bucket for asset \"c6464ef3a9925cfe5c28d912ee7fc0952eb5135b281419c8d450a3aa8825e1ef\"" + }, + "AssetParametersc6464ef3a9925cfe5c28d912ee7fc0952eb5135b281419c8d450a3aa8825e1efS3VersionKeyB80604FE": { + "Type": "String", + "Description": "S3 key for asset version \"c6464ef3a9925cfe5c28d912ee7fc0952eb5135b281419c8d450a3aa8825e1ef\"" + }, + "AssetParametersc6464ef3a9925cfe5c28d912ee7fc0952eb5135b281419c8d450a3aa8825e1efArtifactHashED1A6259": { + "Type": "String", + "Description": "Artifact hash for asset \"c6464ef3a9925cfe5c28d912ee7fc0952eb5135b281419c8d450a3aa8825e1ef\"" + }, + "AssetParameters480caddfb9aa669df64905982e75c672d967ce9d9ed261ee8c73f6bdcaf97141S3Bucket74F8A623": { + "Type": "String", + "Description": "S3 bucket for asset \"480caddfb9aa669df64905982e75c672d967ce9d9ed261ee8c73f6bdcaf97141\"" + }, + "AssetParameters480caddfb9aa669df64905982e75c672d967ce9d9ed261ee8c73f6bdcaf97141S3VersionKeyC855AC3B": { + "Type": "String", + "Description": "S3 key for asset version \"480caddfb9aa669df64905982e75c672d967ce9d9ed261ee8c73f6bdcaf97141\"" + }, + "AssetParameters480caddfb9aa669df64905982e75c672d967ce9d9ed261ee8c73f6bdcaf97141ArtifactHash1198374C": { + "Type": "String", + "Description": "Artifact hash for asset \"480caddfb9aa669df64905982e75c672d967ce9d9ed261ee8c73f6bdcaf97141\"" + }, + "AssetParameters04407a85c5bf6d4da110e25ee35b1f67903f760cd7835965518b0f7ad37e86abS3BucketADE4C6AE": { + "Type": "String", + "Description": "S3 bucket for asset \"04407a85c5bf6d4da110e25ee35b1f67903f760cd7835965518b0f7ad37e86ab\"" + }, + "AssetParameters04407a85c5bf6d4da110e25ee35b1f67903f760cd7835965518b0f7ad37e86abS3VersionKeyF36B0062": { + "Type": "String", + "Description": "S3 key for asset version \"04407a85c5bf6d4da110e25ee35b1f67903f760cd7835965518b0f7ad37e86ab\"" + }, + "AssetParameters04407a85c5bf6d4da110e25ee35b1f67903f760cd7835965518b0f7ad37e86abArtifactHash6DD5E125": { + "Type": "String", + "Description": "Artifact hash for asset \"04407a85c5bf6d4da110e25ee35b1f67903f760cd7835965518b0f7ad37e86ab\"" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/integ.restapi-import.lit.ts b/packages/@aws-cdk/aws-apigateway/test/integ.restapi-import.lit.ts new file mode 100644 index 0000000000000..bea2be6c5b05f --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/integ.restapi-import.lit.ts @@ -0,0 +1,124 @@ +import { App, CfnOutput, Construct, NestedStack, NestedStackProps, Stack } from '@aws-cdk/core'; +import { Deployment, Method, MockIntegration, PassthroughBehavior, RestApi, Stage } from '../lib'; + +/** + * This file showcases how to split up a RestApi's Resources and Methods across nested stacks. + * + * The root stack 'RootStack' first defines a RestApi. + * Two nested stacks BooksStack and PetsStack, create corresponding Resources '/books' and '/pets'. + * They are then deployed to a 'prod' Stage via a third nested stack - DeployStack. + * + * To verify this worked, go to the APIGateway + */ + +class RootStack extends Stack { + constructor(scope: Construct) { + super(scope, 'integ-restapi-import-RootStack'); + + const restApi = new RestApi(this, 'RestApi', { + deploy: false, + }); + restApi.root.addMethod('ANY'); + + const petsStack = new PetsStack(this, { + restApiId: restApi.restApiId, + rootResourceId: restApi.restApiRootResourceId, + }); + const booksStack = new BooksStack(this, { + restApiId: restApi.restApiId, + rootResourceId: restApi.restApiRootResourceId, + }); + new DeployStack(this, { + restApiId: restApi.restApiId, + methods: [ ...petsStack.methods, ...booksStack.methods ], + }); + + new CfnOutput(this, 'PetsURL', { + value: `https://${restApi.restApiId}.execute-api.${this.region}.amazonaws.com/prod/pets`, + }); + + new CfnOutput(this, 'BooksURL', { + value: `https://${restApi.restApiId}.execute-api.${this.region}.amazonaws.com/prod/books`, + }); + } +} + +interface ResourceNestedStackProps extends NestedStackProps { + readonly restApiId: string; + + readonly rootResourceId: string; +} + +class PetsStack extends NestedStack { + public readonly methods: Method[] = []; + + constructor(scope: Construct, props: ResourceNestedStackProps) { + super(scope, 'integ-restapi-import-PetsStack', props); + + const api = RestApi.fromRestApiAttributes(this, 'RestApi', { + restApiId: props.restApiId, + rootResourceId: props.rootResourceId, + }); + + const method = api.root.addResource('pets').addMethod('GET', new MockIntegration({ + integrationResponses: [{ + statusCode: '200', + }], + passthroughBehavior: PassthroughBehavior.NEVER, + requestTemplates: { + 'application/json': '{ "statusCode": 200 }', + }, + }), { + methodResponses: [ { statusCode: '200' } ], + }); + + this.methods.push(method); + } +} + +class BooksStack extends NestedStack { + public readonly methods: Method[] = []; + + constructor(scope: Construct, props: ResourceNestedStackProps) { + super(scope, 'integ-restapi-import-BooksStack', props); + + const api = RestApi.fromRestApiAttributes(this, 'RestApi', { + restApiId: props.restApiId, + rootResourceId: props.rootResourceId, + }); + + const method = api.root.addResource('books').addMethod('GET', new MockIntegration({ + integrationResponses: [{ + statusCode: '200', + }], + passthroughBehavior: PassthroughBehavior.NEVER, + requestTemplates: { + 'application/json': '{ "statusCode": 200 }', + }, + }), { + methodResponses: [ { statusCode: '200' } ], + }); + + this.methods.push(method); + } +} + +interface DeployStackProps extends NestedStackProps { + readonly restApiId: string; + + readonly methods?: Method[]; +} + +class DeployStack extends NestedStack { + constructor(scope: Construct, props: DeployStackProps) { + super(scope, 'integ-restapi-import-DeployStack', props); + + const deployment = new Deployment(this, 'Deployment', { + api: RestApi.fromRestApiId(this, 'RestApi', props.restApiId), + }); + (props.methods ?? []).forEach((method) => deployment.node.addDependency(method)); + new Stage(this, 'Stage', { deployment }); + } +} + +new RootStack(new App()); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/test.authorizer.ts b/packages/@aws-cdk/aws-apigateway/test/test.authorizer.ts index ce5a5279228e3..89b6905fddb4e 100644 --- a/packages/@aws-cdk/aws-apigateway/test/test.authorizer.ts +++ b/packages/@aws-cdk/aws-apigateway/test/test.authorizer.ts @@ -1,12 +1,12 @@ import { Stack } from '@aws-cdk/core'; import { Test } from 'nodeunit'; -import { Authorizer, RestApi } from '../lib'; +import { Authorizer, IRestApi } from '../lib'; export = { 'isAuthorizer correctly detects an instance of type Authorizer'(test: Test) { class MyAuthorizer extends Authorizer { public readonly authorizerId = 'test-authorizer-id'; - public _attachToApi(_: RestApi): void { + public _attachToApi(_: IRestApi): void { // do nothing } } diff --git a/packages/@aws-cdk/aws-apigateway/test/test.method.ts b/packages/@aws-cdk/aws-apigateway/test/test.method.ts index e4383ecf768ac..bb0d28b976094 100644 --- a/packages/@aws-cdk/aws-apigateway/test/test.method.ts +++ b/packages/@aws-cdk/aws-apigateway/test/test.method.ts @@ -928,4 +928,38 @@ export = { test.done(); }, + + '"restApi" and "api" properties return the RestApi correctly'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const api = new apigw.RestApi(stack, 'test-api'); + const method = api.root.addResource('pets').addMethod('GET'); + + // THEN + test.ok(method.restApi); + test.ok(method.api); + test.deepEqual(stack.resolve(method.api.restApiId), stack.resolve(method.restApi.restApiId)); + + test.done(); + }, + + '"restApi" throws an error on imported while "api" returns correctly'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const api = apigw.RestApi.fromRestApiAttributes(stack, 'test-api', { + restApiId: 'test-rest-api-id', + rootResourceId: 'test-root-resource-id', + }); + const method = api.root.addResource('pets').addMethod('GET'); + + // THEN + test.throws(() => method.restApi, /not available on Resource not connected to an instance of RestApi/); + test.ok(method.api); + + test.done(); + }, }; diff --git a/packages/@aws-cdk/aws-apigateway/test/test.restapi.ts b/packages/@aws-cdk/aws-apigateway/test/test.restapi.ts index d512b924cfe98..4df5bd3fd2755 100644 --- a/packages/@aws-cdk/aws-apigateway/test/test.restapi.ts +++ b/packages/@aws-cdk/aws-apigateway/test/test.restapi.ts @@ -335,18 +335,6 @@ export = { test.done(); }, - 'fromRestApiId'(test: Test) { - // GIVEN - const stack = new Stack(); - - // WHEN - const imported = apigw.RestApi.fromRestApiId(stack, 'imported-api', 'api-rxt4498f'); - - // THEN - test.deepEqual(stack.resolve(imported.restApiId), 'api-rxt4498f'); - test.done(); - }, - '"url" and "urlForPath" return the URL endpoints of the deployed API'(test: Test) { // GIVEN const stack = new Stack(); @@ -933,4 +921,102 @@ export = { test.done(); }, + + '"restApi" and "api" properties return the RestApi correctly'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + const api = new apigw.RestApi(stack, 'test-api'); + const method = api.root.addResource('pets').addMethod('GET'); + + // THEN + test.ok(method.restApi); + test.ok(method.api); + test.deepEqual(stack.resolve(method.api.restApiId), stack.resolve(method.restApi.restApiId)); + + test.done(); + }, + + '"restApi" throws an error on imported while "api" returns correctly'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + const api = apigw.RestApi.fromRestApiAttributes(stack, 'test-api', { + restApiId: 'test-rest-api-id', + rootResourceId: 'test-root-resource-id', + }); + const method = api.root.addResource('pets').addMethod('GET'); + + // THEN + test.throws(() => method.restApi, /not available on Resource not connected to an instance of RestApi/); + test.ok(method.api); + + test.done(); + }, + + 'Import': { + 'fromRestApiId()'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + const imported = apigw.RestApi.fromRestApiId(stack, 'imported-api', 'api-rxt4498f'); + + // THEN + test.deepEqual(stack.resolve(imported.restApiId), 'api-rxt4498f'); + test.done(); + }, + + 'fromRestApiAttributes()'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + const imported = apigw.RestApi.fromRestApiAttributes(stack, 'imported-api', { + restApiId: 'test-restapi-id', + rootResourceId: 'test-root-resource-id', + }); + const resource = imported.root.addResource('pets'); + resource.addMethod('GET'); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::Resource', { + PathPart: 'pets', + ParentId: stack.resolve(imported.restApiRootResourceId), + })); + expect(stack).to(haveResource('AWS::ApiGateway::Method', { + HttpMethod: 'GET', + ResourceId: stack.resolve(resource.resourceId), + })); + + test.done(); + }, + }, + + 'SpecRestApi': { + 'add Methods and Resources'(test: Test) { + // GIVEN + const stack = new Stack(); + const api = new apigw.SpecRestApi(stack, 'SpecRestApi', { + apiDefinition: apigw.ApiDefinition.fromInline({ foo: 'bar' }), + }); + + // WHEN + const resource = api.root.addResource('pets'); + resource.addMethod('GET'); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::Resource', { + PathPart: 'pets', + ParentId: stack.resolve(api.restApiRootResourceId), + })); + expect(stack).to(haveResource('AWS::ApiGateway::Method', { + HttpMethod: 'GET', + ResourceId: stack.resolve(resource.resourceId), + })); + test.done(); + }, + }, };