From 52b136b34ad5dd14131bdf856ec57c20bc75bb19 Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Mon, 1 Jul 2019 13:27:39 +0300 Subject: [PATCH] feat(apigateway): support custom domain names (#3135) 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. --- packages/@aws-cdk/aws-apigateway/README.md | 68 ++- .../aws-apigateway/lib/base-path-mapping.ts | 60 +++ .../aws-apigateway/lib/domain-name.ts | 141 +++++ packages/@aws-cdk/aws-apigateway/lib/index.ts | 2 + .../@aws-cdk/aws-apigateway/lib/lambda-api.ts | 15 +- .../@aws-cdk/aws-apigateway/lib/restapi.ts | 30 ++ packages/@aws-cdk/aws-apigateway/package.json | 3 + .../aws-apigateway/test/test.domains.ts | 182 +++++++ .../aws-apigateway/test/test.lambda-api.ts | 7 +- packages/@aws-cdk/aws-iam/lib/lazy-role.ts | 3 +- packages/@aws-cdk/aws-kms/lib/alias.ts | 4 +- packages/@aws-cdk/aws-kms/package.json | 2 +- packages/@aws-cdk/aws-rds/lib/cluster-ref.ts | 4 +- packages/@aws-cdk/aws-rds/lib/instance.ts | 4 +- packages/@aws-cdk/aws-rds/package.json | 1 - .../lib/api-gateway-domain-name.ts | 36 ++ .../@aws-cdk/aws-route53-targets/lib/index.ts | 1 + .../@aws-cdk/aws-route53-targets/package.json | 6 +- .../test/apigateway-target.test.ts | 108 ++++ ...nteg.api-gateway-domain-name.expected.json | 481 ++++++++++++++++++ .../test/integ.api-gateway-domain-name.ts | 53 ++ .../@aws-cdk/aws-route53/lib/record-set.ts | 15 +- packages/@aws-cdk/aws-ssm/lib/parameter.ts | 4 +- packages/decdk/package.json | 2 +- tools/awslint/lib/rules/cfn-resource.ts | 2 +- tools/awslint/lib/rules/resource.ts | 56 +- 26 files changed, 1247 insertions(+), 43 deletions(-) create mode 100644 packages/@aws-cdk/aws-apigateway/lib/base-path-mapping.ts create mode 100644 packages/@aws-cdk/aws-apigateway/lib/domain-name.ts create mode 100644 packages/@aws-cdk/aws-apigateway/test/test.domains.ts create mode 100644 packages/@aws-cdk/aws-route53-targets/lib/api-gateway-domain-name.ts create mode 100644 packages/@aws-cdk/aws-route53-targets/test/apigateway-target.test.ts create mode 100644 packages/@aws-cdk/aws-route53-targets/test/integ.api-gateway-domain-name.expected.json create mode 100644 packages/@aws-cdk/aws-route53-targets/test/integ.api-gateway-domain-name.ts diff --git a/packages/@aws-cdk/aws-apigateway/README.md b/packages/@aws-cdk/aws-apigateway/README.md index c949362c5039d..d4a72af689aaa 100644 --- a/packages/@aws-cdk/aws-apigateway/README.md +++ b/packages/@aws-cdk/aws-apigateway/README.md @@ -398,11 +398,75 @@ 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 api = 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. + +To route domain traffic to an API Gateway API, use Amazon Route 53 to create an alias record. An alias record is a Route 53 extension to DNS. It's similar to a CNAME record, but you can create an alias record both for the root domain, such as example.com, and for subdomains, such as www.example.com. (You can create CNAME records only for subdomains.) + +```ts +new route53.ARecord(this, 'CustomDomainAliasRecord', { + zone: hostedZoneForExampleCom, + target: route53.AddressRecordTarget.fromAlias(new route53_targets.ApiGateway(api)) +}); +``` + +You can also define a `DomainName` resource directly in order to customize the default behavior: + +```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. + +If you wish to setup this domain with an Amazon Route53 alias, use the `route53_targets.ApiGatewayDomain`: + +```ts +new route53.ARecord(this, 'CustomDomainAliasRecord', { + zone: hostedZoneForExampleCom, + target: route53.AddressRecordTarget.fromAlias(new route53_targets.ApiGatewayDomain(domainName)) +}); +``` ---- diff --git a/packages/@aws-cdk/aws-apigateway/lib/base-path-mapping.ts b/packages/@aws-cdk/aws-apigateway/lib/base-path-mapping.ts new file mode 100644 index 0000000000000..4a307db43d90c --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/base-path-mapping.ts @@ -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, + }); + } +} diff --git a/packages/@aws-cdk/aws-apigateway/lib/domain-name.ts b/packages/@aws-cdk/aws-apigateway/lib/domain-name.ts new file mode 100644 index 0000000000000..985cb7b7b9ee8 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/domain-name.ts @@ -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; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/lib/index.ts b/packages/@aws-cdk/aws-apigateway/lib/index.ts index 781cf9c370e7f..d174e58933827 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/index.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/index.ts @@ -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'; diff --git a/packages/@aws-cdk/aws-apigateway/lib/lambda-api.ts b/packages/@aws-cdk/aws-apigateway/lib/lambda-api.ts index 4878ba0d0b90c..664ed22afa530 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/lambda-api.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/lambda-api.ts @@ -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. * @@ -25,9 +25,11 @@ export interface LambdaRestApiProps { readonly proxy?: boolean; /** - * Further customization of the REST API. + * @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. * - * @default defaults + * @default - no options. */ readonly options?: RestApiProps; } @@ -41,13 +43,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) { diff --git a/packages/@aws-cdk/aws-apigateway/lib/restapi.ts b/packages/@aws-cdk/aws-apigateway/lib/restapi.ts index 366b8186f36ed..62e1c1194d0f1 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/restapi.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/restapi.ts @@ -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'; @@ -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; } /** @@ -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(); private _latestDeployment: Deployment | undefined; @@ -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); + } } /** @@ -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. */ diff --git a/packages/@aws-cdk/aws-apigateway/package.json b/packages/@aws-cdk/aws-apigateway/package.json index fb8d18790463a..33aa9dbb4af10 100644 --- a/packages/@aws-cdk/aws-apigateway/package.json +++ b/packages/@aws-cdk/aws-apigateway/package.json @@ -77,12 +77,14 @@ "@aws-cdk/aws-elasticloadbalancingv2": "^0.36.0", "@aws-cdk/aws-iam": "^0.36.0", "@aws-cdk/aws-lambda": "^0.36.0", + "@aws-cdk/aws-certificatemanager": "^0.36.0", "@aws-cdk/core": "^0.36.0" }, "homepage": "https://github.com/awslabs/aws-cdk", "peerDependencies": { "@aws-cdk/aws-elasticloadbalancingv2": "^0.36.0", "@aws-cdk/aws-iam": "^0.36.0", + "@aws-cdk/aws-certificatemanager": "^0.36.0", "@aws-cdk/aws-lambda": "^0.36.0", "@aws-cdk/core": "^0.36.0" }, @@ -101,6 +103,7 @@ "props-physical-name:@aws-cdk/aws-apigateway.ResourceProps", "props-physical-name:@aws-cdk/aws-apigateway.UsagePlanProps", "props-physical-name-type:@aws-cdk/aws-apigateway.StageProps.stageName", + "props-physical-name:@aws-cdk/aws-apigateway.BasePathMappingProps", "props-physical-name:@aws-cdk/aws-apigateway.LambdaRestApiProps", "construct-interface-extends-iconstruct:@aws-cdk/aws-apigateway.IModel", "resource-interface-extends-resource:@aws-cdk/aws-apigateway.IModel" diff --git a/packages/@aws-cdk/aws-apigateway/test/test.domains.ts b/packages/@aws-cdk/aws-apigateway/test/test.domains.ts new file mode 100644 index 0000000000000..99eace81fdac9 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/test.domains.ts @@ -0,0 +1,182 @@ +// tslint:disable:object-literal-key-quotes +import { expect, haveResource } from '@aws-cdk/assert'; +import acm = require('@aws-cdk/aws-certificatemanager'); +import { Stack } from '@aws-cdk/core'; +import { Test } from 'nodeunit'; +import apigw = require('../lib'); + +export = { + 'can define either an EDGE or REGIONAL domain name'(test: Test) { + // GIVEN + const stack = new Stack(); + const cert = new acm.Certificate(stack, 'Cert', { domainName: 'example.com' }); + + // WHEN + const regionalDomain = new apigw.DomainName(stack, 'my-domain', { + domainName: 'example.com', + certificate: cert, + endpointType: apigw.EndpointType.REGIONAL + }); + + const edgeDomain = new apigw.DomainName(stack, 'your-domain', { + domainName: 'example.com', + certificate: cert, + endpointType: apigw.EndpointType.EDGE + }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::DomainName', { + "DomainName": "example.com", + "EndpointConfiguration": { "Types": [ "REGIONAL" ] }, + "RegionalCertificateArn": { "Ref": "Cert5C9FAEC1" } + })); + + expect(stack).to(haveResource('AWS::ApiGateway::DomainName', { + "DomainName": "example.com", + "EndpointConfiguration": { "Types": [ "EDGE" ] }, + "CertificateArn": { "Ref": "Cert5C9FAEC1" } + })); + + test.deepEqual(stack.resolve(regionalDomain.domainNameAliasDomainName), { 'Fn::GetAtt': [ 'mydomain592C948B', 'RegionalDomainName' ] }); + test.deepEqual(stack.resolve(regionalDomain.domainNameAliasHostedZoneId), { 'Fn::GetAtt': [ 'mydomain592C948B', 'RegionalHostedZoneId' ] }); + test.deepEqual(stack.resolve(edgeDomain.domainNameAliasDomainName), { 'Fn::GetAtt': [ 'yourdomain5FE30C81', 'DistributionDomainName' ] }); + test.deepEqual(stack.resolve(edgeDomain.domainNameAliasHostedZoneId), { 'Fn::GetAtt': [ 'yourdomain5FE30C81', 'DistributionHostedZoneId' ] }); + + test.done(); + }, + + 'default endpoint type is REGIONAL'(test: Test) { + // GIVEN + const stack = new Stack(); + const cert = new acm.Certificate(stack, 'Cert', { domainName: 'example.com' }); + + // WHEN + new apigw.DomainName(stack, 'my-domain', { + domainName: 'example.com', + certificate: cert, + }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::DomainName', { + "DomainName": "example.com", + "EndpointConfiguration": { "Types": [ "REGIONAL" ] }, + "RegionalCertificateArn": { "Ref": "Cert5C9FAEC1" } + })); + test.done(); + }, + + '"mapping" can be used to automatically map this domain to the deployment stage of an API'(test: Test) { + // GIVEN + const stack = new Stack(); + const api = new apigw.RestApi(stack, 'api'); + api.root.addMethod('GET'); + + // WHEN + new apigw.DomainName(stack, 'Domain', { + domainName: 'foo.com', + certificate: acm.Certificate.fromCertificateArn(stack, 'cert', 'arn:aws:acm:us-east-1:1111111:certificate/11-3336f1-44483d-adc7-9cd375c5169d'), + endpointType: apigw.EndpointType.EDGE, + mapping: api + }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::BasePathMapping', { + "DomainName": { + "Ref": "Domain66AC69E0" + }, + "RestApiId": { + "Ref": "apiC8550315" + }, + "Stage": { + "Ref": "apiDeploymentStageprod896C8101" + } + })); + test.done(); + }, + + '"addBasePathMapping" can be used to add base path mapping to the domain'(test: Test) { + // GIVEN + const stack = new Stack(); + const api1 = new apigw.RestApi(stack, 'api1'); + const api2 = new apigw.RestApi(stack, 'api2'); + const domain = new apigw.DomainName(stack, 'my-domain', { + domainName: 'example.com', + certificate: acm.Certificate.fromCertificateArn(stack, 'cert', 'arn:aws:acm:us-east-1:1111111:certificate/11-3336f1-44483d-adc7-9cd375c5169d'), + endpointType: apigw.EndpointType.REGIONAL + }); + api1.root.addMethod('GET'); + api2.root.addMethod('GET'); + + // WHEN + domain.addBasePathMapping(api1, { basePath: 'api1' }); + domain.addBasePathMapping(api2, { basePath: 'api2' }); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::BasePathMapping', { + "DomainName": { + "Ref": "mydomain592C948B" + }, + "BasePath": "api1", + "RestApiId": { + "Ref": "api1A91238E2" + }, + "Stage": { + "Ref": "api1DeploymentStageprod362746F6" + } + })); + + expect(stack).to(haveResource('AWS::ApiGateway::BasePathMapping', { + "DomainName": { + "Ref": "mydomain592C948B" + }, + "BasePath": "api2", + "RestApiId": { + "Ref": "api2C4850CEA" + }, + "Stage": { + "Ref": "api2DeploymentStageprod4120D74E" + } + })); + test.done(); + }, + + 'a domain name can be defined with the API'(test: Test) { + // GIVEN + const domainName = 'my.domain.com'; + const stack = new Stack(); + const certificate = new acm.Certificate(stack, 'cert', { domainName: 'my.domain.com' }); + + // WHEN + const api = new apigw.RestApi(stack, 'api', { + domainName: { domainName, certificate } + }); + + api.root.addMethod('GET'); + + // THEN + expect(stack).to(haveResource('AWS::ApiGateway::DomainName', { + "DomainName": "my.domain.com", + "EndpointConfiguration": { + "Types": [ + "REGIONAL" + ] + }, + "RegionalCertificateArn": { + "Ref": "cert56CA94EB" + } + })); + expect(stack).to(haveResource('AWS::ApiGateway::BasePathMapping', { + "DomainName": { + "Ref": "apiCustomDomain64773C4F" + }, + "RestApiId": { + "Ref": "apiC8550315" + }, + "Stage": { + "Ref": "apiDeploymentStageprod896C8101" + } + })); + + test.done(); + } +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/test.lambda-api.ts b/packages/@aws-cdk/aws-apigateway/test/test.lambda-api.ts index 0314b62e4cb8e..6a1e26a1671cf 100644 --- a/packages/@aws-cdk/aws-apigateway/test/test.lambda-api.ts +++ b/packages/@aws-cdk/aws-apigateway/test/test.lambda-api.ts @@ -190,7 +190,12 @@ export = { test.throws(() => new apigw.LambdaRestApi(stack, 'lambda-rest-api', { handler, options: { defaultIntegration: new apigw.HttpIntegration('https://foo/bar') } - }), /Cannot specify \"options\.defaultIntegration\" since Lambda integration is automatically defined/); + }), /Cannot specify \"defaultIntegration\" since Lambda integration is automatically defined/); + + test.throws(() => new apigw.LambdaRestApi(stack, 'lambda-rest-api', { + handler, + defaultIntegration: new apigw.HttpIntegration('https://foo/bar') + }), /Cannot specify \"defaultIntegration\" since Lambda integration is automatically defined/); test.done(); }, diff --git a/packages/@aws-cdk/aws-iam/lib/lazy-role.ts b/packages/@aws-cdk/aws-iam/lib/lazy-role.ts index 85860d9725569..e3385d511cbfd 100644 --- a/packages/@aws-cdk/aws-iam/lib/lazy-role.ts +++ b/packages/@aws-cdk/aws-iam/lib/lazy-role.ts @@ -25,6 +25,7 @@ export interface LazyRoleProps extends RoleProps { export class LazyRole extends cdk.Resource implements IRole { public readonly grantPrincipal: IPrincipal = this; public readonly assumeRoleAction: string = 'sts:AssumeRole'; + private role?: Role; private readonly statements = new Array(); private readonly policies = new Array(); @@ -79,7 +80,7 @@ export class LazyRole extends cdk.Resource implements IRole { return this.instantiate().roleArn; } - /** @attribute */ + /** @attribute RoleId */ public get roleId(): string { return this.instantiate().roleId; } diff --git a/packages/@aws-cdk/aws-kms/lib/alias.ts b/packages/@aws-cdk/aws-kms/lib/alias.ts index ea5f6b2259635..1c9a116fa3ba0 100644 --- a/packages/@aws-cdk/aws-kms/lib/alias.ts +++ b/packages/@aws-cdk/aws-kms/lib/alias.ts @@ -12,14 +12,14 @@ export interface IAlias extends IResource { /** * The name of the alias. * - * @attribute AliasName + * @attribute */ readonly aliasName: string; /** * The Key to which the Alias refers. * - * @attribute TargetKeyId + * @attribute */ readonly aliasTargetKey: IKey; } diff --git a/packages/@aws-cdk/aws-kms/package.json b/packages/@aws-cdk/aws-kms/package.json index 6d1f398e7991a..0cc9f5c1e6f74 100644 --- a/packages/@aws-cdk/aws-kms/package.json +++ b/packages/@aws-cdk/aws-kms/package.json @@ -87,4 +87,4 @@ ] }, "stability": "experimental" -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-rds/lib/cluster-ref.ts b/packages/@aws-cdk/aws-rds/lib/cluster-ref.ts index f9fab146684d4..ba35a274fa7c5 100644 --- a/packages/@aws-cdk/aws-rds/lib/cluster-ref.ts +++ b/packages/@aws-cdk/aws-rds/lib/cluster-ref.ts @@ -19,13 +19,13 @@ export interface IDatabaseCluster extends IResource, ec2.IConnectable, secretsma /** * The endpoint to use for read/write operations - * @attribute dbClusterEndpointAddress,dbClusterEndpointPort + * @attribute EndpointAddress,EndpointPort */ readonly clusterEndpoint: Endpoint; /** * Endpoint to use for load-balanced read-only operations. - * @attribute dbClusterReadEndpointAddress + * @attribute ReadEndpointAddress */ readonly clusterReadEndpoint: Endpoint; diff --git a/packages/@aws-cdk/aws-rds/lib/instance.ts b/packages/@aws-cdk/aws-rds/lib/instance.ts index 0574d8150f947..6765bbf12399d 100644 --- a/packages/@aws-cdk/aws-rds/lib/instance.ts +++ b/packages/@aws-cdk/aws-rds/lib/instance.ts @@ -28,14 +28,14 @@ export interface IDatabaseInstance extends IResource, ec2.IConnectable, secretsm /** * The instance endpoint address. * - * @attribute + * @attribute EndpointAddress */ readonly dbInstanceEndpointAddress: string; /** * The instance endpoint port. * - * @attribute + * @attribute EndpointPort */ readonly dbInstanceEndpointPort: string; diff --git a/packages/@aws-cdk/aws-rds/package.json b/packages/@aws-cdk/aws-rds/package.json index a7f2f3387241b..019cb5f468ad3 100644 --- a/packages/@aws-cdk/aws-rds/package.json +++ b/packages/@aws-cdk/aws-rds/package.json @@ -100,7 +100,6 @@ }, "awslint": { "exclude": [ - "construct-base-is-private:@aws-cdk/aws-rds.DatabaseInstanceBase", "props-physical-name:@aws-cdk/aws-rds.ParameterGroupProps", "props-physical-name:@aws-cdk/aws-rds.ClusterParameterGroupProps", "props-physical-name:@aws-cdk/aws-rds.DatabaseClusterProps", diff --git a/packages/@aws-cdk/aws-route53-targets/lib/api-gateway-domain-name.ts b/packages/@aws-cdk/aws-route53-targets/lib/api-gateway-domain-name.ts new file mode 100644 index 0000000000000..e11cb87abe909 --- /dev/null +++ b/packages/@aws-cdk/aws-route53-targets/lib/api-gateway-domain-name.ts @@ -0,0 +1,36 @@ +import apig = require('@aws-cdk/aws-apigateway'); +import route53 = require('@aws-cdk/aws-route53'); + +/** + * Defines an API Gateway domain name as the alias target. + * + * Use the `ApiGateway` class if you wish to map the alias to an REST API with a + * domain name defined throug the `RestApiProps.domainName` prop. + */ +export class ApiGatewayDomain implements route53.IAliasRecordTarget { + constructor(private readonly domainName: apig.IDomainName) { } + + public bind(_record: route53.IRecordSet): route53.AliasRecordTargetConfig { + return { + dnsName: this.domainName.domainNameAliasDomainName, + hostedZoneId: this.domainName.domainNameAliasHostedZoneId, + }; + } +} + +/** + * Defines an API Gateway REST API as the alias target. Requires that the domain + * name will be defined through `RestApiProps.domainName`. + * + * You can direct the alias to any `apigateway.DomainName` resource through the + * `ApiGatewayDomain` class. + */ +export class ApiGateway extends ApiGatewayDomain { + constructor(api: apig.RestApi) { + if (!api.domainName) { + throw new Error(`API does not define a default domain name`); + } + + super(api.domainName); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-route53-targets/lib/index.ts b/packages/@aws-cdk/aws-route53-targets/lib/index.ts index 9b0669ce0bee7..6f8cfee50c9a8 100644 --- a/packages/@aws-cdk/aws-route53-targets/lib/index.ts +++ b/packages/@aws-cdk/aws-route53-targets/lib/index.ts @@ -1,2 +1,3 @@ +export * from './api-gateway-domain-name'; export * from './cloudfront-target'; export * from './load-balancer-target'; diff --git a/packages/@aws-cdk/aws-route53-targets/package.json b/packages/@aws-cdk/aws-route53-targets/package.json index 87be50e3f0e58..08584c6ca384b 100644 --- a/packages/@aws-cdk/aws-route53-targets/package.json +++ b/packages/@aws-cdk/aws-route53-targets/package.json @@ -69,6 +69,8 @@ "license": "Apache-2.0", "devDependencies": { "@aws-cdk/assert": "^0.36.0", + "@aws-cdk/aws-certificatemanager": "^0.36.0", + "@aws-cdk/aws-lambda": "^0.36.0", "@aws-cdk/aws-ec2": "^0.36.0", "@aws-cdk/aws-s3": "^0.36.0", "cdk-build-tools": "^0.36.0", @@ -82,6 +84,7 @@ "@aws-cdk/aws-elasticloadbalancingv2": "^0.36.0", "@aws-cdk/aws-iam": "^0.36.0", "@aws-cdk/aws-route53": "^0.36.0", + "@aws-cdk/aws-apigateway": "^0.36.0", "@aws-cdk/core": "^0.36.0" }, "homepage": "https://github.com/awslabs/aws-cdk", @@ -90,10 +93,11 @@ "@aws-cdk/aws-elasticloadbalancingv2": "^0.36.0", "@aws-cdk/aws-iam": "^0.36.0", "@aws-cdk/aws-route53": "^0.36.0", + "@aws-cdk/aws-apigateway": "^0.36.0", "@aws-cdk/core": "^0.36.0" }, "engines": { "node": ">= 8.10.0" }, "stability": "experimental" -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-route53-targets/test/apigateway-target.test.ts b/packages/@aws-cdk/aws-route53-targets/test/apigateway-target.test.ts new file mode 100644 index 0000000000000..a3b5b9df8a4bf --- /dev/null +++ b/packages/@aws-cdk/aws-route53-targets/test/apigateway-target.test.ts @@ -0,0 +1,108 @@ +import { expect as expectStack, haveResource } from '@aws-cdk/assert'; +import apigw = require('@aws-cdk/aws-apigateway'); +import acm = require('@aws-cdk/aws-certificatemanager'); +import route53 = require('@aws-cdk/aws-route53'); +import { Stack } from '@aws-cdk/core'; +import targets = require('../lib'); + +test('targets.ApiGateway can be used to the default domain of an APIGW', () => { + // GIVEN + const stack = new Stack(); + const cert = new acm.Certificate(stack, 'cert', { domainName: 'example.com' }); + const api = new apigw.RestApi(stack, 'api', { + domainName: { + domainName: 'example.com', + certificate: cert, + } + }); + const zone = new route53.HostedZone(stack, 'zone', { + zoneName: 'example.com' + }); + api.root.addMethod('GET'); + + // WHEN + new route53.ARecord(stack, 'A', { + zone, + target: route53.RecordTarget.fromAlias(new targets.ApiGateway(api)) + }); + + // THEN + expectStack(stack).to(haveResource('AWS::Route53::RecordSet', { + Name: "example.com.", + Type: "A", + AliasTarget: { + DNSName: { + "Fn::GetAtt": [ + "apiCustomDomain64773C4F", + "RegionalDomainName" + ] + }, + HostedZoneId: { + "Fn::GetAtt": [ + "apiCustomDomain64773C4F", + "RegionalHostedZoneId" + ] + } + }, + HostedZoneId: { + Ref: "zoneEB40FF1E" + } + })); +}); + +test('targets.ApiGatewayDomain can be used to directly reference a domain', () => { + // GIVEN + const stack = new Stack(); + const cert = new acm.Certificate(stack, 'cert', { domainName: 'example.com' }); + const domain = new apigw.DomainName(stack, 'domain', { domainName: 'example.com', certificate: cert }); + const zone = new route53.HostedZone(stack, 'zone', { + zoneName: 'example.com' + }); + + // WHEN + new route53.ARecord(stack, 'A', { + zone, + target: route53.RecordTarget.fromAlias(new targets.ApiGatewayDomain(domain)) + }); + + // THEN + expectStack(stack).to(haveResource('AWS::Route53::RecordSet', { + Name: "example.com.", + Type: "A", + AliasTarget: { + DNSName: { + "Fn::GetAtt": [ + "domainFBFFA2F6", + "RegionalDomainName" + ] + }, + HostedZoneId: { + "Fn::GetAtt": [ + "domainFBFFA2F6", + "RegionalHostedZoneId" + ] + } + }, + HostedZoneId: { + Ref: "zoneEB40FF1E" + } + })); +}); + +test('fails if an ApiGateway is used with an API that does not define a domain name', () => { + // GIVEN + const stack = new Stack(); + const api = new apigw.RestApi(stack, 'api'); + const zone = new route53.HostedZone(stack, 'zone', { + zoneName: 'example.com' + }); + api.root.addMethod('GET'); + + // THEN + expect(() => { + new route53.ARecord(stack, 'A', { + zone, + target: route53.RecordTarget.fromAlias(new targets.ApiGateway(api)) + }); + }).toThrow(/API does not define a default domain name/); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-route53-targets/test/integ.api-gateway-domain-name.expected.json b/packages/@aws-cdk/aws-route53-targets/test/integ.api-gateway-domain-name.expected.json new file mode 100644 index 0000000000000..3a195f331906e --- /dev/null +++ b/packages/@aws-cdk/aws-route53-targets/test/integ.api-gateway-domain-name.expected.json @@ -0,0 +1,481 @@ +{ + "Resources": { + "HandlerServiceRoleFCDC14AE": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "lambda.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "Handler886CB40B": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = async () => {\n return {\n statusCode: '200',\n body: 'hello, world!'\n };\n };" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "HandlerServiceRoleFCDC14AE", + "Arn" + ] + }, + "Runtime": "nodejs8.10" + }, + "DependsOn": [ + "HandlerServiceRoleFCDC14AE" + ] + }, + "HandlerApiPermissionANY90312B89": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "Handler886CB40B", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "apiC8550315" + }, + "/", + { + "Ref": "apiDeploymentStageprod896C8101" + }, + "/*/" + ] + ] + } + } + }, + "HandlerApiPermissionTestANY40D41BDC": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "Handler886CB40B", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "apiC8550315" + }, + "/test-invoke-stage/*/" + ] + ] + } + } + }, + "HandlerApiPermissionANYproxyDC4EA7A1": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "Handler886CB40B", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "apiC8550315" + }, + "/", + { + "Ref": "apiDeploymentStageprod896C8101" + }, + "/*/{proxy+}" + ] + ] + } + } + }, + "HandlerApiPermissionTestANYproxyCF641E04": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "Handler886CB40B", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "apiC8550315" + }, + "/test-invoke-stage/*/{proxy+}" + ] + ] + } + } + }, + "apiC8550315": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "api" + } + }, + "apiDeployment149F12949ab8cbc5717926c7a1a01815778330c4": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "RestApiId": { + "Ref": "apiC8550315" + }, + "Description": "Automatically created by the RestApi construct" + }, + "DependsOn": [ + "apiproxyANY7F13F09C", + "apiproxy4EA44110", + "apiANYB3DF8C3C" + ] + }, + "apiDeploymentStageprod896C8101": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "RestApiId": { + "Ref": "apiC8550315" + }, + "DeploymentId": { + "Ref": "apiDeployment149F12949ab8cbc5717926c7a1a01815778330c4" + }, + "StageName": "prod" + } + }, + "apiCloudWatchRoleAC81D93E": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "apigateway.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" + ] + ] + } + ] + } + }, + "apiAccount57E28B43": { + "Type": "AWS::ApiGateway::Account", + "Properties": { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + "apiCloudWatchRoleAC81D93E", + "Arn" + ] + } + }, + "DependsOn": [ + "apiC8550315" + ] + }, + "apiproxy4EA44110": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Fn::GetAtt": [ + "apiC8550315", + "RootResourceId" + ] + }, + "PathPart": "{proxy+}", + "RestApiId": { + "Ref": "apiC8550315" + } + } + }, + "apiproxyANY7F13F09C": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "ResourceId": { + "Ref": "apiproxy4EA44110" + }, + "RestApiId": { + "Ref": "apiC8550315" + }, + "AuthorizationType": "NONE", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "Handler886CB40B", + "Arn" + ] + }, + "/invocations" + ] + ] + } + } + } + }, + "apiANYB3DF8C3C": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "ResourceId": { + "Fn::GetAtt": [ + "apiC8550315", + "RootResourceId" + ] + }, + "RestApiId": { + "Ref": "apiC8550315" + }, + "AuthorizationType": "NONE", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "Handler886CB40B", + "Arn" + ] + }, + "/invocations" + ] + ] + } + } + } + }, + "apiCustomDomain64773C4F": { + "Type": "AWS::ApiGateway::DomainName", + "Properties": { + "DomainName": "example.com", + "EndpointConfiguration": { + "Types": [ + "REGIONAL" + ] + }, + "RegionalCertificateArn": "arn:aws:acm:us-east-1:111111111111:certificate" + } + }, + "apiCustomDomainMapawscdkapigwaliasintegapiF4DF08ACCF365F22": { + "Type": "AWS::ApiGateway::BasePathMapping", + "Properties": { + "DomainName": { + "Ref": "apiCustomDomain64773C4F" + }, + "RestApiId": { + "Ref": "apiC8550315" + }, + "Stage": { + "Ref": "apiDeploymentStageprod896C8101" + } + } + }, + "Alias325C5727": { + "Type": "AWS::Route53::RecordSet", + "Properties": { + "Name": "example.com.", + "Type": "A", + "AliasTarget": { + "DNSName": { + "Fn::GetAtt": [ + "apiCustomDomain64773C4F", + "RegionalDomainName" + ] + }, + "HostedZoneId": { + "Fn::GetAtt": [ + "apiCustomDomain64773C4F", + "RegionalHostedZoneId" + ] + } + }, + "HostedZoneId": "AAAAAAAAAAAAA" + } + } + }, + "Outputs": { + "apiEndpoint9349E63C": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "apiC8550315" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "apiDeploymentStageprod896C8101" + }, + "/" + ] + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-route53-targets/test/integ.api-gateway-domain-name.ts b/packages/@aws-cdk/aws-route53-targets/test/integ.api-gateway-domain-name.ts new file mode 100644 index 0000000000000..ae94fb06417bb --- /dev/null +++ b/packages/@aws-cdk/aws-route53-targets/test/integ.api-gateway-domain-name.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import apig = require('@aws-cdk/aws-apigateway'); +import acm = require('@aws-cdk/aws-certificatemanager'); +import lambda = require('@aws-cdk/aws-lambda'); +import route53 = require('@aws-cdk/aws-route53'); +import { App, Construct, Stack } from '@aws-cdk/core'; +import targets = require('../lib'); + +class TestStack extends Stack { + constructor(scope: Construct, id: string) { + super(scope, id); + + const domainName = 'example.com'; + const certArn = 'arn:aws:acm:us-east-1:111111111111:certificate'; + const hostedZoneId = 'AAAAAAAAAAAAA'; + + const handler = new lambda.Function(this, 'Handler', { + code: lambda.Code.inline(`exports.handler = async () => { + return { + statusCode: '200', + body: 'hello, world!' + }; + };`), + runtime: lambda.Runtime.NODEJS_8_10, + handler: 'index.handler' + }); + + const certificate = acm.Certificate.fromCertificateArn(this, 'cert', certArn); + + const api = new apig.LambdaRestApi(this, 'api', { + handler, + domainName: { + certificate, + domainName, + endpointType: apig.EndpointType.REGIONAL, + } + }); + + const zone = route53.HostedZone.fromHostedZoneAttributes(this, 'hosted-zone', { + zoneName: domainName, + hostedZoneId + }); + + new route53.ARecord(this, 'Alias', { + zone, + target: route53.AddressRecordTarget.fromAlias(new targets.ApiGateway(api)) + }); + } +} + +const app = new App(); +new TestStack(app, 'aws-cdk-apigw-alias-integ'); +app.synth(); diff --git a/packages/@aws-cdk/aws-route53/lib/record-set.ts b/packages/@aws-cdk/aws-route53/lib/record-set.ts index e1de367756f7a..c15e4a0093916 100644 --- a/packages/@aws-cdk/aws-route53/lib/record-set.ts +++ b/packages/@aws-cdk/aws-route53/lib/record-set.ts @@ -81,6 +81,13 @@ export class RecordTarget { return new RecordTarget(undefined, aliasTarget); } + /** + * Use ip adresses as target. + */ + public static fromIpAddresses(...ipAddresses: string[]) { + return RecordTarget.fromValues(...ipAddresses); + } + protected constructor(public readonly values?: string[], public readonly aliasTarget?: IAliasRecordTarget) { } } @@ -130,12 +137,6 @@ export class RecordSet extends Resource implements IRecordSet { * */ export class AddressRecordTarget extends RecordTarget { - /** - * Use ip adresses as target. - */ - public static fromIpAddresses(...ipAddresses: string[]) { - return RecordTarget.fromValues(...ipAddresses); - } } /** @@ -145,7 +146,7 @@ export interface ARecordProps extends RecordSetOptions { /** * The target. */ - readonly target: AddressRecordTarget; + readonly target: RecordTarget; } /** diff --git a/packages/@aws-cdk/aws-ssm/lib/parameter.ts b/packages/@aws-cdk/aws-ssm/lib/parameter.ts index b391fdf2ff13e..a5605c4af01b8 100644 --- a/packages/@aws-cdk/aws-ssm/lib/parameter.ts +++ b/packages/@aws-cdk/aws-ssm/lib/parameter.ts @@ -50,7 +50,7 @@ export interface IStringParameter extends IParameter { /** * The parameter value. Value must not nest another parameter. Do not use {{}} in the value. * - * @attribute parameterValue + * @attribute Value */ readonly stringValue: string; } @@ -63,7 +63,7 @@ export interface IStringListParameter extends IParameter { * The parameter value. Value must not nest another parameter. Do not use {{}} in the value. Values in the array * cannot contain commas (``,``). * - * @attribute parameterValue + * @attribute Value */ readonly stringListValue: string[]; } diff --git a/packages/decdk/package.json b/packages/decdk/package.json index 2353e4d7ff794..8128fbd880f58 100644 --- a/packages/decdk/package.json +++ b/packages/decdk/package.json @@ -162,4 +162,4 @@ "engines": { "node": ">= 8.10.0" } -} +} \ No newline at end of file diff --git a/tools/awslint/lib/rules/cfn-resource.ts b/tools/awslint/lib/rules/cfn-resource.ts index 4b1cf265aaae4..f60619898086f 100644 --- a/tools/awslint/lib/rules/cfn-resource.ts +++ b/tools/awslint/lib/rules/cfn-resource.ts @@ -78,7 +78,7 @@ export class CfnResourceReflection { // special case (someone was smart), special case copied from cfn2ts if (this.basename === 'SecurityGroup' && name === 'GroupId') { - return 'securityGroupId'; + return 'Id'; } return camelcase(name, { pascalCase: true }); diff --git a/tools/awslint/lib/rules/resource.ts b/tools/awslint/lib/rules/resource.ts index 0cc2c33514875..aeabbc18e9b58 100644 --- a/tools/awslint/lib/rules/resource.ts +++ b/tools/awslint/lib/rules/resource.ts @@ -13,7 +13,7 @@ export const resourceLinter = new Linter(a => ResourceReflection.findAll(a)); export interface Attribute { site: AttributeSite; property: reflect.Property; - names: string[]; // bucketArn + cfnAttributeNames: string[]; // bucketArn } export enum AttributeSite { @@ -72,7 +72,9 @@ export class ResourceReflection { } const resourceName = camelcase(this.cfn.basename); - const physicalNameProp = `${resourceName}Name`; + + // if resource name ends with "Name" (e.g. DomainName, then just use it as-is, otherwise append "Name") + const physicalNameProp = resourceName.endsWith('Name') ? resourceName : `${resourceName}Name`; return this.construct.propsType.allProperties.find(x => x.name === physicalNameProp); } @@ -87,17 +89,40 @@ export class ResourceReflection { continue; // skip any protected properties } + const basename = camelcase(this.cfn.basename); + // an attribute property is a property which starts with the type name // (e.g. "bucketXxx") and/or has an @attribute doc tag. const tag = getDocTag(p, 'attribute'); - if (!p.name.startsWith(camelcase(this.basename)) && !tag) { + if (!p.name.startsWith(basename) && !tag) { continue; } - // if there's an `@attribute` doc tag with a value other than "true" - // it should be used as the attribute name instead of the property name - // multiple attribute names can be listed as a comma-delimited list - const propertyNames = (tag && tag !== 'true') ? tag.split(',') : [ p.name ]; + let cfnAttributeNames; + if (tag && tag !== 'true') { + // if there's an `@attribute` doc tag with a value other than "true" + // it should be used as the CFN attribute name instead of the property name + // multiple attribute names can be listed as a comma-delimited list + cfnAttributeNames = tag.split(','); + } else { + // okay, we don't have an explicit CFN attribute name, so we'll guess it + // from the name of the property. + + const name = camelcase(p.name, { pascalCase: true }); + if (this.cfn.attributeNames.includes(name)) { + // special case: there is a cloudformation resource type in the attribute name + // for example 'RoleId'. + cfnAttributeNames = [ name ]; + } else if (p.name.startsWith(basename)) { + // begins with the resource name, just trim it + cfnAttributeNames = [ name.substring(this.cfn.basename.length) ]; + } else { + // we couldn't determine CFN attribute name, so we don't account for this + // as an attribute. this could be, for example, when a construct implements + // an interface that represents another resource (e.g. `lambda.Alias` implements `IFunction`). + continue; + } + } // check if this attribute is defined on an interface or on a class const property = findDeclarationSite(p); @@ -105,7 +130,7 @@ export class ResourceReflection { result.push({ site, - names: propertyNames, + cfnAttributeNames, property }); } @@ -162,13 +187,18 @@ resourceLinter.add({ resourceLinter.add({ code: 'resource-attribute', - message: 'resources must represent all cloudformation attributes as attribute properties. missing property:', + message: + 'resources must represent all cloudformation attributes as attribute properties. ' + + '"@attribute ATTR[,ATTR]" can be used to tag non-standard attribute names. ' + + 'missing property:', eval: e => { for (const name of e.ctx.cfn.attributeNames) { - const lookup = camelcase(name).startsWith(camelcase(e.ctx.cfn.basename)) ? - camelcase(name) : camelcase(e.ctx.cfn.basename + name); - const found = e.ctx.attributes.find(a => a.names.includes(lookup)); - e.assert(found, `${e.ctx.fqn}.${name}`, name); + const expected = camelcase(name).startsWith(camelcase(e.ctx.cfn.basename)) + ? camelcase(name) + : camelcase(e.ctx.cfn.basename + name); + + const found = e.ctx.attributes.find(a => a.cfnAttributeNames.includes(name)); + e.assert(found, `${e.ctx.fqn}.${expected}`, expected); } } });