Skip to content

Commit 92d4b01

Browse files
authored
feat: custom domains Api Gateway support (#1144)
1 parent 5ad9921 commit 92d4b01

24 files changed

+2541
-20
lines changed

docs/cloudformation_compatibility.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ MinimumCompressionSize All
184184
Cors All
185185
TracingEnabled All
186186
OpenApiVersion None
187+
Domain All
187188
================================== ======================== ========================
188189

189190

docs/globals.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ Currently, the following resources and properties are being supported:
8989
CanarySetting:
9090
TracingEnabled:
9191
OpenApiVersion:
92+
Domain:
9293
9394
SimpleTable:
9495
# Properties of AWS::Serverless::SimpleTable
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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.
8+
9+
## PostRequisites
10+
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)
11+
12+
## Running the example
13+
14+
```bash
15+
$ sam deploy \
16+
--template-file /path_to_template/packaged-template.yaml \
17+
--stack-name my-new-stack \
18+
--capabilities CAPABILITY_IAM
19+
```
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
Parameters:
2+
MyDomainName:
3+
Type: String
4+
Default: another-example.com
5+
6+
MyDomainCert:
7+
Type: String
8+
Default: another-api-arn
9+
10+
Globals:
11+
Api:
12+
Domain:
13+
DomainName: !Ref MyDomainName
14+
CertificateArn: !Ref MyDomainCert
15+
EndpointConfiguration: 'REGIONAL'
16+
BasePath: ['/get']
17+
18+
Resources:
19+
MyFunction:
20+
Type: AWS::Serverless::Function
21+
Properties:
22+
InlineCode: |
23+
exports.handler = async (event) => {
24+
const response = {
25+
statusCode: 200,
26+
body: JSON.stringify('Hello from Lambda!'),
27+
};
28+
return response;
29+
};
30+
Handler: index.handler
31+
Runtime: nodejs8.10
32+
Events:
33+
ImplicitGet:
34+
Type: Api
35+
Properties:
36+
Method: Get
37+
Path: /get

samtranslator/model/api/api_generator.py

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from samtranslator.model.intrinsics import ref
44
from samtranslator.model.apigateway import (ApiGatewayDeployment, ApiGatewayRestApi,
55
ApiGatewayStage, ApiGatewayAuthorizer,
6-
ApiGatewayResponse)
6+
ApiGatewayResponse, ApiGatewayDomainName,
7+
ApiGatewayBasePathMapping)
78
from samtranslator.model.exceptions import InvalidResourceException
89
from samtranslator.model.s3_utils.uri_parser import parse_s3_uri
910
from samtranslator.region_configuration import RegionConfiguration
@@ -35,7 +36,7 @@ def __init__(self, logical_id, cache_cluster_enabled, cache_cluster_size, variab
3536
method_settings=None, binary_media=None, minimum_compression_size=None, cors=None,
3637
auth=None, gateway_responses=None, access_log_setting=None, canary_setting=None,
3738
tracing_enabled=None, resource_attributes=None, passthrough_resource_attributes=None,
38-
open_api_version=None, models=None):
39+
open_api_version=None, models=None, domain=None):
3940
"""Constructs an API Generator class that generates API Gateway resources
4041
4142
:param logical_id: Logical id of the SAM API Resource
@@ -80,6 +81,7 @@ def __init__(self, logical_id, cache_cluster_enabled, cache_cluster_size, variab
8081
self.open_api_version = open_api_version
8182
self.remove_extra_stage = open_api_version
8283
self.models = models
84+
self.domain = domain
8385

8486
def _construct_rest_api(self):
8587
"""Constructs and returns the ApiGateway RestApi.
@@ -204,20 +206,86 @@ def _construct_stage(self, deployment, swagger):
204206
stage.TracingEnabled = self.tracing_enabled
205207

206208
if swagger is not None:
207-
deployment.make_auto_deployable(stage, self.remove_extra_stage, swagger)
209+
deployment.make_auto_deployable(stage, self.remove_extra_stage, swagger, self.domain)
208210

209211
if self.tags is not None:
210212
stage.Tags = get_tag_list(self.tags)
211213

212214
return stage
213215

216+
def _construct_api_domain(self, rest_api):
217+
"""
218+
Constructs and returns the ApiGateway Domain and BasepathMapping
219+
"""
220+
if self.domain is None:
221+
return None, None
222+
223+
if self.domain.get('DomainName') is None or \
224+
self.domain.get('CertificateArn') is None:
225+
raise InvalidResourceException(self.logical_id,
226+
"Custom Domains only works if both DomainName and CertificateArn"
227+
" are provided")
228+
229+
logical_id = logical_id_generator.LogicalIdGenerator("", self.domain).gen()
230+
231+
domain = ApiGatewayDomainName('ApiGatewayDomainName' + logical_id,
232+
attributes=self.passthrough_resource_attributes)
233+
domain.DomainName = self.domain.get('DomainName')
234+
endpoint = self.domain.get('EndpointConfiguration')
235+
236+
if endpoint is None:
237+
endpoint = 'REGIONAL'
238+
elif endpoint not in ['EDGE', 'REGIONAL']:
239+
raise InvalidResourceException(self.logical_id,
240+
"EndpointConfiguration for Custom Domains must be"
241+
" one of {}".format(['EDGE', 'REGIONAL']))
242+
243+
if endpoint == 'REGIONAL':
244+
domain.RegionalCertificateArn = self.domain.get('CertificateArn')
245+
else:
246+
domain.CertificateArn = self.domain.get('CertificateArn')
247+
248+
domain.EndpointConfiguration = {"Types": [endpoint]}
249+
250+
# Create BasepathMappings
251+
if self.domain.get('BasePath') and isinstance(self.domain.get('BasePath'), string_types):
252+
basepaths = [self.domain.get('BasePath')]
253+
elif self.domain.get('BasePath') and isinstance(self.domain.get('BasePath'), list):
254+
basepaths = self.domain.get('BasePath')
255+
else:
256+
basepaths = None
257+
258+
basepath_resource_list = []
259+
260+
if basepaths is None:
261+
basepath_mapping = ApiGatewayBasePathMapping(self.logical_id + 'BasePathMapping',
262+
attributes=self.passthrough_resource_attributes)
263+
basepath_mapping.DomainName = self.domain.get('DomainName')
264+
basepath_mapping.RestApiId = ref(rest_api.logical_id)
265+
basepath_mapping.Stage = ref(rest_api.logical_id + '.Stage')
266+
basepath_resource_list.extend([basepath_mapping])
267+
else:
268+
for path in basepaths:
269+
path = ''.join(e for e in path if e.isalnum())
270+
logical_id = "{}{}{}".format(self.logical_id, path, 'BasePathMapping')
271+
basepath_mapping = ApiGatewayBasePathMapping(logical_id,
272+
attributes=self.passthrough_resource_attributes)
273+
basepath_mapping.DomainName = self.domain.get('DomainName')
274+
basepath_mapping.RestApiId = ref(rest_api.logical_id)
275+
basepath_mapping.Stage = ref(rest_api.logical_id + '.Stage')
276+
basepath_mapping.BasePath = path
277+
basepath_resource_list.extend([basepath_mapping])
278+
279+
return domain, basepath_resource_list
280+
214281
def to_cloudformation(self):
215282
"""Generates CloudFormation resources from a SAM API resource
216283
217284
:returns: a tuple containing the RestApi, Deployment, and Stage for an empty Api.
218285
:rtype: tuple
219286
"""
220287
rest_api = self._construct_rest_api()
288+
domain, basepath_mapping = self._construct_api_domain(rest_api)
221289
deployment = self._construct_deployment(rest_api)
222290

223291
swagger = None
@@ -229,7 +297,7 @@ def to_cloudformation(self):
229297
stage = self._construct_stage(deployment, swagger)
230298
permissions = self._construct_authorizer_lambda_permission()
231299

232-
return rest_api, deployment, stage, permissions
300+
return rest_api, deployment, stage, permissions, domain, basepath_mapping
233301

234302
def _add_cors(self):
235303
"""

samtranslator/model/apigateway.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import json
12
from re import match
2-
33
from samtranslator.model import PropertyType, Resource
44
from samtranslator.model.exceptions import InvalidResourceException
55
from samtranslator.model.types import is_type, one_of, is_str, list_of
@@ -76,12 +76,14 @@ class ApiGatewayDeployment(Resource):
7676
"deployment_id": lambda self: ref(self.logical_id),
7777
}
7878

79-
def make_auto_deployable(self, stage, openapi_version=None, swagger=None):
79+
def make_auto_deployable(self, stage, openapi_version=None, swagger=None, domain=None):
8080
"""
8181
Sets up the resource such that it will trigger a re-deployment when Swagger changes
82-
or the openapi version changes.
82+
or the openapi version changes or a domain resource changes.
8383
8484
:param swagger: Dictionary containing the Swagger definition of the API
85+
:param openapi_version: string containing value of OpenApiVersion flag in the template
86+
:param domain: Dictionary containing the custom domain configuration for the API
8587
"""
8688
if not swagger:
8789
return
@@ -95,6 +97,9 @@ def make_auto_deployable(self, stage, openapi_version=None, swagger=None):
9597
hash_input = [str(swagger)]
9698
if openapi_version:
9799
hash_input.append(str(openapi_version))
100+
if domain:
101+
hash_input.append(self._X_HASH_DELIMITER)
102+
hash_input.append(json.dumps(domain))
98103

99104
data = self._X_HASH_DELIMITER.join(hash_input)
100105
generator = logical_id_generator.LogicalIdGenerator(self.logical_id, data)
@@ -153,6 +158,26 @@ def _status_code_string(self, status_code):
153158
return None if status_code is None else str(status_code)
154159

155160

161+
class ApiGatewayDomainName(Resource):
162+
resource_type = 'AWS::ApiGateway::DomainName'
163+
property_types = {
164+
'RegionalCertificateArn': PropertyType(False, is_str()),
165+
'DomainName': PropertyType(True, is_str()),
166+
'EndpointConfiguration': PropertyType(False, is_type(dict)),
167+
'CertificateArn': PropertyType(False, is_str())
168+
}
169+
170+
171+
class ApiGatewayBasePathMapping(Resource):
172+
resource_type = 'AWS::ApiGateway::BasePathMapping'
173+
property_types = {
174+
'BasePath': PropertyType(False, is_str()),
175+
'DomainName': PropertyType(True, is_str()),
176+
'RestApiId': PropertyType(False, is_str()),
177+
'Stage': PropertyType(False, is_str())
178+
}
179+
180+
156181
class ApiGatewayAuthorizer(object):
157182
_VALID_FUNCTION_PAYLOAD_TYPES = [None, 'TOKEN', 'REQUEST']
158183

samtranslator/model/sam_resources.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,8 @@ class SamApi(SamResourceMacro):
482482
'CanarySetting': PropertyType(False, is_type(dict)),
483483
'TracingEnabled': PropertyType(False, is_type(bool)),
484484
'OpenApiVersion': PropertyType(False, is_str()),
485-
'Models': PropertyType(False, is_type(dict))
485+
'Models': PropertyType(False, is_type(dict)),
486+
'Domain': PropertyType(False, is_type(dict))
486487
}
487488

488489
referable_properties = {
@@ -502,6 +503,7 @@ def to_cloudformation(self, **kwargs):
502503

503504
intrinsics_resolver = kwargs["intrinsics_resolver"]
504505
self.BinaryMediaTypes = intrinsics_resolver.resolve_parameter_refs(self.BinaryMediaTypes)
506+
self.Domain = intrinsics_resolver.resolve_parameter_refs(self.Domain)
505507

506508
api_generator = ApiGenerator(self.logical_id,
507509
self.CacheClusterEnabled,
@@ -526,13 +528,17 @@ def to_cloudformation(self, **kwargs):
526528
resource_attributes=self.resource_attributes,
527529
passthrough_resource_attributes=self.get_passthrough_resource_attributes(),
528530
open_api_version=self.OpenApiVersion,
529-
models=self.Models)
531+
models=self.Models,
532+
domain=self.Domain)
530533

531-
rest_api, deployment, stage, permissions = api_generator.to_cloudformation()
534+
rest_api, deployment, stage, permissions, domain, basepath_mapping = api_generator.to_cloudformation()
532535

533536
resources.extend([rest_api, deployment, stage])
534537
resources.extend(permissions)
535-
538+
if domain:
539+
resources.extend([domain])
540+
if basepath_mapping:
541+
resources.extend(basepath_mapping)
536542
return resources
537543

538544

samtranslator/plugins/globals/globals.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ class Globals(object):
5959
"AccessLogSetting",
6060
"CanarySetting",
6161
"TracingEnabled",
62-
"OpenApiVersion"
62+
"OpenApiVersion",
63+
"Domain"
6364
],
6465

6566
SamResourceType.SimpleTable.value: [

samtranslator/plugins/globals/globals_plugin.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ def on_before_transform_template(self, template_dict):
2626
2727
:param dict template_dict: SAM template as a dictionary
2828
"""
29-
3029
try:
3130
global_section = Globals(template_dict)
3231
except InvalidGlobalsSectionException as ex:

samtranslator/translator/verify_logical_id.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
'AWS::SNS::Topic': 'AWS::SNS::Topic',
88
'AWS::DynamoDB::Table': 'AWS::Serverless::SimpleTable',
99
'AWS::CloudFormation::Stack': 'AWS::Serverless::Application',
10-
'AWS::Cognito::UserPool': 'AWS::Cognito::UserPool'
10+
'AWS::Cognito::UserPool': 'AWS::Cognito::UserPool',
11+
'AWS::ApiGateway::DomainName': 'AWS::ApiGateway::DomainName',
12+
'AWS::ApiGateway::BasePathMapping': 'AWS::ApiGateway::BasePathMapping'
1113
}
1214

1315

0 commit comments

Comments
 (0)