diff --git a/packages/@aws-cdk/aws-apigatewayv2/.npmignore b/packages/@aws-cdk/aws-apigatewayv2/.npmignore index 683e3e0847e1f..4bed40b6573da 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/.npmignore +++ b/packages/@aws-cdk/aws-apigatewayv2/.npmignore @@ -4,6 +4,7 @@ *.snk !*.d.ts !*.js +**/cdk.out # Coverage coverage diff --git a/packages/@aws-cdk/aws-apigatewayv2/README.md b/packages/@aws-cdk/aws-apigatewayv2/README.md index a885fa9979695..cbf0958608abd 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/README.md +++ b/packages/@aws-cdk/aws-apigatewayv2/README.md @@ -21,6 +21,7 @@ - [Defining HTTP APIs](#defining-http-apis) - [Cross Origin Resource Sharing (CORS)](#cross-origin-resource-sharing-cors) - [Publishing HTTP APIs](#publishing-http-apis) + - [Custom Domain](#custom-domain) ## Introduction @@ -134,3 +135,68 @@ If you omit the `stageName` will create a `$default` stage. A `$default` stage i the API's URL - `https://{api_id}.execute-api.{region}.amazonaws.com/`. Note that, `HttpApi` will always creates a `$default` stage, unless the `createDefaultStage` property is unset. + + + +### Custom Domain + +Custom domain names are simpler and more intuitive URLs that you can provide to your API users. Custom domain name are associated to API stages. + +The code snippet below creates a custom domain and configures a default domain mapping for your API that maps the +custom domain to the `$default` stage of the API. + +```ts +const certArn = 'arn:aws:acm:us-east-1:111111111111:certificate'; +const domainName = 'example.com'; + +const dn = new DomainName(stack, 'DN', { + domainName, + certificate: acm.Certificate.fromCertificateArn(stack, 'cert', certArn), +}); + +const api = new HttpApi(stack, 'HttpProxyProdApi', { + defaultIntegration: new LambdaProxyIntegration({ handler }), + // https://${dn.domainName} goes to prodApi $default stage + defaultDomainMapping: { + domainName: dn, + mappingKey: '/', + }, +}); +``` + +To associate a specifc `Stage` to a custom domain mapping - + +```ts +api.addStage('beta', { + stageName: 'beta', + autoDeploy: true, + // https://${dn.domainName}/beta goes to the beta stage + domainMapping: { + domainName: dn, + mappingKey: 'beta', + }, +}); +``` + +The same domain name can be associated with stages across different `HttpApi` as so - + +```ts +const apiDemo = new HttpApi(stack, 'DemoApi', { + defaultIntegration: new LambdaProxyIntegration({ handler }), + // https://${dn.domainName}/demo goes to apiDemo $default stage + defaultDomainMapping: { + domainName: dn, + mappingKey: 'demo', + }, +}); +``` + +The `mappingKey` determines the `path` of the URL with the custom domain. Each custom domain is only allowed +to have one API mapping with the root(/) `mappingKey`. In the sample above, the custom domain is associated +with 3 API mapping resources across different APIs and Stages. + +| API | Stage | URL | +| :------------: | :---------: | :----: | +| api | $default | `https://${domainName}` | +| api | beta | `https://${domainName}/beta` | +| apiDemo | $default | `https://${domainName}/demo` | diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/api-mapping.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/api-mapping.ts new file mode 100644 index 0000000000000..d843b51f8b315 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/api-mapping.ts @@ -0,0 +1,13 @@ +import { IResource } from '@aws-cdk/core'; + +/** + * Represents an ApiGatewayV2 ApiMapping resource + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigatewayv2-apimapping.html + */ +export interface IApiMapping extends IResource { + /** + * ID of the api mapping + * @attribute + */ + readonly apiMappingId: string; +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/domain-name.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/domain-name.ts new file mode 100644 index 0000000000000..93234807bbf09 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/domain-name.ts @@ -0,0 +1,117 @@ +import { ICertificate } from '@aws-cdk/aws-certificatemanager'; +import { Construct, IResource, Resource, Token } from '@aws-cdk/core'; +import { CfnDomainName, CfnDomainNameProps } from '../apigatewayv2.generated'; + +/** + * Represents an APIGatewayV2 DomainName + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigatewayv2-domainname.html + */ +export interface IDomainName extends IResource { + /** + * The custom domain name + * + * @attribute + * + */ + readonly domainName: string; + + /** + * The domain name associated with the regional endpoint for this custom domain name. + * + * @attribute + */ + readonly regionalDomainName: string; + + /** + * The region-specific Amazon Route 53 Hosted Zone ID of the regional endpoint. + * + * @attribute + */ + readonly regionalHostedZoneId: string; +} + +/** + * custom domain name attributes + */ +export interface DomainNameAttributes { + /** + * domain name string + */ + readonly domainName: string; + + /** + * The domain name associated with the regional endpoint for this custom domain name. + */ + readonly regionalDomainName: string; + + /** + * The region-specific Amazon Route 53 Hosted Zone ID of the regional endpoint. + */ + readonly regionalHostedZoneId: string; +} + +/** + * properties used for creating the DomainName + */ +export interface DomainNameProps { + /** + * The custom domain name + */ + readonly domainName: string; + /** + * The ACM certificate for this domain name + */ + readonly certificate: ICertificate; +} + +/** + * Custom domain resource for the API + */ +export class DomainName extends Resource implements IDomainName { + /** + * import from attributes + */ + public static fromDomainNameAttributes(scope: Construct, id: string, attrs: DomainNameAttributes): IDomainName { + class Import extends Resource implements IDomainName { + public readonly regionalDomainName = attrs.regionalDomainName; + public readonly regionalHostedZoneId = attrs.regionalHostedZoneId; + public readonly domainName = attrs.domainName; + } + return new Import(scope, id); + } + + /** + * The custom domain name for your API in Amazon API Gateway. + * + * @attribute + */ + public readonly domainName: string; + + /** + * The domain name associated with the regional endpoint for this custom domain name. + */ + public readonly regionalDomainName: string; + + /** + * The region-specific Amazon Route 53 Hosted Zone ID of the regional endpoint. + */ + public readonly regionalHostedZoneId: string; + + constructor(scope: Construct, id: string, props: DomainNameProps) { + super(scope, id); + + const domainNameProps: CfnDomainNameProps = { + domainName: props.domainName, + domainNameConfigurations: [ + { + certificateArn: props.certificate.certificateArn, + endpointType: 'REGIONAL', + }, + ], + }; + const resource = new CfnDomainName(this, 'Resource', domainNameProps); + this.domainName = props.domainName ?? resource.ref; + this.regionalDomainName = Token.asString(resource.getAtt('RegionalDomainName')); + this.regionalHostedZoneId = Token.asString(resource.getAtt('RegionalHostedZoneId')); + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/common/index.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/common/index.ts index 5995c40125978..d727436b86c99 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/common/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/common/index.ts @@ -1,3 +1,5 @@ export * from './integration'; export * from './route'; -export * from './stage'; \ No newline at end of file +export * from './stage'; +export * from './domain-name'; +export * from './api-mapping'; diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/api-mapping.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/api-mapping.ts new file mode 100644 index 0000000000000..855c6a1f30638 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/api-mapping.ts @@ -0,0 +1,78 @@ +import { Construct, Resource } from '@aws-cdk/core'; +import { CfnApiMapping, CfnApiMappingProps } from '../apigatewayv2.generated'; +import { IApiMapping, IDomainName } from '../common'; +import { IHttpApi } from '../http/api'; +import { IHttpStage } from './stage'; + +/** + * Properties used to create the HttpApiMapping resource + */ +export interface HttpApiMappingProps { + /** + * Api mapping key. The path where this stage should be mapped to on the domain + * @default '/' + */ + readonly apiMappingKey?: string; + + /** + * The HttpApi to which this mapping is applied + */ + readonly api: IHttpApi; + + /** + * custom domain name of the mapping target + */ + readonly domainName: IDomainName; + + /** + * stage for the HttpApiMapping resource + * + * @default - the $default stage + */ + readonly stage?: IHttpStage; +} + +/** + * The attributes used to import existing HttpApiMapping + */ +export interface HttpApiMappingAttributes { + /** + * The API mapping ID + */ + readonly apiMappingId: string; +} + +/** + * Create a new API mapping for API Gateway HTTP API endpoint. + * @resource AWS::ApiGatewayV2::ApiMapping + */ +export class HttpApiMapping extends Resource implements IApiMapping { + /** + * import from API ID + */ + public static fromHttpApiMappingAttributes(scope: Construct, id: string, attrs: HttpApiMappingAttributes): IApiMapping { + class Import extends Resource implements IApiMapping { + public readonly apiMappingId = attrs.apiMappingId; + } + return new Import(scope, id); + } + /** + * ID of the API Mapping + */ + public readonly apiMappingId: string; + + constructor(scope: Construct, id: string, props: HttpApiMappingProps) { + super(scope, id); + + const apiMappingProps: CfnApiMappingProps = { + apiId: props.api.httpApiId, + domainName: props.domainName.domainName, + stage: props.stage?.stageName ?? '$default', + apiMappingKey: props.apiMappingKey, + }; + + const resource = new CfnApiMapping(this, 'Resource', apiMappingProps); + this.apiMappingId = resource.ref; + } + +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/api.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/api.ts index 7856ff5f87bf7..c8a12d35ed354 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/api.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/api.ts @@ -1,5 +1,6 @@ import { Construct, Duration, IResource, Resource } from '@aws-cdk/core'; import { CfnApi, CfnApiProps } from '../apigatewayv2.generated'; +import { DefaultDomainMappingOptions } from '../http/stage'; import { IHttpRouteIntegration } from './integration'; import { BatchHttpRouteOptions, HttpMethod, HttpRoute, HttpRouteKey } from './route'; import { HttpStage, HttpStageOptions } from './stage'; @@ -43,6 +44,13 @@ export interface HttpApiProps { * @default - CORS disabled. */ readonly corsPreflight?: CorsPreflightOptions; + + /** + * Configure a custom domain with the API mapping resource to the HTTP API + * + * @default - no default domain mapping configured. meaningless if `createDefaultStage` is `false`. + */ + readonly defaultDomainMapping?: DefaultDomainMappingOptions; } /** @@ -118,6 +126,9 @@ export class HttpApi extends Resource implements IHttpApi { } public readonly httpApiId: string; + /** + * default stage of the api resource + */ private readonly defaultStage: HttpStage | undefined; constructor(scope: Construct, id: string, props?: HttpApiProps) { @@ -166,8 +177,14 @@ export class HttpApi extends Resource implements IHttpApi { this.defaultStage = new HttpStage(this, 'DefaultStage', { httpApi: this, autoDeploy: true, + domainMapping: props?.defaultDomainMapping, }); } + + if (props?.createDefaultStage === false && props.defaultDomainMapping) { + throw new Error('defaultDomainMapping not supported with createDefaultStage disabled', + ); + } } /** @@ -182,10 +199,11 @@ export class HttpApi extends Resource implements IHttpApi { * Add a new stage. */ public addStage(id: string, options: HttpStageOptions): HttpStage { - return new HttpStage(this, id, { + const stage = new HttpStage(this, id, { httpApi: this, ...options, }); + return stage; } /** diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts index 4fbb1c4e76f6a..c42e089aa1d08 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts @@ -2,4 +2,5 @@ export * from './api'; export * from './route'; export * from './integration'; export * from './integrations'; -export * from './stage'; \ No newline at end of file +export * from './stage'; +export * from './api-mapping'; diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/stage.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/stage.ts index 1335cbf839984..8fc3e605d87b4 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/stage.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/stage.ts @@ -1,14 +1,27 @@ import { Construct, Resource, Stack } from '@aws-cdk/core'; import { CfnStage } from '../apigatewayv2.generated'; -import { CommonStageOptions, IStage } from '../common'; +import { CommonStageOptions, IDomainName, IStage } from '../common'; import { IHttpApi } from './api'; +import { HttpApiMapping } from './api-mapping'; const DEFAULT_STAGE_NAME = '$default'; +/** + * Represents the HttpStage + */ +export interface IHttpStage extends IStage { +} + /** * Options to create a new stage for an HTTP API. */ export interface HttpStageOptions extends CommonStageOptions { + /** + * The options for custom domain and api mapping + * + * @default - no custom domain and api mapping configuration + */ + readonly domainMapping?: DomainMappingOptions; } /** @@ -21,6 +34,36 @@ export interface HttpStageProps extends HttpStageOptions { readonly httpApi: IHttpApi; } +/** + * Options for defaultDomainMapping + */ +export interface DefaultDomainMappingOptions { + /** + * The domain name for the mapping + * + */ + readonly domainName: IDomainName; + + /** + * The API mapping key. Specify '/' for the root path mapping. + * + */ + readonly mappingKey: string; + +} + +/** + * Options for DomainMapping + */ +export interface DomainMappingOptions extends DefaultDomainMappingOptions { + /** + * The API Stage + * + * @default - the $default stage + */ + readonly stage?: IStage; +} + /** * Represents a stage where an instance of the API is deployed. * @resource AWS::ApiGatewayV2::Stage @@ -52,6 +95,16 @@ export class HttpStage extends Resource implements IStage { this.stageName = this.physicalName; this.httpApi = props.httpApi; + + if (props.domainMapping) { + new HttpApiMapping(this, `${props.domainMapping.domainName}${props.domainMapping.mappingKey}`, { + api: props.httpApi, + domainName: props.domainMapping.domainName, + stage: this, + apiMappingKey: props.domainMapping.mappingKey, + }); + } + } /** @@ -62,4 +115,4 @@ export class HttpStage extends Resource implements IStage { const urlPath = this.stageName === DEFAULT_STAGE_NAME ? '' : this.stageName; return `https://${this.httpApi.httpApiId}.execute-api.${s.region}.${s.urlSuffix}/${urlPath}`; } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/package.json b/packages/@aws-cdk/aws-apigatewayv2/package.json index 0920b42061902..bc22bfc9ed2f7 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/package.json +++ b/packages/@aws-cdk/aws-apigatewayv2/package.json @@ -71,6 +71,7 @@ "pkglint": "0.0.0" }, "dependencies": { + "@aws-cdk/aws-certificatemanager": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-lambda": "0.0.0", "@aws-cdk/core": "0.0.0", @@ -79,6 +80,7 @@ "peerDependencies": { "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-lambda": "0.0.0", + "@aws-cdk/aws-certificatemanager": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.0.2" }, @@ -89,7 +91,9 @@ "exclude": [ "from-method:@aws-cdk/aws-apigatewayv2.HttpIntegration", "from-method:@aws-cdk/aws-apigatewayv2.HttpRoute", + "from-method:@aws-cdk/aws-apigatewayv2.HttpStage", "props-physical-name-type:@aws-cdk/aws-apigatewayv2.HttpStageProps.stageName", + "props-physical-name:@aws-cdk/aws-apigatewayv2.HttpApiMappingProps", "props-physical-name:@aws-cdk/aws-apigatewayv2.HttpIntegrationProps", "props-physical-name:@aws-cdk/aws-apigatewayv2.HttpRouteProps" ] diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/api-mapping.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/api-mapping.test.ts new file mode 100644 index 0000000000000..d2a536ae52862 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/api-mapping.test.ts @@ -0,0 +1,89 @@ +import '@aws-cdk/assert/jest'; +import { Certificate } from '@aws-cdk/aws-certificatemanager'; +import { Stack } from '@aws-cdk/core'; +import { DomainName, HttpApi, HttpApiMapping } from '../../lib'; + +const domainName = 'example.com'; +const certArn = 'arn:aws:acm:us-east-1:111111111111:certificate'; + +describe('ApiMapping', () => { + test('default stage', () => { + + const stack = new Stack(); + const api = new HttpApi(stack, 'Api'); + + const dn = new DomainName(stack, 'DomainName', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), + }); + + new HttpApiMapping(stack, 'Mapping', { + api, + domainName: dn, + }); + + expect(stack).toHaveResource('AWS::ApiGatewayV2::ApiMapping', { + ApiId: { + Ref: 'ApiF70053CD', + }, + DomainName: 'example.com', + Stage: '$default', + }); + }); + + test('beta stage mapping', () => { + + const stack = new Stack(); + const api = new HttpApi(stack, 'Api', { + createDefaultStage: false, + }); + + const beta = api.addStage('beta', { + stageName: 'beta', + }); + + const dn = new DomainName(stack, 'DomainName', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), + }); + + new HttpApiMapping(stack, 'Mapping', { + api, + domainName: dn, + stage: beta, + apiMappingKey: 'beta', + }); + + expect(stack).toHaveResource('AWS::ApiGatewayV2::ApiMapping', { + ApiId: { + Ref: 'ApiF70053CD', + }, + DomainName: 'example.com', + Stage: 'beta', + ApiMappingKey: 'beta', + }); + }); + + test('import mapping', () => { + + const stack = new Stack(); + const api = new HttpApi(stack, 'Api'); + + const dn = new DomainName(stack, 'DomainName', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), + }); + + const mapping = new HttpApiMapping(stack, 'Mapping', { + api, + domainName: dn, + apiMappingKey: '/', + }); + + const imported = HttpApiMapping.fromHttpApiMappingAttributes(stack, 'ImportedMapping', { + apiMappingId: mapping.apiMappingId, + } ); + + expect(imported.apiMappingId).toEqual(mapping.apiMappingId); + }); +}); diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts index 3f89ff312b14f..5e88dbfe3cbf5 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts @@ -25,6 +25,15 @@ describe('HttpApi', () => { expect(api.url).toBeDefined(); }); + test('import', () => { + const stack = new Stack(); + const api = new HttpApi(stack, 'api', { apiName: 'customName' }); + const imported = HttpApi.fromApiId(stack, 'imported', api.httpApiId ); + + expect(imported.httpApiId).toEqual(api.httpApiId); + + }); + test('unsetting createDefaultStage', () => { const stack = new Stack(); const api = new HttpApi(stack, 'api', { @@ -104,4 +113,4 @@ describe('HttpApi', () => { RouteKey: 'ANY /pets', }); }); -}); \ No newline at end of file +}); diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/domain-name.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/domain-name.test.ts new file mode 100644 index 0000000000000..12d3327300df3 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/domain-name.test.ts @@ -0,0 +1,154 @@ +import '@aws-cdk/assert/jest'; +// import { expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; +import { Certificate } from '@aws-cdk/aws-certificatemanager'; +import { Stack } from '@aws-cdk/core'; +import { DomainName, HttpApi } from '../../lib'; + +const domainName = 'example.com'; +const certArn = 'arn:aws:acm:us-east-1:111111111111:certificate'; + +describe('DomainName', () => { + test('create domain name correctly', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new DomainName(stack, 'DomainName', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::DomainName', { + DomainName: 'example.com', + DomainNameConfigurations: [ + { + CertificateArn: 'arn:aws:acm:us-east-1:111111111111:certificate', + EndpointType: 'REGIONAL', + }, + ], + }); + }); + + test('import domain name correctly', () => { + // GIVEN + const stack = new Stack(); + + const dn = new DomainName(stack, 'DomainName', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), + }); + + // WHEN + const imported = DomainName.fromDomainNameAttributes(stack, 'dn', { + domainName: dn.domainName, + regionalDomainName: dn.regionalDomainName, + regionalHostedZoneId: dn.regionalHostedZoneId, + }); + + // THEN; + expect(imported.domainName).toEqual(dn.domainName); + expect(imported.regionalDomainName).toEqual(dn.regionalDomainName); + expect(imported.regionalHostedZoneId).toEqual(dn.regionalHostedZoneId); + }); + + test('addStage with domainNameMapping', () => { + // GIVEN + const stack = new Stack(); + const api = new HttpApi(stack, 'Api', { + createDefaultStage: true, + }); + + // WHEN + const dn = new DomainName(stack, 'DN', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), + }); + + api.addStage('beta', { + stageName: 'beta', + autoDeploy: true, + domainMapping: { + domainName: dn, + mappingKey: 'beta', + }, + }); + + expect(stack).toHaveResourceLike('AWS::ApiGatewayV2::DomainName', { + DomainName: 'example.com', + DomainNameConfigurations: [ + { + CertificateArn: 'arn:aws:acm:us-east-1:111111111111:certificate', + EndpointType: 'REGIONAL', + }, + ], + }); + expect(stack).toHaveResourceLike('AWS::ApiGatewayV2::ApiMapping', { + ApiId: { + Ref: 'ApiF70053CD', + }, + DomainName: 'example.com', + Stage: 'beta', + ApiMappingKey: 'beta', + }); + }); + + test('api with defaultDomainMapping', () => { + // GIVEN + const stack = new Stack(); + const dn = new DomainName(stack, 'DN', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), + }); + + // WHEN + new HttpApi(stack, 'Api', { + createDefaultStage: true, + defaultDomainMapping: { + domainName: dn, + mappingKey: '/', + }, + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::ApiGatewayV2::DomainName', { + DomainName: 'example.com', + DomainNameConfigurations: [ + { + CertificateArn: 'arn:aws:acm:us-east-1:111111111111:certificate', + EndpointType: 'REGIONAL', + }, + ], + }); + + expect(stack).toHaveResourceLike('AWS::ApiGatewayV2::ApiMapping', { + ApiId: { + Ref: 'ApiF70053CD', + }, + DomainName: 'example.com', + Stage: '$default', + }); + }); + + test('throws when defaultDomainMapping enabled with createDefaultStage disabled', () => { + // GIVEN + const stack = new Stack(); + const dn = new DomainName(stack, 'DN', { + domainName, + certificate: Certificate.fromCertificateArn(stack, 'cert', certArn), + }); + const t = () => { + new HttpApi(stack, 'Api', { + createDefaultStage: false, + defaultDomainMapping: { + domainName: dn, + mappingKey: '/', + }, + }); + }; + + // WHEN/THEN + expect(t).toThrow('defaultDomainMapping not supported with createDefaultStage disabled'); + + }); +}); diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/integ.custom-domain.expected.json b/packages/@aws-cdk/aws-apigatewayv2/test/http/integ.custom-domain.expected.json new file mode 100644 index 0000000000000..2fb796e79265c --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/integ.custom-domain.expected.json @@ -0,0 +1,348 @@ +{ + "Resources": { + "echohandlerServiceRole833A8F7A": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [{ + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + }], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [{ + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + }] + } + }, + "echohandler8F648AB2": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = async function(event, context) { return { statusCode: 200, headers: { \"content-type\": \"application/json\" }, body: JSON.stringify(event) }; };" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "echohandlerServiceRole833A8F7A", + "Arn" + ] + }, + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "echohandlerServiceRole833A8F7A" + ] + }, + "echohandlerinteghttpproxyHttpProxyProdApiDefaultRoute20082F68PermissionBE86C6B3": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "echohandler8F648AB2", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "HttpProxyProdApi368B6161" + }, + "/*/*" + ] + ] + } + } + }, + "echohandlerinteghttpproxyHttpProxyBetaApiDefaultRouteC328B302Permission40FB964B": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "echohandler8F648AB2", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "HttpProxyBetaApiBFB4DA5E" + }, + "/*/*" + ] + ] + } + } + }, + "DNFDC76583": { + "Type": "AWS::ApiGatewayV2::DomainName", + "Properties": { + "DomainName": "apigv2.demo.com", + "DomainNameConfigurations": [{ + "CertificateArn": "arn:aws:acm:us-east-1:111111111111:certificate", + "EndpointType": "REGIONAL" + }] + } + }, + "HttpProxyProdApi368B6161": { + "Type": "AWS::ApiGatewayV2::Api", + "Properties": { + "Name": "HttpProxyProdApi", + "ProtocolType": "HTTP" + } + }, + "HttpProxyProdApiDefaultRouteDefaultRouteIntegration702F0DF7": { + "Type": "AWS::ApiGatewayV2::Integration", + "Properties": { + "ApiId": { + "Ref": "HttpProxyProdApi368B6161" + }, + "IntegrationType": "AWS_PROXY", + "IntegrationUri": { + "Fn::GetAtt": [ + "echohandler8F648AB2", + "Arn" + ] + }, + "PayloadFormatVersion": "2.0" + } + }, + "HttpProxyProdApiDefaultRoute40EFC108": { + "Type": "AWS::ApiGatewayV2::Route", + "Properties": { + "ApiId": { + "Ref": "HttpProxyProdApi368B6161" + }, + "RouteKey": "$default", + "Target": { + "Fn::Join": [ + "", + [ + "integrations/", + { + "Ref": "HttpProxyProdApiDefaultRouteDefaultRouteIntegration702F0DF7" + } + ] + ] + } + } + }, + "HttpProxyProdApiDefaultStage0038B180": { + "Type": "AWS::ApiGatewayV2::Stage", + "Properties": { + "ApiId": { + "Ref": "HttpProxyProdApi368B6161" + }, + "StageName": "$default", + "AutoDeploy": true + } + }, + "HttpProxyProdApiDefaultStageinteghttpproxyDN4CD83A2F": { + "Type": "AWS::ApiGatewayV2::ApiMapping", + "Properties": { + "ApiId": { + "Ref": "HttpProxyProdApi368B6161" + }, + "ApiMappingKey": "/", + "DomainName": "apigv2.demo.com", + "Stage": "$default" + } + }, + "HttpProxyProdApitesting225373A0": { + "Type": "AWS::ApiGatewayV2::Stage", + "Properties": { + "ApiId": { + "Ref": "HttpProxyProdApi368B6161" + }, + "StageName": "testing", + "AutoDeploy": true + } + }, + "HttpProxyProdApitestinginteghttpproxyDNtestingBEBAEF7B": { + "Type": "AWS::ApiGatewayV2::ApiMapping", + "Properties": { + "ApiId": { + "Ref": "HttpProxyProdApi368B6161" + }, + "DomainName": "apigv2.demo.com", + "Stage": "testing", + "ApiMappingKey": "testing" + } + }, + "HttpProxyBetaApiBFB4DA5E": { + "Type": "AWS::ApiGatewayV2::Api", + "Properties": { + "Name": "HttpProxyBetaApi", + "ProtocolType": "HTTP" + } + }, + "HttpProxyBetaApiDefaultRouteDefaultRouteIntegration24A25241": { + "Type": "AWS::ApiGatewayV2::Integration", + "Properties": { + "ApiId": { + "Ref": "HttpProxyBetaApiBFB4DA5E" + }, + "IntegrationType": "AWS_PROXY", + "IntegrationUri": { + "Fn::GetAtt": [ + "echohandler8F648AB2", + "Arn" + ] + }, + "PayloadFormatVersion": "2.0" + } + }, + "HttpProxyBetaApiDefaultRoute12DC547F": { + "Type": "AWS::ApiGatewayV2::Route", + "Properties": { + "ApiId": { + "Ref": "HttpProxyBetaApiBFB4DA5E" + }, + "RouteKey": "$default", + "Target": { + "Fn::Join": [ + "", + [ + "integrations/", + { + "Ref": "HttpProxyBetaApiDefaultRouteDefaultRouteIntegration24A25241" + } + ] + ] + } + } + }, + "HttpProxyBetaApiDefaultStage4890F8A1": { + "Type": "AWS::ApiGatewayV2::Stage", + "Properties": { + "ApiId": { + "Ref": "HttpProxyBetaApiBFB4DA5E" + }, + "StageName": "$default", + "AutoDeploy": true + } + }, + "HttpProxyBetaApiDefaultStageinteghttpproxyDNbeta0904192E": { + "Type": "AWS::ApiGatewayV2::ApiMapping", + "Properties": { + "ApiId": { + "Ref": "HttpProxyBetaApiBFB4DA5E" + }, + "ApiMappingKey": "beta", + "DomainName": "apigv2.demo.com", + "Stage": "$default" + } + } + }, + "Outputs": { + "RegionalDomainName": { + "Value": { + "Fn::GetAtt": [ + "DNFDC76583", + "RegionalDomainName" + ] + } + }, + "DomainName": { + "Value": "apigv2.demo.com" + }, + "CustomUDomainURL": { + "Value": "https://apigv2.demo.com" + }, + "ProdApiEndpoint": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "HttpProxyProdApi368B6161" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/" + ] + ] + } + }, + "BetaApiEndpoint": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "HttpProxyBetaApiBFB4DA5E" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/" + ] + ] + } + }, + "Region": { + "Value": { + "Ref": "AWS::Region" + } + } + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/integ.custom-domain.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/integ.custom-domain.ts new file mode 100644 index 0000000000000..4064a256d961f --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/integ.custom-domain.ts @@ -0,0 +1,57 @@ +import * as acm from '@aws-cdk/aws-certificatemanager'; +import * as lambda from '@aws-cdk/aws-lambda'; +import { App, CfnOutput, Stack } from '@aws-cdk/core'; +import { DomainName, HttpApi, LambdaProxyIntegration } from '../../lib'; + +const app = new App(); + +const stack = new Stack(app, 'integ-http-proxy'); + +const handler = new lambda.Function(stack, 'echohandler', { + runtime: lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + code: new lambda.InlineCode('exports.handler = async function(event, context) { return { statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify(event) }; };'), +}); + +const certArn = 'arn:aws:acm:us-east-1:111111111111:certificate'; +const domainName = 'apigv2.demo.com'; + +const dn = new DomainName(stack, 'DN', { + domainName, + certificate: acm.Certificate.fromCertificateArn(stack, 'cert', certArn), +}); + +const prodApi = new HttpApi(stack, 'HttpProxyProdApi', { + defaultIntegration: new LambdaProxyIntegration({ handler }), + // https://${dn.domainName} goes to prodApi $default stage + defaultDomainMapping: { + domainName: dn, + mappingKey: '/', + }, +}); + +const betaApi = new HttpApi(stack, 'HttpProxyBetaApi', { + defaultIntegration: new LambdaProxyIntegration({ handler }), + // https://${dn.domainName}/beta goes to betaApi $default stage + defaultDomainMapping: { + domainName: dn, + mappingKey: 'beta', + }, +}); + +prodApi.addStage('testing', { + stageName: 'testing', + autoDeploy: true, + // https://${dn.domainName}/testing goes to prodApi testing stage + domainMapping: { + domainName: dn, + mappingKey: 'testing', + }, +} ); + +new CfnOutput(stack, 'RegionalDomainName', { value: dn.regionalDomainName }); +new CfnOutput(stack, 'DomainName', { value: dn.domainName }); +new CfnOutput(stack, 'CustomUDomainURL', { value: `https://${dn.domainName}` }); +new CfnOutput(stack, 'ProdApiEndpoint', { value: prodApi.url! }); +new CfnOutput(stack, 'BetaApiEndpoint', { value: betaApi.url! }); +new CfnOutput(stack, 'Region', { value: Stack.of(stack).region}); diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/integrations/http.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/integrations/http.test.ts index cc5ed64df20eb..8f187d0ab9c78 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/integrations/http.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/integrations/http.test.ts @@ -1,7 +1,7 @@ import { ABSENT } from '@aws-cdk/assert'; import '@aws-cdk/assert/jest'; import { Duration, Stack } from '@aws-cdk/core'; -import { HttpApi, HttpMethod, HttpProxyIntegration, HttpRoute, HttpRouteKey } from '../../../lib'; +import { HttpApi, HttpIntegration, HttpIntegrationType, HttpMethod, HttpProxyIntegration, HttpRoute, HttpRouteKey, PayloadFormatVersion } from '../../../lib'; describe('HttpProxyIntegration', () => { test('default', () => { @@ -40,6 +40,40 @@ describe('HttpProxyIntegration', () => { }); }); + test('custom payload format version is allowed', () => { + const stack = new Stack(); + const api = new HttpApi(stack, 'HttpApi'); + new HttpIntegration(stack, 'HttpInteg', { + payloadFormatVersion: PayloadFormatVersion.custom('99.99'), + httpApi: api, + integrationType: HttpIntegrationType.HTTP_PROXY, + integrationUri: 'some-target-url', + }); + + expect(stack).toHaveResource('AWS::ApiGatewayV2::Integration', { + IntegrationType: 'HTTP_PROXY', + IntegrationUri: 'some-target-url', + PayloadFormatVersion: '99.99', + }); + }); + + test('HttpIntegration without payloadFormatVersion is allowed', () => { + const stack = new Stack(); + const api = new HttpApi(stack, 'HttpApi'); + new HttpIntegration(stack, 'HttpInteg', { + httpApi: api, + integrationType: HttpIntegrationType.HTTP_PROXY, + integrationUri: 'some-target-url', + }); + + expect(stack).toHaveResource('AWS::ApiGatewayV2::Integration', { + IntegrationType: 'HTTP_PROXY', + IntegrationUri: 'some-target-url', + }); + }); +}); + +describe('CORS', () => { test('CORS Configuration is correctly configured.', () => { const stack = new Stack(); new HttpApi(stack, 'HttpApi', { diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts index 25df12528e63e..f129db5186486 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts @@ -52,6 +52,29 @@ describe('HttpRoute', () => { IntegrationUri: 'some-uri', }); }); + + test('throws when path not start with /', () => { + const stack = new Stack(); + const httpApi = new HttpApi(stack, 'HttpApi'); + + expect(() => new HttpRoute(stack, 'HttpRoute', { + httpApi, + integration: new DummyIntegration(), + routeKey: HttpRouteKey.with('books', HttpMethod.GET), + })).toThrowError(/path must always start with a "\/" and not end with a "\/"/); + }); + + test('throws when path ends with /', () => { + const stack = new Stack(); + const httpApi = new HttpApi(stack, 'HttpApi'); + + expect(() => new HttpRoute(stack, 'HttpRoute', { + httpApi, + integration: new DummyIntegration(), + routeKey: HttpRouteKey.with('/books/', HttpMethod.GET), + })).toThrowError(/path must always start with a "\/" and not end with a "\/"/); + }); + }); class DummyIntegration implements IHttpRouteIntegration { diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/stage.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/stage.test.ts index 336d3a74852a4..06e7a9efbc4ce 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/stage.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/stage.test.ts @@ -19,6 +19,21 @@ describe('HttpStage', () => { }); }); + test('import', () => { + const stack = new Stack(); + const api = new HttpApi(stack, 'Api', { + createDefaultStage: false, + }); + + const stage = new HttpStage(stack, 'Stage', { + httpApi: api, + }); + + const imported = HttpStage.fromStageName(stack, 'Import', stage.stageName ); + + expect(imported.stageName).toEqual(stage.stageName); + }); + test('url returns the correct path', () => { const stack = new Stack(); const api = new HttpApi(stack, 'Api', {