Skip to content

Commit 8587a28

Browse files
authored
feat: Custom domains route53 support (#1165)
1 parent 92d4b01 commit 8587a28

19 files changed

+1261
-240
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Custom Domains support
2+
3+
Example SAM template for setting up Api Gateway resources for custom domains.
4+
5+
## Prerequisites for setting up custom domains
6+
1. A domain name. You can purchase a domain name from a domain name provider.
7+
1. A certificate ARN. Set up or import a valid certificate into AWS Certificate Manager. If the endpoint is EDGE, the certificate must be created in us-east-1.
8+
1. A HostedZone in Route53 for the domain name.
9+
1. A Cloudfront Distribution for the domain if the endpoint is set to EDGE.
10+
11+
## PostRequisites
12+
After deploying the template, make sure you configure the DNS settings on the domain name provider's website. You will need to add Type A and Type AAAA DNS records that are point to ApiGateway's Hosted Zone Id. Read more [here](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-to-api-gateway.html)
13+
14+
## Running the example
15+
16+
```bash
17+
$ sam deploy \
18+
--template-file /path_to_template/packaged-template.yaml \
19+
--stack-name my-new-stack \
20+
--capabilities CAPABILITY_IAM
21+
```
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
Parameters:
2+
DomainName:
3+
Type: String
4+
Default: 'example.com'
5+
ACMCertificateArn:
6+
Type: String
7+
Default: 'cert-arn-in-us-east-1'
8+
Resources:
9+
MyFunction:
10+
Type: AWS::Serverless::Function
11+
Properties:
12+
InlineCode: |
13+
exports.handler = async (event) => {
14+
const response = {
15+
statusCode: 200,
16+
body: JSON.stringify('Hello from Lambda!'),
17+
};
18+
return response;
19+
};
20+
Handler: index.handler
21+
Runtime: nodejs8.10
22+
Events:
23+
Fetch:
24+
Type: Api
25+
Properties:
26+
RestApiId: !Ref MyApi
27+
Method: Post
28+
Path: /fetch
29+
30+
MyApi:
31+
Type: AWS::Serverless::Api
32+
Properties:
33+
OpenApiVersion: 3.0.1
34+
StageName: Prod
35+
Domain:
36+
DomainName: !Ref DomainName
37+
CertificateArn: !Ref ACMCertificateArn
38+
EndpointConfiguration: EDGE
39+
BasePath:
40+
- /fetch
41+
Route53:
42+
HostedZoneId: ZQ1UAL4EFZVME
43+
IpV6: true
44+
DistributionDomainName: !GetAtt Distribution.DomainName
45+
46+
Distribution:
47+
Type: AWS::CloudFront::Distribution
48+
Properties:
49+
DistributionConfig:
50+
Enabled: true
51+
HttpVersion: http2
52+
Origins:
53+
- DomainName: !Ref DomainName
54+
Id: !Ref DomainName
55+
CustomOriginConfig:
56+
HTTPPort: 80
57+
HTTPSPort: 443
58+
OriginProtocolPolicy: https-only
59+
DefaultCacheBehavior:
60+
AllowedMethods: [ HEAD, DELETE, POST, GET, OPTIONS, PUT, PATCH ]
61+
ForwardedValues:
62+
QueryString: false
63+
SmoothStreaming: false
64+
Compress: true
65+
TargetOriginId: !Ref DomainName
66+
ViewerProtocolPolicy: redirect-to-https
67+
PriceClass: PriceClass_100
68+
ViewerCertificate:
69+
SslSupportMethod: sni-only
70+
AcmCertificateArn: !Ref ACMCertificateArn

samtranslator/model/api/api_generator.py

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from collections import namedtuple
22
from six import string_types
3-
from samtranslator.model.intrinsics import ref
3+
from samtranslator.model.intrinsics import ref, fnGetAtt
44
from samtranslator.model.apigateway import (ApiGatewayDeployment, ApiGatewayRestApi,
55
ApiGatewayStage, ApiGatewayAuthorizer,
66
ApiGatewayResponse, ApiGatewayDomainName,
77
ApiGatewayBasePathMapping)
8+
from samtranslator.model.route53 import Route53RecordSetGroup
89
from samtranslator.model.exceptions import InvalidResourceException
910
from samtranslator.model.s3_utils.uri_parser import parse_s3_uri
1011
from samtranslator.region_configuration import RegionConfiguration
@@ -218,23 +219,26 @@ def _construct_api_domain(self, rest_api):
218219
Constructs and returns the ApiGateway Domain and BasepathMapping
219220
"""
220221
if self.domain is None:
221-
return None, None
222+
return None, None, None
222223

223224
if self.domain.get('DomainName') is None or \
224225
self.domain.get('CertificateArn') is None:
225226
raise InvalidResourceException(self.logical_id,
226227
"Custom Domains only works if both DomainName and CertificateArn"
227228
" are provided")
228229

229-
logical_id = logical_id_generator.LogicalIdGenerator("", self.domain).gen()
230+
self.domain['ApiDomainName'] = "{}{}".format('ApiGatewayDomainName',
231+
logical_id_generator.
232+
LogicalIdGenerator("", self.domain.get('DomainName')).gen())
230233

231-
domain = ApiGatewayDomainName('ApiGatewayDomainName' + logical_id,
234+
domain = ApiGatewayDomainName(self.domain.get('ApiDomainName'),
232235
attributes=self.passthrough_resource_attributes)
233236
domain.DomainName = self.domain.get('DomainName')
234237
endpoint = self.domain.get('EndpointConfiguration')
235238

236239
if endpoint is None:
237240
endpoint = 'REGIONAL'
241+
self.domain['EndpointConfiguration'] = 'REGIONAL'
238242
elif endpoint not in ['EDGE', 'REGIONAL']:
239243
raise InvalidResourceException(self.logical_id,
240244
"EndpointConfiguration for Custom Domains must be"
@@ -276,7 +280,58 @@ def _construct_api_domain(self, rest_api):
276280
basepath_mapping.BasePath = path
277281
basepath_resource_list.extend([basepath_mapping])
278282

279-
return domain, basepath_resource_list
283+
# Create the Route53 RecordSetGroup resource
284+
record_set_group = None
285+
if self.domain.get('Route53') is not None:
286+
route53 = self.domain.get('Route53')
287+
if route53.get('HostedZoneId') is None:
288+
raise InvalidResourceException(self.logical_id,
289+
"HostedZoneId is required to enable Route53 support on Custom Domains.")
290+
logical_id = logical_id_generator.LogicalIdGenerator("", route53.get('HostedZoneId')).gen()
291+
record_set_group = Route53RecordSetGroup('RecordSetGroup' + logical_id,
292+
attributes=self.passthrough_resource_attributes)
293+
record_set_group.HostedZoneId = route53.get('HostedZoneId')
294+
record_set_group.RecordSets = self._construct_record_sets_for_domain(self.domain)
295+
296+
return domain, basepath_resource_list, record_set_group
297+
298+
def _construct_record_sets_for_domain(self, domain):
299+
recordset_list = []
300+
recordset = {}
301+
route53 = domain.get('Route53')
302+
303+
recordset['Name'] = domain.get('DomainName')
304+
recordset['Type'] = 'A'
305+
recordset['AliasTarget'] = self._construct_alias_target(self.domain)
306+
recordset_list.extend([recordset])
307+
308+
recordset_ipv6 = {}
309+
if route53.get('IpV6') is not None and route53.get('IpV6') is True:
310+
recordset_ipv6['Name'] = domain.get('DomainName')
311+
recordset_ipv6['Type'] = 'AAAA'
312+
recordset_ipv6['AliasTarget'] = self._construct_alias_target(self.domain)
313+
recordset_list.extend([recordset_ipv6])
314+
315+
return recordset_list
316+
317+
def _construct_alias_target(self, domain):
318+
alias_target = {}
319+
route53 = domain.get('Route53')
320+
target_health = route53.get('EvaluateTargetHealth')
321+
322+
if target_health is not None:
323+
alias_target['EvaluateTargetHealth'] = target_health
324+
if domain.get('EndpointConfiguration') == 'REGIONAL':
325+
alias_target['HostedZoneId'] = fnGetAtt(self.domain.get('ApiDomainName'), 'RegionalHostedZoneId')
326+
alias_target['DNSName'] = fnGetAtt(self.domain.get('ApiDomainName'), 'RegionalDomainName')
327+
else:
328+
if route53.get('DistributionDomainName') is None:
329+
raise InvalidResourceException(self.logical_id,
330+
"Custom Domains support for EDGE requires the name "
331+
"of a Distribution resource")
332+
alias_target['HostedZoneId'] = 'Z2FDTNDATAQYW2'
333+
alias_target['DNSName'] = route53.get('DistributionDomainName')
334+
return alias_target
280335

281336
def to_cloudformation(self):
282337
"""Generates CloudFormation resources from a SAM API resource
@@ -285,7 +340,7 @@ def to_cloudformation(self):
285340
:rtype: tuple
286341
"""
287342
rest_api = self._construct_rest_api()
288-
domain, basepath_mapping = self._construct_api_domain(rest_api)
343+
domain, basepath_mapping, route53 = self._construct_api_domain(rest_api)
289344
deployment = self._construct_deployment(rest_api)
290345

291346
swagger = None
@@ -297,7 +352,7 @@ def to_cloudformation(self):
297352
stage = self._construct_stage(deployment, swagger)
298353
permissions = self._construct_authorizer_lambda_permission()
299354

300-
return rest_api, deployment, stage, permissions, domain, basepath_mapping
355+
return rest_api, deployment, stage, permissions, domain, basepath_mapping, route53
301356

302357
def _add_cors(self):
303358
"""

samtranslator/model/route53.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from samtranslator.model import PropertyType, Resource
2+
from samtranslator.model.types import is_type, is_str
3+
4+
5+
class Route53RecordSetGroup(Resource):
6+
resource_type = 'AWS::Route53::RecordSetGroup'
7+
property_types = {
8+
'HostedZoneId': PropertyType(False, is_str()),
9+
'RecordSets': PropertyType(False, is_type(list)),
10+
}

samtranslator/model/sam_resources.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from .tags.resource_tagging import get_tag_list
1111
from samtranslator.model import (PropertyType, SamResourceMacro,
1212
ResourceTypeResolver)
13-
from samtranslator.model.apigateway import ApiGatewayDeployment, ApiGatewayStage
13+
from samtranslator.model.apigateway import ApiGatewayDeployment, ApiGatewayStage, ApiGatewayDomainName
1414
from samtranslator.model.cloudformation import NestedStack
1515
from samtranslator.model.dynamodb import DynamoDBTable
1616
from samtranslator.model.exceptions import (InvalidEventException,
@@ -489,6 +489,7 @@ class SamApi(SamResourceMacro):
489489
referable_properties = {
490490
"Stage": ApiGatewayStage.resource_type,
491491
"Deployment": ApiGatewayDeployment.resource_type,
492+
"DomainName": ApiGatewayDomainName.resource_type
492493
}
493494

494495
def to_cloudformation(self, **kwargs):
@@ -531,14 +532,16 @@ def to_cloudformation(self, **kwargs):
531532
models=self.Models,
532533
domain=self.Domain)
533534

534-
rest_api, deployment, stage, permissions, domain, basepath_mapping = api_generator.to_cloudformation()
535+
rest_api, deployment, stage, permissions, domain, basepath_mapping, route53 = api_generator.to_cloudformation()
535536

536537
resources.extend([rest_api, deployment, stage])
537538
resources.extend(permissions)
538539
if domain:
539540
resources.extend([domain])
540541
if basepath_mapping:
541542
resources.extend(basepath_mapping)
543+
if route53:
544+
resources.extend([route53])
542545
return resources
543546

544547

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
Parameters:
2+
DomainName:
3+
Type: String
4+
Default: 'example.com'
5+
ACMCertificateArn:
6+
Type: String
7+
Default: 'cert-arn-in-us-east-1'
8+
Resources:
9+
MyFunction:
10+
Type: AWS::Serverless::Function
11+
Properties:
12+
InlineCode: |
13+
exports.handler = async (event) => {
14+
const response = {
15+
statusCode: 200,
16+
body: JSON.stringify('Hello from Lambda!'),
17+
};
18+
return response;
19+
};
20+
Handler: index.handler
21+
Runtime: nodejs8.10
22+
Events:
23+
Fetch:
24+
Type: Api
25+
Properties:
26+
RestApiId: !Ref MyApi
27+
Method: Post
28+
Path: /fetch
29+
30+
MyApi:
31+
Type: AWS::Serverless::Api
32+
Properties:
33+
OpenApiVersion: 3.0.1
34+
StageName: Prod
35+
Domain:
36+
DomainName: !Ref DomainName
37+
CertificateArn: !Ref ACMCertificateArn
38+
EndpointConfiguration: EDGE
39+
BasePath:
40+
- /fetch
41+
Route53:
42+
HostedZoneId: ZQ1UAL4EFZVME
43+
IpV6: true
44+
DistributionDomainName: !GetAtt Distribution.DomainName
45+
46+
Distribution:
47+
Type: AWS::CloudFront::Distribution
48+
Properties:
49+
DistributionConfig:
50+
Enabled: true
51+
HttpVersion: http2
52+
Origins:
53+
- DomainName: !Ref DomainName
54+
Id: !Ref DomainName
55+
CustomOriginConfig:
56+
HTTPPort: 80
57+
HTTPSPort: 443
58+
OriginProtocolPolicy: https-only
59+
DefaultCacheBehavior:
60+
AllowedMethods: [ HEAD, DELETE, POST, GET, OPTIONS, PUT, PATCH ]
61+
ForwardedValues:
62+
QueryString: false
63+
SmoothStreaming: false
64+
Compress: true
65+
TargetOriginId: !Ref DomainName
66+
ViewerProtocolPolicy: redirect-to-https
67+
PriceClass: PriceClass_100
68+
ViewerCertificate:
69+
SslSupportMethod: sni-only
70+
AcmCertificateArn: !Ref ACMCertificateArn
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
Resources:
2+
MyFunction:
3+
Type: AWS::Serverless::Function
4+
Properties:
5+
InlineCode: |
6+
exports.handler = async (event) => {
7+
const response = {
8+
statusCode: 200,
9+
body: JSON.stringify('Hello from Lambda!'),
10+
};
11+
return response;
12+
};
13+
Handler: index.handler
14+
Runtime: nodejs8.10
15+
Events:
16+
Api:
17+
Type: Api
18+
Properties:
19+
RestApiId: !Ref MyApi
20+
Method: Put
21+
Path: /get
22+
Fetch:
23+
Type: Api
24+
Properties:
25+
RestApiId: !Ref MyApi
26+
Method: Post
27+
Path: /fetch
28+
29+
MyApi:
30+
Type: AWS::Serverless::Api
31+
Properties:
32+
OpenApiVersion: 3.0.1
33+
StageName: Prod
34+
Domain:
35+
DomainName: 'api-example.com'
36+
CertificateArn: 'my-api-cert-arn'
37+
EndpointConfiguration: 'EDGE'
38+
BasePath: [ "/get", "/fetch" ]
39+
Route53:
40+
EvaluateTargetHealth: false

0 commit comments

Comments
 (0)