Skip to content

Commit 5a27594

Browse files
author
Vandita Patidar
committed
Custom domain name support for private endpoints
1 parent 9f0d08c commit 5a27594

File tree

10 files changed

+683
-4
lines changed

10 files changed

+683
-4
lines changed

samtranslator/internal/schema_source/aws_serverless_api.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,23 @@ class Domain(BaseModel):
183183
["AWS::ApiGateway::DomainName", "Properties", "SecurityPolicy"],
184184
)
185185

186+
class DomainV2(BaseModel):
187+
BasePath: Optional[PassThroughProp] = domain("BasePath")
188+
NormalizeBasePath: Optional[bool] = domain("NormalizeBasePath")
189+
CertificateArn: PassThroughProp = domain("CertificateArn")
190+
DomainName: PassThroughProp = passthrough_prop(
191+
DOMAIN_STEM,
192+
"DomainName",
193+
["AWS::ApiGateway::DomainNameV2", "Properties", "DomainName"],
194+
)
195+
EndpointConfiguration: Optional[SamIntrinsicable[Literal["PRIVATE"]]] = domain("EndpointConfiguration")
196+
Route53: Optional[Route53] = domain("Route53")
197+
SecurityPolicy: Optional[PassThroughProp] = passthrough_prop(
198+
DOMAIN_STEM,
199+
"SecurityPolicy",
200+
["AWS::ApiGateway::DomainNameV2", "Properties", "SecurityPolicy"],
201+
)
202+
186203

187204
class DefinitionUri(BaseModel):
188205
Bucket: PassThroughProp = passthrough_prop(

samtranslator/model/api/api_generator.py

Lines changed: 139 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
ApiGatewayApiKey,
1111
ApiGatewayAuthorizer,
1212
ApiGatewayBasePathMapping,
13+
ApiGatewayBasePathMappingV2,
1314
ApiGatewayDeployment,
1415
ApiGatewayDomainName,
16+
ApiGatewayDomainNameV2,
1517
ApiGatewayResponse,
1618
ApiGatewayRestApi,
1719
ApiGatewayStage,
@@ -78,7 +80,11 @@ class ApiDomainResponse:
7880
apigw_basepath_mapping_list: Optional[List[ApiGatewayBasePathMapping]]
7981
recordset_group: Any
8082

81-
83+
@dataclass
84+
class ApiDomainResponseV2:
85+
domain: Optional[ApiGatewayDomainNameV2]
86+
apigw_basepath_mapping_list: Optional[List[ApiGatewayBasePathMappingV2]]
87+
recordset_group: Any
8288
class SharedApiUsagePlan:
8389
"""
8490
Collects API information from different API resources in the same template,
@@ -603,6 +609,129 @@ def _construct_api_domain( # noqa: PLR0912, PLR0915
603609

604610
return ApiDomainResponse(domain, basepath_resource_list, record_set_group)
605611

612+
def _construct_api_domain_v2(
613+
self, rest_api: ApiGatewayRestApi, route53_record_set_groups: Any
614+
) -> ApiDomainResponseV2:
615+
"""
616+
Constructs and returns the ApiGateway Domain and BasepathMapping
617+
"""
618+
if self.domain is None:
619+
return ApiDomainResponseV2(None, None, None)
620+
621+
sam_expect(self.domain, self.logical_id, "Domain").to_be_a_map()
622+
domain_name: PassThrough = sam_expect(
623+
self.domain.get("DomainName"), self.logical_id, "Domain.DomainName"
624+
).to_not_be_none()
625+
domain_name_arn: PassThrough = sam_expect(
626+
self.domain.get("DomainNameArn"), self.logical_id, "Domain.DomainNameArn"
627+
)
628+
certificate_arn: PassThrough = sam_expect(
629+
self.domain.get("CertificateArn"), self.logical_id, "Domain.CertificateArn"
630+
).to_not_be_none()
631+
632+
api_domain_name = "{}{}".format("ApiGatewayDomainNameV2", LogicalIdGenerator("", domain_name).gen())
633+
self.domain["ApiDomainName"] = api_domain_name
634+
domain = ApiGatewayDomainNameV2(api_domain_name, attributes=self.passthrough_resource_attributes)
635+
636+
domain.DomainName = domain_name
637+
endpoint = self.domain.get("EndpointConfiguration")
638+
639+
if endpoint not in ["EDGE", "REGIONAL", "PRIVATE"]:
640+
raise InvalidResourceException(
641+
self.logical_id,
642+
"EndpointConfiguration for Custom Domains must be"
643+
" one of {}.".format(["EDGE", "REGIONAL", "PRIVATE"]),
644+
)
645+
646+
domain.CertificateArn = certificate_arn
647+
648+
domain.EndpointConfiguration = {"Types": [endpoint]}
649+
650+
if self.domain.get("SecurityPolicy", None):
651+
domain.SecurityPolicy = self.domain["SecurityPolicy"]
652+
653+
if self.domain.get("Policy", None):
654+
domain.Policy = self.domain["Policy"]
655+
656+
basepaths: Optional[List[str]]
657+
basepath_value = self.domain.get("BasePath")
658+
# Create BasepathMappings
659+
if self.domain.get("BasePath") and isinstance(basepath_value, str):
660+
basepaths = [basepath_value]
661+
elif self.domain.get("BasePath") and isinstance(basepath_value, list):
662+
basepaths = cast(Optional[List[Any]], basepath_value)
663+
else:
664+
basepaths = None
665+
666+
# Boolean to allow/disallow symbols in BasePath property
667+
normalize_basepath = self.domain.get("NormalizeBasePath", True)
668+
669+
basepath_resource_list: List[ApiGatewayBasePathMappingV2] = []
670+
if basepaths is None:
671+
basepath_mapping = ApiGatewayBasePathMappingV2(
672+
self.logical_id + "BasePathMapping", attributes=self.passthrough_resource_attributes
673+
)
674+
basepath_mapping.DomainNameArn = ref(domain_name_arn)
675+
basepath_mapping.RestApiId = ref(rest_api.logical_id)
676+
basepath_mapping.Stage = ref(rest_api.logical_id + ".Stage")
677+
basepath_resource_list.extend([basepath_mapping])
678+
else:
679+
sam_expect(basepaths, self.logical_id, "Domain.BasePath").to_be_a_list_of(ExpectedType.STRING)
680+
for basepath in basepaths:
681+
# Remove possible leading and trailing '/' because a base path may only
682+
# contain letters, numbers, and one of "$-_.+!*'()"
683+
path = "".join(e for e in basepath if e.isalnum())
684+
logical_id = "{}{}{}".format(self.logical_id, path, "BasePathMapping")
685+
basepath_mapping = ApiGatewayBasePathMappingV2(
686+
logical_id, attributes=self.passthrough_resource_attributes
687+
)
688+
basepath_mapping.DomainNameArn = ref(domain_name_arn)
689+
basepath_mapping.RestApiId = ref(rest_api.logical_id)
690+
basepath_mapping.Stage = ref(rest_api.logical_id + ".Stage")
691+
basepath_mapping.BasePath = path if normalize_basepath else basepath
692+
basepath_resource_list.extend([basepath_mapping])
693+
694+
# Create the Route53 RecordSetGroup resource
695+
record_set_group = None
696+
route53 = self.domain.get("Route53")
697+
if route53 is not None:
698+
sam_expect(route53, self.logical_id, "Domain.Route53").to_be_a_map()
699+
if route53.get("HostedZoneId") is None and route53.get("HostedZoneName") is None:
700+
raise InvalidResourceException(
701+
self.logical_id,
702+
"HostedZoneId or HostedZoneName is required to enable Route53 support on Custom Domains.",
703+
)
704+
705+
logical_id_suffix = LogicalIdGenerator(
706+
"", route53.get("HostedZoneId") or route53.get("HostedZoneName")
707+
).gen()
708+
logical_id = "RecordSetGroup" + logical_id_suffix
709+
710+
record_set_group = route53_record_set_groups.get(logical_id)
711+
712+
if route53.get("SeparateRecordSetGroup"):
713+
sam_expect(
714+
route53.get("SeparateRecordSetGroup"), self.logical_id, "Domain.Route53.SeparateRecordSetGroup"
715+
).to_be_a_bool()
716+
return ApiDomainResponseV2(
717+
domain,
718+
basepath_resource_list,
719+
self._construct_single_record_set_group(self.domain, domain_name, route53),
720+
)
721+
722+
if not record_set_group:
723+
record_set_group = Route53RecordSetGroup(logical_id, attributes=self.passthrough_resource_attributes)
724+
if "HostedZoneId" in route53:
725+
record_set_group.HostedZoneId = route53.get("HostedZoneId")
726+
if "HostedZoneName" in route53:
727+
record_set_group.HostedZoneName = route53.get("HostedZoneName")
728+
record_set_group.RecordSets = []
729+
route53_record_set_groups[logical_id] = record_set_group
730+
731+
record_set_group.RecordSets += self._construct_record_sets_for_domain(self.domain, domain_name, route53)
732+
733+
return ApiDomainResponseV2(domain, basepath_resource_list, record_set_group)
734+
606735
def _construct_single_record_set_group(
607736
self, domain: Dict[str, Any], api_domain_name: str, route53: Any
608737
) -> Route53RecordSetGroup:
@@ -677,9 +806,15 @@ def to_cloudformation(
677806
:rtype: tuple
678807
"""
679808
rest_api = self._construct_rest_api()
680-
api_domain_response = self._construct_api_domain(rest_api, route53_record_set_groups)
681-
domain = api_domain_response.domain
682-
basepath_mapping = api_domain_response.apigw_basepath_mapping_list
809+
if self.endpoint_configuration == "PRIVATE":
810+
api_domain_response = self._construct_api_domain_v2(rest_api, route53_record_set_groups)
811+
domain = api_domain_response.domain
812+
basepath_mapping = api_domain_response.apigw_basepath_mapping_list
813+
else:
814+
api_domain_response = self._construct_api_domain(rest_api, route53_record_set_groups)
815+
domain = api_domain_response.domain
816+
basepath_mapping = api_domain_response.apigw_basepath_mapping_list
817+
683818
route53_recordsetGroup = api_domain_response.recordset_group
684819

685820
deployment = self._construct_deployment(rest_api)

samtranslator/model/apigateway.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,23 @@ class ApiGatewayDomainName(Resource):
230230
OwnershipVerificationCertificateArn: Optional[PassThrough]
231231

232232

233+
class ApiGatewayDomainNameV2(Resource):
234+
resource_type = "AWS::ApiGateway::DomainNameV2"
235+
property_types = {
236+
"DomainName": GeneratedProperty(),
237+
"EndpointConfiguration": GeneratedProperty(),
238+
"SecurityPolicy": GeneratedProperty(),
239+
"CertificateArn": GeneratedProperty(),
240+
"Tags": GeneratedProperty(),
241+
}
242+
243+
DomainName: PassThrough
244+
EndpointConfiguration: Optional[PassThrough]
245+
SecurityPolicy: Optional[PassThrough]
246+
CertificateArn: Optional[PassThrough]
247+
Tags: Optional[PassThrough]
248+
249+
233250
class ApiGatewayBasePathMapping(Resource):
234251
resource_type = "AWS::ApiGateway::BasePathMapping"
235252
property_types = {
@@ -240,6 +257,16 @@ class ApiGatewayBasePathMapping(Resource):
240257
}
241258

242259

260+
class ApiGatewayBasePathMappingV2(Resource):
261+
resource_type = "AWS::ApiGateway::BasePathMappingV2"
262+
property_types = {
263+
"BasePath": GeneratedProperty(),
264+
"DomainNameArn": GeneratedProperty(),
265+
"RestApiId": GeneratedProperty(),
266+
"Stage": GeneratedProperty(),
267+
}
268+
269+
243270
class ApiGatewayUsagePlan(Resource):
244271
resource_type = "AWS::ApiGateway::UsagePlan"
245272
property_types = {

samtranslator/model/sam_resources.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
ApiGatewayApiKey,
5454
ApiGatewayDeployment,
5555
ApiGatewayDomainName,
56+
ApiGatewayDomainNameV2,
5657
ApiGatewayStage,
5758
ApiGatewayUsagePlan,
5859
ApiGatewayUsagePlanKey,
@@ -1310,6 +1311,7 @@ class SamApi(SamResourceMacro):
13101311
"Stage": ApiGatewayStage.resource_type,
13111312
"Deployment": ApiGatewayDeployment.resource_type,
13121313
"DomainName": ApiGatewayDomainName.resource_type,
1314+
"DomainNameV2": ApiGatewayDomainNameV2.resource_type,
13131315
"UsagePlan": ApiGatewayUsagePlan.resource_type,
13141316
"UsagePlanKey": ApiGatewayUsagePlanKey.resource_type,
13151317
"ApiKey": ApiGatewayApiKey.resource_type,

samtranslator/translator/verify_logical_id.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
"AWS::Cognito::UserPool": "AWS::Cognito::UserPool",
1616
"AWS::ApiGateway::DomainName": "AWS::ApiGateway::DomainName",
1717
"AWS::ApiGateway::BasePathMapping": "AWS::ApiGateway::BasePathMapping",
18+
"AWS::ApiGateway::DomainNameV2": "AWS::ApiGateway::DomainNameV2",
19+
"AWS::ApiGateway::BasePathMappingV2": "AWS::ApiGateway::BasePathMappingV2",
1820
"AWS::StepFunctions::StateMachine": "AWS::Serverless::StateMachine",
1921
"AWS::AppSync::GraphQLApi": "AWS::Serverless::GraphQLApi",
2022
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
Parameters:
2+
DomainName:
3+
Type: String
4+
Default: private.example.com
5+
Description: Custom domain name for the API
6+
7+
CertificateArn:
8+
Type: String
9+
Default: arn:aws:acm:us-west-2:123456789:certificate/abcd-000-1234-0000-000000abcd
10+
Description: ARN of the ACM certificate for the domain
11+
12+
VpcEndpointId:
13+
Type: String
14+
Default: vpce-abcd1234efg
15+
Description: VPC Endpoint ID for private API access
16+
17+
Resources:
18+
MyApi:
19+
Type: AWS::Serverless::Api
20+
Properties:
21+
StageName: prod
22+
EndpointConfiguration:
23+
Type: PRIVATE
24+
Auth:
25+
ResourcePolicy:
26+
CustomStatements:
27+
- Effect: Allow
28+
Principal: '*'
29+
Action: execute-api:Invoke
30+
Resource: execute-api:/*
31+
- Effect: Deny
32+
Principal: '*'
33+
Action: execute-api:Invoke
34+
Resource: execute-api:/*
35+
Condition:
36+
StringNotEquals:
37+
aws:SourceVpce: !Ref VpcEndpointId
38+
39+
ApiDomainName:
40+
Type: AWS::ApiGateway::DomainNameV2
41+
Properties:
42+
DomainName: !Ref DomainName
43+
EndpointConfiguration:
44+
Types:
45+
- PRIVATE
46+
SecurityPolicy: TLS_1_2
47+
RegionalCertificateArn: !Ref CertificateArn
48+
49+
ApiMapping:
50+
Type: AWS::ApiGateway::BasePathMappingV2
51+
Properties:
52+
DomainName: !Ref ApiDomainName
53+
RestApiId: !Ref MyApi
54+
Stage: prod
55+
56+
DomainNameVpcEndpointAssociation:
57+
Type: AWS::ApiGateway::VpcEndpointAssociation
58+
Properties:
59+
DomainName: !Ref DomainName
60+
VpcEndpointId: !Ref VpcEndpointId
61+
62+
Outputs:
63+
ApiDomainName:
64+
Description: Custom Domain Name for the API
65+
Value: !Ref DomainName
66+
67+
ApiEndpoint:
68+
Description: API Gateway endpoint URL
69+
Value: !Sub https://${MyApi}.execute-api.${AWS::Region}.amazonaws.com/prod/

0 commit comments

Comments
 (0)