Skip to content

Commit

Permalink
feat(apigateway): support custom domain names
Browse files Browse the repository at this point in the history
Adds support for custom domain names in API Gateway and using route53 aliases.

Fixes #3103

Misc: change the awslint rule that verifies resource attributes to use CloudFormation attribute names.
  • Loading branch information
Elad Ben-Israel committed Jun 30, 2019
1 parent 12e6380 commit 8c42a2b
Show file tree
Hide file tree
Showing 23 changed files with 1,223 additions and 37 deletions.
51 changes: 49 additions & 2 deletions packages/@aws-cdk/aws-apigateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,10 +398,57 @@ to allow users revert the stage to an old deployment manually.
[Deployment]: https://docs.aws.amazon.com/apigateway/api-reference/resource/deployment/
[Stage]: https://docs.aws.amazon.com/apigateway/api-reference/resource/stage/

### Missing Features
### Custom Domains

To associate an API with a custom domain, use the `domainName` configuration when
you define your API:

### Roadmap
```ts
const domain = new apigw.RestApi(this, 'MyDomain', {
domainName: {
domainName: 'example.com',
certificate: acmCertificateForExampleCom,
},
});
```

This will define a `DomainName` resource for you, along with a `BasePathMapping`
from the root of the domain to the deployment stage of the API. This is a common
set up.

You can customize this by defining a `DomainName` resource directly:

```ts
new apigw.DomainName(this, 'custom-domain', {
domainName: 'example.com',
certificate: acmCertificateForExampleCom,
endpointType: apigw.EndpointType.EDGE // default is REGIONAL
});
```

Once you have a domain, you can map base paths of the domain to APIs.
The following example will map the URL https://example.com/go-to-api1
to the `api1` API and https://example.com/boom to the `api2` API.

```ts
domain.addBasePathMapping(api1, { basePath: 'go-to-api1' });
domain.addBasePathMapping(api2, { basePath: 'boom' });
```

NOTE: currently, the mapping will always be assigned to the APIs
`deploymentStage`, which will automatically assigned to the latest API
deployment. Raise a GitHub issue if you require more granular control over
mapping base paths to stages.

If you don't specify `basePath`, all URLs under this domain will be mapped
to the API, and you won't be able to map another API to the same domain:

```ts
domain.addBasePathMapping(api);
```

This can also be achieved through the `mapping` configuration when defining the
domain as demonstrated above.


----
Expand Down
60 changes: 60 additions & 0 deletions packages/@aws-cdk/aws-apigateway/lib/base-path-mapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Construct, Resource, Token } from '@aws-cdk/core';
import { CfnBasePathMapping } from './apigateway.generated';
import { IDomainName } from './domain-name';
import { IRestApi, RestApi } from './restapi';

export interface BasePathMappingOptions {
/**
* The base path name that callers of the API must provide in the URL after
* the domain name (e.g. `example.com/base-path`). If you specify this
* property, it can't be an empty string.
*
* @default - map requests from the domain root (e.g. `example.com`). If this
* is undefined, no additional mappings will be allowed on this domain name.
*/
readonly basePath?: string;
}

export interface BasePathMappingProps extends BasePathMappingOptions {
/**
* The DomainName to associate with this base path mapping.
*/
readonly domainName: IDomainName;

/**
* The RestApi resource to target.
*/
readonly restApi: IRestApi;
}

/**
* This resource creates a base path that clients who call your API must use in
* the invocation URL.
*
* In most cases, you will probably want to use
* `DomainName.addBasePathMapping()` to define mappings.
*/
export class BasePathMapping extends Resource {
constructor(scope: Construct, id: string, props: BasePathMappingProps) {
super(scope, id);

if (props.basePath && !Token.isUnresolved(props.basePath)) {
if (!props.basePath.match(/^[a-z0-9$_.+!*'()-]+$/)) {
throw new Error(`A base path may only contain letters, numbers, and one of "$-_.+!*'()", received: ${props.basePath}`);
}
}

// if this is an owned API and it has a deployment stage, map all requests
// to that stage. otherwise, the stage will have to be specified in the URL.
const stage = props.restApi instanceof RestApi
? props.restApi.deploymentStage
: undefined;

new CfnBasePathMapping(this, 'Resource', {
basePath: props.basePath,
domainName: props.domainName.domainName,
restApiId: props.restApi.restApiId,
stage: stage && stage.stageName,
});
}
}
141 changes: 141 additions & 0 deletions packages/@aws-cdk/aws-apigateway/lib/domain-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import acm = require('@aws-cdk/aws-certificatemanager');
import { Construct, IResource, Resource } from '@aws-cdk/core';
import { CfnDomainName } from './apigateway.generated';
import { BasePathMapping, BasePathMappingOptions } from './base-path-mapping';
import { EndpointType, IRestApi} from './restapi';

export interface DomainNameOptions {
/**
* The custom domain name for your API. Uppercase letters are not supported.
*/
readonly domainName: string;

/**
* The reference to an AWS-managed certificate for use by the edge-optimized
* endpoint for the domain name. For "EDGE" domain names, the certificate
* needs to be in the US East (N. Virginia) region.
*/
readonly certificate: acm.ICertificate;

/**
* The type of endpoint for this DomainName.
* @default REGIONAL
*/
readonly endpointType?: EndpointType;
}

export interface DomainNameProps extends DomainNameOptions {
/**
* If specified, all requests to this domain will be mapped to the production
* deployment of this API. If you wish to map this domain to multiple APIs
* with different base paths, don't specify this option and use
* `addBasePathMapping`.
*
* @default - you will have to call `addBasePathMapping` to map this domain to
* API endpoints.
*/
readonly mapping?: IRestApi;
}

export interface IDomainName extends IResource {
/**
* The domain name (e.g. `example.com`)
*
* @attribute DomainName
*/
readonly domainName: string;

/**
* The Route53 alias target to use in order to connect a record set to this domain through an alias.
*
* @attribute DistributionDomainName,RegionalDomainName
*/
readonly domainNameAliasDomainName: string;

/**
* Thje Route53 hosted zone ID to use in order to connect a record set to this domain through an alias.
*
* @attribute DistributionHostedZoneId,RegionalHostedZoneId
*/
readonly domainNameAliasHostedZoneId: string;
}

export class DomainName extends Resource implements IDomainName {

/**
* Imports an existing domain name.
*/
public static fromDomainNameAttributes(scope: Construct, id: string, attrs: DomainNameAttributes): IDomainName {
class Import extends Resource implements IDomainName {
public readonly domainName = attrs.domainName;
public readonly domainNameAliasDomainName = attrs.domainNameAliasTarget;
public readonly domainNameAliasHostedZoneId = attrs.domainNameAliasHostedZoneId;
}

return new Import(scope, id);
}

public readonly domainName: string;
public readonly domainNameAliasDomainName: string;
public readonly domainNameAliasHostedZoneId: string;

constructor(scope: Construct, id: string, props: DomainNameProps) {
super(scope, id);

const endpointType = props.endpointType || EndpointType.REGIONAL;
const edge = endpointType === EndpointType.EDGE;

const resource = new CfnDomainName(this, 'Resource', {
domainName: props.domainName,
certificateArn: edge ? props.certificate.certificateArn : undefined,
regionalCertificateArn: edge ? undefined : props.certificate.certificateArn,
endpointConfiguration: { types: [endpointType] },
});

this.domainName = resource.ref;

this.domainNameAliasDomainName = edge
? resource.attrDistributionDomainName
: resource.attrRegionalDomainName;

this.domainNameAliasHostedZoneId = edge
? resource.attrDistributionHostedZoneId
: resource.attrRegionalHostedZoneId;

if (props.mapping) {
this.addBasePathMapping(props.mapping);
}
}

/**
* Maps this domain to an API endpoint.
* @param targetApi That target API endpoint, requests will be mapped to the deployment stage.
* @param options Options for mapping to base path with or without a stage
*/
public addBasePathMapping(targetApi: IRestApi, options: BasePathMappingOptions = { }) {
const basePath = options.basePath || '/';
const id = `Map:${basePath}=>${targetApi.node.uniqueId}`;
return new BasePathMapping(this, id, {
domainName: this,
restApi: targetApi,
...options
});
}
}

export interface DomainNameAttributes {
/**
* The domain name (e.g. `example.com`)
*/
readonly domainName: string;

/**
* The Route53 alias target to use in order to connect a record set to this domain through an alias.
*/
readonly domainNameAliasTarget: string;

/**
* Thje Route53 hosted zone ID to use in order to connect a record set to this domain through an alias.
*/
readonly domainNameAliasHostedZoneId: string;
}
2 changes: 2 additions & 0 deletions packages/@aws-cdk/aws-apigateway/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export * from './model';
export * from './requestvalidator';
export * from './authorizer';
export * from './json-schema';
export * from './domain-name';
export * from './base-path-mapping';

// AWS::ApiGateway CloudFormation Resources:
export * from './apigateway.generated';
Expand Down
15 changes: 8 additions & 7 deletions packages/@aws-cdk/aws-apigateway/lib/lambda-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Method } from './method';
import { ProxyResource, Resource } from './resource';
import { RestApi, RestApiProps } from './restapi';

export interface LambdaRestApiProps {
export interface LambdaRestApiProps extends RestApiProps {
/**
* The default Lambda function that handles all requests from this API.
*
Expand All @@ -25,9 +25,9 @@ export interface LambdaRestApiProps {
readonly proxy?: boolean;

/**
* Further customization of the REST API.
*
* @default defaults
* @deprecated the `LambdaRestApiProps` now extends `RestApiProps`, so all
* options are just available here. Note that the options specified in
* `options` will be overridden by any props specified at the root level.
*/
readonly options?: RestApiProps;
}
Expand All @@ -41,13 +41,14 @@ export interface LambdaRestApiProps {
*/
export class LambdaRestApi extends RestApi {
constructor(scope: 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`);
if ((props.options && props.options.defaultIntegration) || props.defaultIntegration) {
throw new Error(`Cannot specify "defaultIntegration" since Lambda integration is automatically defined`);
}

super(scope, id, {
defaultIntegration: new LambdaIntegration(props.handler),
...props.options
...props.options, // deprecated, but we still support
...props,
});

if (props.proxy !== false) {
Expand Down
30 changes: 30 additions & 0 deletions packages/@aws-cdk/aws-apigateway/lib/restapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CfnOutput, Construct, IResource as IResourceBase, Resource, Stack } fro
import { ApiKey, IApiKey } from './api-key';
import { CfnAccount, CfnRestApi } from './apigateway.generated';
import { Deployment } from './deployment';
import { DomainName, DomainNameOptions } from './domain-name';
import { Integration } from './integration';
import { Method, MethodOptions } from './method';
import { Model, ModelOptions } from './model';
Expand Down Expand Up @@ -147,6 +148,13 @@ export interface RestApiProps extends ResourceOptions {
* @default true
*/
readonly cloudWatchRole?: boolean;

/**
* Configure a custom domain name and map it to this API.
*
* @default - no domain name is defined, use `addDomainName` or directly define a `DomainName`.
*/
readonly domainName?: DomainNameOptions;
}

/**
Expand Down Expand Up @@ -196,6 +204,12 @@ export class RestApi extends Resource implements IRestApi {
*/
public deploymentStage: Stage;

/**
* The domain name mapped to this API, if defined through the `domainName`
* configuration prop.
*/
public readonly domainName?: DomainName;

private readonly methods = new Array<Method>();
private _latestDeployment: Deployment | undefined;

Expand Down Expand Up @@ -227,6 +241,10 @@ export class RestApi extends Resource implements IRestApi {
}

this.root = new RootResource(this, props, resource.attrRootResourceId);

if (props.domainName) {
this.domainName = this.addDomainName('CustomDomain', props.domainName);
}
}

/**
Expand Down Expand Up @@ -258,6 +276,18 @@ export class RestApi extends Resource implements IRestApi {
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 {
return new DomainName(this, id, {
...options,
mapping: this
});
}

/**
* Adds a usage plan.
*/
Expand Down
Loading

0 comments on commit 8c42a2b

Please sign in to comment.