Skip to content

Commit cdc5753

Browse files
authored
feat(apigateway): support multi-level paths for custom domains (#22463)
This PR adds support for multi-level paths in api mappings for custom domains. This is a unique case because in order to create multi-level mappings for RestApis (ApiGateway v1) you have to use the ApiGateway v2 API. The aws-apigatewayv2 package is currently an alpha module so this cannot depend on that module which is why I used the L1 level to implement this support. I thought about deprecating the v1 api (BasePathMapping), but that is still a valid API (and is required if you have an EDGE domain name). The experience I landed on was to mostly make it transparent to users. When users create a DomainName, it will now create either a BasePathMapping or an ApiMapping depending on whether they provide a multi-level basePath. I did have to introduce a new `addApiMapping` method since `addBasePathMapping` has a return type of `BasePathMapping`. I also removed the validation that prevented users from adding additional basePaths if a (none) basePath was already created. It seems like that limitation was removed at some point and I have added an integration test to confirm. fixes #15904 ---- ### All Submissions: * [ ] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) ### Adding new Unconventional Dependencies: * [ ] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md/#adding-new-unconventional-dependencies) ### New Features * [ ] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/main/INTEGRATION_TESTS.md)? * [ ] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 20dc5c0 commit cdc5753

15 files changed

+4245
-29
lines changed

packages/@aws-cdk/aws-apigateway/README.md

+39
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,45 @@ new route53.ARecord(this, 'CustomDomainAliasRecord', {
10831083
});
10841084
```
10851085

1086+
### Custom Domains with multi-level api mapping
1087+
1088+
Additional requirements for creating multi-level path mappings for RestApis:
1089+
1090+
(both are defaults)
1091+
1092+
- Must use `SecurityPolicy.TLS_1_2`
1093+
- DomainNames must be `EndpointType.REGIONAL`
1094+
1095+
```ts
1096+
declare const acmCertificateForExampleCom: any;
1097+
declare const restApi: apigateway.RestApi;
1098+
1099+
new apigateway.DomainName(this, 'custom-domain', {
1100+
domainName: 'example.com',
1101+
certificate: acmCertificateForExampleCom,
1102+
mapping: restApi,
1103+
basePath: 'orders/v1/api',
1104+
});
1105+
```
1106+
1107+
To then add additional mappings to a domain you can use the `addApiMapping` method.
1108+
1109+
```ts
1110+
declare const acmCertificateForExampleCom: any;
1111+
declare const restApi: apigateway.RestApi;
1112+
declare const secondRestApi: apigateway.RestApi;
1113+
1114+
const domain = new apigateway.DomainName(this, 'custom-domain', {
1115+
domainName: 'example.com',
1116+
certificate: acmCertificateForExampleCom,
1117+
mapping: restApi,
1118+
});
1119+
1120+
domain.addApiMapping(secondRestApi.deploymentStage, {
1121+
basePath: 'orders/v2/api',
1122+
});
1123+
```
1124+
10861125
## Access Logging
10871126

10881127
Access logging creates logs every time an API method is accessed. Access logs can have information on

packages/@aws-cdk/aws-apigateway/lib/domain-name.ts

+90-11
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,30 @@
1+
import * as apigwv2 from '@aws-cdk/aws-apigatewayv2';
12
import * as acm from '@aws-cdk/aws-certificatemanager';
23
import { IBucket } from '@aws-cdk/aws-s3';
34
import { IResource, Names, Resource, Token } from '@aws-cdk/core';
45
import { Construct } from 'constructs';
56
import { CfnDomainName } from './apigateway.generated';
67
import { BasePathMapping, BasePathMappingOptions } from './base-path-mapping';
78
import { EndpointType, IRestApi } from './restapi';
9+
import { IStage } from './stage';
10+
11+
/**
12+
* Options for creating an api mapping
13+
*/
14+
export interface ApiMappingOptions {
15+
/**
16+
* The api path name that callers of the API must provide in the URL after
17+
* the domain name (e.g. `example.com/base-path`). If you specify this
18+
* property, it can't be an empty string.
19+
*
20+
* If this is undefined, a mapping will be added for the empty path. Any request
21+
* that does not match a mapping will get sent to the API that has been mapped
22+
* to the empty path.
23+
*
24+
* @default - map requests from the domain root (e.g. `example.com`).
25+
*/
26+
readonly basePath?: string;
27+
}
828

929
/**
1030
* The minimum version of the SSL protocol that you want API Gateway to use for HTTPS connections.
@@ -54,8 +74,7 @@ export interface DomainNameOptions {
5474
* the domain name (e.g. `example.com/base-path`). If you specify this
5575
* property, it can't be an empty string.
5676
*
57-
* @default - map requests from the domain root (e.g. `example.com`). If this
58-
* is undefined, no additional mappings will be allowed on this domain name.
77+
* @default - map requests from the domain root (e.g. `example.com`).
5978
*/
6079
readonly basePath?: string;
6180
}
@@ -64,8 +83,7 @@ export interface DomainNameProps extends DomainNameOptions {
6483
/**
6584
* If specified, all requests to this domain will be mapped to the production
6685
* deployment of this API. If you wish to map this domain to multiple APIs
67-
* with different base paths, don't specify this option and use
68-
* `addBasePathMapping`.
86+
* with different base paths, use `addBasePathMapping` or `addApiMapping`.
6987
*
7088
* @default - you will have to call `addBasePathMapping` to map this domain to
7189
* API endpoints.
@@ -115,12 +133,15 @@ export class DomainName extends Resource implements IDomainName {
115133
public readonly domainNameAliasDomainName: string;
116134
public readonly domainNameAliasHostedZoneId: string;
117135
private readonly basePaths = new Set<string | undefined>();
136+
private readonly securityPolicy?: SecurityPolicy;
137+
private readonly endpointType: EndpointType;
118138

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

122-
const endpointType = props.endpointType || EndpointType.REGIONAL;
123-
const edge = endpointType === EndpointType.EDGE;
142+
this.endpointType = props.endpointType || EndpointType.REGIONAL;
143+
const edge = this.endpointType === EndpointType.EDGE;
144+
this.securityPolicy = props.securityPolicy;
124145

125146
if (!Token.isUnresolved(props.domainName) && /[A-Z]/.test(props.domainName)) {
126147
throw new Error(`Domain name does not support uppercase letters. Got: ${props.domainName}`);
@@ -131,7 +152,7 @@ export class DomainName extends Resource implements IDomainName {
131152
domainName: props.domainName,
132153
certificateArn: edge ? props.certificate.certificateArn : undefined,
133154
regionalCertificateArn: edge ? undefined : props.certificate.certificateArn,
134-
endpointConfiguration: { types: [endpointType] },
155+
endpointConfiguration: { types: [this.endpointType] },
135156
mutualTlsAuthentication: mtlsConfig,
136157
securityPolicy: props.securityPolicy,
137158
});
@@ -146,22 +167,54 @@ export class DomainName extends Resource implements IDomainName {
146167
? resource.attrDistributionHostedZoneId
147168
: resource.attrRegionalHostedZoneId;
148169

149-
if (props.mapping) {
170+
171+
const multiLevel = this.validateBasePath(props.basePath);
172+
if (props.mapping && !multiLevel) {
150173
this.addBasePathMapping(props.mapping, {
151174
basePath: props.basePath,
152175
});
176+
} else if (props.mapping && multiLevel) {
177+
this.addApiMapping(props.mapping.deploymentStage, {
178+
basePath: props.basePath,
179+
});
180+
}
181+
}
182+
183+
private validateBasePath(path?: string): boolean {
184+
if (this.isMultiLevel(path)) {
185+
if (this.endpointType === EndpointType.EDGE) {
186+
throw new Error('multi-level basePath is only supported when endpointType is EndpointType.REGIONAL');
187+
}
188+
if (this.securityPolicy && this.securityPolicy !== SecurityPolicy.TLS_1_2) {
189+
throw new Error('securityPolicy must be set to TLS_1_2 if multi-level basePath is provided');
190+
}
191+
return true;
153192
}
193+
return false;
194+
}
195+
196+
private isMultiLevel(path?: string): boolean {
197+
return (path?.split('/').filter(x => !!x) ?? []).length >= 2;
154198
}
155199

156200
/**
157201
* Maps this domain to an API endpoint.
202+
*
203+
* This uses the BasePathMapping from ApiGateway v1 which does not support multi-level paths.
204+
*
205+
* If you need to create a mapping for a multi-level path use `addApiMapping` instead.
206+
*
158207
* @param targetApi That target API endpoint, requests will be mapped to the deployment stage.
159208
* @param options Options for mapping to base path with or without a stage
160209
*/
161-
public addBasePathMapping(targetApi: IRestApi, options: BasePathMappingOptions = { }) {
162-
if (this.basePaths.has(undefined)) {
163-
throw new Error('This domain name already has an empty base path. No additional base paths are allowed.');
210+
public addBasePathMapping(targetApi: IRestApi, options: BasePathMappingOptions = { }): BasePathMapping {
211+
if (this.basePaths.has(options.basePath)) {
212+
throw new Error(`DomainName ${this.node.id} already has a mapping for path ${options.basePath}`);
164213
}
214+
if (this.isMultiLevel(options.basePath)) {
215+
throw new Error('BasePathMapping does not support multi-level paths. Use "addApiMapping instead.');
216+
}
217+
165218
this.basePaths.add(options.basePath);
166219
const basePath = options.basePath || '/';
167220
const id = `Map:${basePath}=>${Names.nodeUniqueId(targetApi.node)}`;
@@ -172,6 +225,32 @@ export class DomainName extends Resource implements IDomainName {
172225
});
173226
}
174227

228+
/**
229+
* Maps this domain to an API endpoint.
230+
*
231+
* This uses the ApiMapping from ApiGatewayV2 which supports multi-level paths, but
232+
* also only supports:
233+
* - SecurityPolicy.TLS_1_2
234+
* - EndpointType.REGIONAL
235+
*
236+
* @param targetStage the target API stage.
237+
* @param options Options for mapping to a stage
238+
*/
239+
public addApiMapping(targetStage: IStage, options: ApiMappingOptions = {}): void {
240+
if (this.basePaths.has(options.basePath)) {
241+
throw new Error(`DomainName ${this.node.id} already has a mapping for path ${options.basePath}`);
242+
}
243+
this.validateBasePath(options.basePath);
244+
this.basePaths.add(options.basePath);
245+
const id = `Map:${options.basePath ?? 'none'}=>${Names.nodeUniqueId(targetStage.node)}`;
246+
new apigwv2.CfnApiMapping(this, id, {
247+
apiId: targetStage.restApi.restApiId,
248+
stage: targetStage.stageName,
249+
domainName: this.domainName,
250+
apiMappingKey: options.basePath,
251+
});
252+
}
253+
175254
private configureMTLS(mtlsConfig?: MTLSConfig): CfnDomainName.MutualTlsAuthenticationProperty | undefined {
176255
if (!mtlsConfig) return undefined;
177256
return {

packages/@aws-cdk/aws-apigateway/package.json

+8
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"@aws-cdk/assertions": "0.0.0",
8585
"@aws-cdk/cdk-build-tools": "0.0.0",
8686
"@aws-cdk/integ-runner": "0.0.0",
87+
"@aws-cdk/aws-route53": "0.0.0",
8788
"@aws-cdk/cfn2ts": "0.0.0",
8889
"@aws-cdk/pkglint": "0.0.0",
8990
"@types/jest": "^27.5.2"
@@ -100,6 +101,7 @@
100101
"@aws-cdk/aws-s3": "0.0.0",
101102
"@aws-cdk/aws-s3-assets": "0.0.0",
102103
"@aws-cdk/aws-stepfunctions": "0.0.0",
104+
"@aws-cdk/aws-apigatewayv2": "0.0.0",
103105
"@aws-cdk/core": "0.0.0",
104106
"@aws-cdk/cx-api": "0.0.0",
105107
"constructs": "^10.0.0"
@@ -117,6 +119,7 @@
117119
"@aws-cdk/aws-s3": "0.0.0",
118120
"@aws-cdk/aws-s3-assets": "0.0.0",
119121
"@aws-cdk/aws-stepfunctions": "0.0.0",
122+
"@aws-cdk/aws-apigatewayv2": "0.0.0",
120123
"@aws-cdk/core": "0.0.0",
121124
"@aws-cdk/cx-api": "0.0.0",
122125
"constructs": "^10.0.0"
@@ -132,6 +135,11 @@
132135
"lib/apigatewayv2.js"
133136
]
134137
},
138+
"pkglint": {
139+
"exclude": [
140+
"no-experimental-dependencies"
141+
]
142+
},
135143
"awslint": {
136144
"exclude": [
137145
"from-method:@aws-cdk/aws-apigateway.Resource",

0 commit comments

Comments
 (0)