Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions packages/@aws-cdk/aws-apigateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -956,17 +969,27 @@ 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.

**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

Expand Down
4 changes: 2 additions & 2 deletions packages/@aws-cdk/aws-apigateway/lib/authorizer.ts
Original file line number Diff line number Diff line change
@@ -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');

Expand Down Expand Up @@ -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;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/@aws-cdk/aws-apigateway/lib/authorizers/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
}
Expand Down
28 changes: 21 additions & 7 deletions packages/@aws-cdk/aws-apigateway/lib/method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { ConnectionType, Integration } from './integration';
import { MockIntegration } from './integrations/mock';
import { MethodResponse } from './methodresponse';
import { IModel } from './model';
import { RestApiBase } from './private/restapi-base';
import { IRequestValidator, RequestValidatorOptions } from './requestvalidator';
import { IResource } from './resource';
import { RestApi } from './restapi';
import { IRestApi, RestApi } from './restapi';
import { validateHttpMethod } from './util';

export interface MethodOptions {
Expand Down Expand Up @@ -159,13 +160,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);
Expand All @@ -186,12 +190,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,
Expand All @@ -209,15 +213,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:
*
Expand Down
207 changes: 207 additions & 0 deletions packages/@aws-cdk/aws-apigateway/lib/private/restapi-base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import * as iam from '@aws-cdk/aws-iam';
import { CfnOutput, Construct, Resource, Stack } from '@aws-cdk/core';
import { CfnAccount, CfnRestApi } from '../apigateway.generated';
import { Deployment } from '../deployment';
import { DomainName, DomainNameOptions } from '../domain-name';
import { GatewayResponse, GatewayResponseOptions } from '../gateway-response';
import { Method } from '../method';
import { IResource } from '../resource';
import { IRestApi, RestApiOptions } from '../restapi';
import { Stage } from '../stage';
import { UsagePlan, UsagePlanProps } from '../usage-plan';

const RESTAPI_SYMBOL = Symbol.for('@aws-cdk/aws-apigateway.RestApiBase');

/**
* @internal
*/
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this symbol is no longer required now that we use peer dependencies. You can just use instanceof

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe this method is needed.

Copy link
Contributor Author

@nija-at nija-at Jun 11, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instanceof still does not work in some cases. See 1423c53.

symbol based matching seems more more robust.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @RomainMuller who has thoughts on why this should not be instanceof.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is enough to have ONE package in your dependency closure incorrectly modeling their dependencies for the instanceof idiom to fail. This is a surprisingly easy way to break people's code.

Additionally, the npm de-duplication logic is strictly best-effort, so it is possible that for some reason, npm decides to install multiple copies of some library, and then boom, the instanceof idiom breaks.

In my opinion, instanceof can only be safely used in JavaScript if the tested instance has been created in the same file that performs the instanceof check (guaranteeing the imported types come from the same location).

Copy link
Contributor Author

@nija-at nija-at Jun 11, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aside from the Romain's points, the error message to a customer when they don't use peer dependencies is not very informative. In some cases these are not failures either - they just result in the wrong template or state.

Using the current symbol based approach just works in all these cases.

}

/**
* 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.
*/
public abstract readonly restApiId: string;

/**
* The resource ID of the root resource.
*
* @attribute
*/
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).
*
* If `deploy` is disabled, you will need to explicitly assign this value in order to
* set up integrations.
*/
public deploymentStage!: Stage;

private _latestDeployment?: Deployment;
private _domainName?: DomainName;

constructor(scope: Construct, id: string, props: RestApiOptions = { }) {
super(scope, id, {
physicalName: props.restApiName || id,
});

Object.defineProperty(this, RESTAPI_SYMBOL, { value: true });
}

/**
* Returns the URL for an HTTP path.
*
* Fails if `deploymentStage` is not set either by `deploy` or explicitly.
*/
public urlForPath(path: string = '/'): string {
if (!this.deploymentStage) {
throw new Error('Cannot determine deployment stage for API from "deploymentStage". Use "deploy" or explicitly set "deploymentStage"');
}

return this.deploymentStage.urlForPath(path);
}

/**
* Defines an API Gateway domain name and maps it to this API.
* @param id The construct id
* @param options custom domain options
*/
public addDomainName(id: string, options: DomainNameOptions): DomainName {
const domainName = new DomainName(this, id, {
...options,
mapping: this,
});
if (!this._domainName) {
this._domainName = domainName;
}
return domainName;
}

/**
* Adds a usage plan.
*/
public addUsagePlan(id: string, props: UsagePlanProps = {}): UsagePlan {
return new UsagePlan(this, id, props);
}

/**
* Gets the "execute-api" ARN
* @returns The "execute-api" ARN.
* @default "*" returns the execute API ARN for all methods/resources in
* this API.
* @param method The method (default `*`)
* @param path The resource path. Must start with '/' (default `*`)
* @param stage The stage (default `*`)
*/
public arnForExecuteApi(method: string = '*', path: string = '/*', stage: string = '*') {
if (!path.startsWith('/')) {
throw new Error(`"path" must begin with a "/": '${path}'`);
}

if (method.toUpperCase() === 'ANY') {
method = '*';
}

return Stack.of(this).formatArn({
service: 'execute-api',
resource: this.restApiId,
sep: '/',
resourceName: `${stage}/${method}${path}`,
});
}

/**
* Adds a new gateway response.
*/
public addGatewayResponse(id: string, options: GatewayResponseOptions): GatewayResponse {
return new GatewayResponse(this, id, {
restApi: this,
...options,
});
}

/**
* 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'),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonAPIGatewayPushToCloudWatchLogs')],
});

const resource = new CfnAccount(this, 'Account', {
cloudWatchRoleArn: role.roleArn,
});

resource.node.addDependency(apiResource);
}

protected configureDeployment(props: RestApiOptions) {
const deploy = props.deploy === undefined ? true : props.deploy;
if (deploy) {

this._latestDeployment = new Deployment(this, 'Deployment', {
description: 'Automatically created by the RestApi construct',
api: this,
retainDeployments: props.retainDeployments,
});

// encode the stage name into the construct id, so if we change the stage name, it will recreate a new stage.
// stage name is part of the endpoint, so that makes sense.
const stageName = (props.deployOptions && props.deployOptions.stageName) || 'prod';

this.deploymentStage = new Stage(this, `DeploymentStage.${stageName}`, {
deployment: this._latestDeployment,
...props.deployOptions,
});

new CfnOutput(this, 'Endpoint', { exportName: props.endpointExportName, value: this.urlForPath() });
} else {
if (props.deployOptions) {
throw new Error('Cannot set \'deployOptions\' if \'deploy\' is disabled');
}
}
}
}

function ignore(_x: any) {
return;
}
Loading