Skip to content

Commit e887671

Browse files
authored
Merge pull request #234 from adanaja/morosi-feat
Improve RestApi so it can have multiple resources
2 parents a75f956 + 1c668fd commit e887671

File tree

3 files changed

+463
-36
lines changed

3 files changed

+463
-36
lines changed

src/e3/aws/troposphere/apigateway/__init__.py

Lines changed: 144 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,29 @@ def __init__(
163163
self.variables = variables
164164

165165

166+
class Resource(object):
167+
"""REST API resource."""
168+
169+
def __init__(
170+
self,
171+
path: str,
172+
method_list: list[Method],
173+
resource_list: list[Resource] | None = None,
174+
lambda_arn: str | GetAtt | Ref | None = None,
175+
) -> None:
176+
"""Initialize a REST API resource.
177+
178+
:param path: the last path segment for this resource
179+
:param method_list: a list of methods accepted on this resource
180+
:param resource_list: a list of child resources
181+
:param lambda_arn: arn of the lambda executed for this resource
182+
"""
183+
self.path = path
184+
self.method_list = method_list
185+
self.resource_list = resource_list
186+
self.lambda_arn = lambda_arn
187+
188+
166189
class Api(Construct):
167190
"""API abstact Class for APIGateways V1 and V2."""
168191

@@ -679,9 +702,10 @@ def __init__(
679702
name: str,
680703
description: str,
681704
lambda_arn: str | GetAtt | Ref,
682-
method_list: list[Method],
705+
method_list: list[Method] | None = None,
683706
burst_limit: int = 10,
684707
rate_limit: int = 10,
708+
resource_list: list[Resource] | None = None,
685709
domain_name: str | None = None,
686710
hosted_zone_id: str | None = None,
687711
stages_config: list[StageConfiguration] | None = None,
@@ -713,6 +737,7 @@ def __init__(
713737
:param burst_limit: maximum concurrent requests at a given time
714738
(exceeding that limit will cause API Gateway to return 429)
715739
:param rate_limit: maximum number of requests per seconds
740+
:param resource_list: a list of resources to declare
716741
:param domain_name: if domain_name is not None then associate the API
717742
with a given domain name. In that case a certificate is
718743
automatically created for that domain name. Note that if a domain
@@ -738,20 +763,22 @@ def __init__(
738763
stages_config=stages_config,
739764
)
740765
self.method_list = method_list
741-
self.integration_uri = (
742-
integration_uri
743-
if integration_uri is not None
744-
else Sub(
745-
"arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31"
746-
"/functions/${lambdaArn}/invocations",
747-
dict_values={"lambdaArn": lambda_arn},
748-
)
749-
)
766+
self.integration_uri = integration_uri
750767
assert iam_path.startswith("/"), "iam_path must start with '/'"
751768
assert iam_path.endswith("/"), "iam_path must end with '/'"
752769
self.iam_path = iam_path
753770
self.policy = policy
754771

772+
# For backward compatibility
773+
if resource_list is None:
774+
assert (
775+
self.method_list is not None
776+
), "method_list can't be None when resource_list is None"
777+
# Add a default root resource to match everything
778+
resource_list = [Resource(path="{proxy+}", method_list=self.method_list)]
779+
780+
self.resource_list = resource_list
781+
755782
def add_cognito_authorizer(
756783
# we ignore the incompatible signature mypy errors
757784
self,
@@ -793,17 +820,21 @@ def declare_stage(
793820
"""
794821
result = []
795822

823+
# Get the list of methods for DependsOn
824+
method_list = [
825+
r
826+
for r in self._declare_resources(resource_list=self.resource_list)
827+
if isinstance(r, apigateway.Method)
828+
]
829+
796830
# create deployment resource
797831
deployment_name = self.logical_id + name_to_id(stage_name) + "Deployment"
798832
result.append(
799833
apigateway.Deployment(
800834
deployment_name,
801835
Description=f"Deployment resource of {stage_name} stage",
802836
RestApiId=Ref(self.logical_id),
803-
DependsOn=[
804-
name_to_id(self.name + method.method + "Method")
805-
for method in self.method_list
806-
],
837+
DependsOn=[m.name for m in method_list],
807838
)
808839
)
809840

@@ -855,24 +886,49 @@ def declare_stage(
855886

856887
return result
857888

858-
def declare_method(self, method: Method, resource_id: Ref) -> list[AWSObject]:
889+
def _declare_method(
890+
self,
891+
method: Method,
892+
resource: Resource,
893+
resource_id_prefix: str,
894+
resource_path: str,
895+
) -> list[AWSObject]:
859896
"""Declare a method.
860897
861898
:param method: the method definition
899+
:param resource: resource associated with the method
900+
:param resource_id_prefix: resource_id without trailing Resource
901+
:param resource_path: absolute path to the resource
862902
:return: a list of AWSObjects to be added to the stack
863903
"""
864904
result = []
865-
id_prefix = name_to_id(self.name + method.method)
905+
id_prefix = name_to_id(f"{resource_id_prefix}-{method.method}")
906+
907+
# Take the global lambda_arn or the one configured for the resource
908+
lambda_arn = (
909+
self.lambda_arn if resource.lambda_arn is None else resource.lambda_arn
910+
)
911+
912+
# Integration URI for the resource
913+
integration_uri = (
914+
self.integration_uri
915+
if self.integration_uri is not None
916+
else Sub(
917+
"arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31"
918+
"/functions/${lambdaArn}/invocations",
919+
dict_values={"lambdaArn": lambda_arn},
920+
)
921+
)
866922

867923
integration = apigateway.Integration(
868-
id_prefix + "Integration",
924+
f"{id_prefix}Integration",
869925
# set at POST because we are doing lambda integration
870926
CacheKeyParameters=[],
871927
CacheNamespace="none",
872928
IntegrationHttpMethod="POST",
873929
PassthroughBehavior="NEVER",
874930
Type="AWS_PROXY",
875-
Uri=self.integration_uri,
931+
Uri=integration_uri,
876932
)
877933

878934
method_params = {
@@ -882,12 +938,12 @@ def declare_method(self, method: Method, resource_id: Ref) -> list[AWSObject]:
882938
else "NONE",
883939
"HttpMethod": f"{method.method}",
884940
"Integration": integration,
885-
"ResourceId": resource_id,
941+
"ResourceId": Ref(name_to_id(f"{resource_id_prefix}Resource")),
886942
}
887943
if method.authorizer_name:
888944
method_params["AuthorizerId"] = Ref(name_to_id(method.authorizer_name))
889945

890-
result.append(apigateway.Method(id_prefix + "Method", **method_params))
946+
result.append(apigateway.Method(f"{id_prefix}Method", **method_params))
891947

892948
for config in self.stages_config:
893949
result.append(
@@ -899,11 +955,11 @@ def declare_method(self, method: Method, resource_id: Ref) -> list[AWSObject]:
899955
)
900956
),
901957
Action="lambda:InvokeFunction",
902-
FunctionName=self.lambda_arn,
958+
FunctionName=lambda_arn,
903959
Principal="apigateway.amazonaws.com",
904960
SourceArn=Sub(
905961
"arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:"
906-
f"${{api}}/{config.name}/${{method}}/*",
962+
f"${{api}}/{config.name}/${{method}}/{resource_path}",
907963
dict_values={
908964
"api": self.ref,
909965
"method": method.method,
@@ -961,6 +1017,70 @@ def _declare_api_mapping(
9611017
)
9621018
return result
9631019

1020+
def _declare_resources(
1021+
self,
1022+
resource_list: list[Resource],
1023+
parent_id_prefix: str | None = None,
1024+
parent_path: str | None = None,
1025+
) -> list[AWSObject]:
1026+
"""Create API resources and methods recursively.
1027+
1028+
Each resource can define its own methods and have child resources.
1029+
1030+
:param resource_list: list of resources
1031+
:param parent_id_prefix: id of the parent resource without trailing Resource
1032+
:return: a list of AWSObjects to be added to the stack
1033+
"""
1034+
result: list[AWSObject] = []
1035+
1036+
for r in resource_list:
1037+
# Append the path of this resource to the id of the parent resource.
1038+
# Use the API id in case there is no parent.
1039+
# Special {proxy+} case for backward compatibility
1040+
resource_id_prefix = name_to_id(
1041+
"{}{}".format(
1042+
self.logical_id if parent_id_prefix is None else parent_id_prefix,
1043+
"" if r.path == "{proxy+}" else f"-{r.path}",
1044+
)
1045+
)
1046+
1047+
# Append the path of this resource to the path of the parent resource
1048+
resource_path = "{}{}".format(
1049+
"" if parent_path is None else f"{parent_path}/",
1050+
"*" if r.path == "{proxy+}" else r.path,
1051+
)
1052+
1053+
# Declare the resource
1054+
resource = apigateway.Resource(
1055+
f"{resource_id_prefix}Resource",
1056+
ParentId=GetAtt(self.logical_id, "RootResourceId")
1057+
if parent_id_prefix is None
1058+
else GetAtt(f"{parent_id_prefix}Resource", "ResourceId"),
1059+
RestApiId=self.ref,
1060+
PathPart=r.path,
1061+
)
1062+
1063+
result.append(resource)
1064+
1065+
# Declare the methods of this resource
1066+
for method in r.method_list:
1067+
result += self._declare_method(
1068+
method=method,
1069+
resource=r,
1070+
resource_id_prefix=resource_id_prefix,
1071+
resource_path=resource_path,
1072+
)
1073+
1074+
# Declare the children of this resource
1075+
if r.resource_list:
1076+
result += self._declare_resources(
1077+
resource_list=r.resource_list,
1078+
parent_id_prefix=resource_id_prefix,
1079+
parent_path=resource_path,
1080+
)
1081+
1082+
return result
1083+
9641084
def _get_alias_target_attributes(self) -> Api._AliasTargetAttributes:
9651085
"""Get atributes to pass to GetAtt for alias target."""
9661086
return {
@@ -1023,16 +1143,8 @@ def resources(self, stack: Stack) -> list[AWSObject]:
10231143

10241144
result.append(apigateway.RestApi(self.logical_id, **api_params))
10251145

1026-
# Create an API resource
1027-
resource_name = self.logical_id + "Resource"
1028-
result.append(
1029-
apigateway.Resource(
1030-
resource_name,
1031-
ParentId=GetAtt(self.logical_id, "RootResourceId"),
1032-
RestApiId=self.ref,
1033-
PathPart="{proxy+}",
1034-
)
1035-
)
1146+
# Create API resources and methods
1147+
result += self._declare_resources(resource_list=self.resource_list)
10361148

10371149
# Declare the different stages
10381150
for config in self.stages_config:
@@ -1044,10 +1156,6 @@ def resources(self, stack: Stack) -> list[AWSObject]:
10441156
)
10451157
)
10461158

1047-
# Declare the methods
1048-
for method in self.method_list:
1049-
result += self.declare_method(method=method, resource_id=Ref(resource_name))
1050-
10511159
# Declare the domain
10521160
if self.domain_name is not None:
10531161
assert self.hosted_zone_id is not None

tests/tests_e3_aws/troposphere/apigateway/apigateway_test.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
GET,
1818
POST,
1919
Method,
20+
Resource,
2021
StageConfiguration,
2122
)
2223

@@ -706,3 +707,47 @@ def test_rest_api_custom_domain_stages(stack: Stack, lambda_fun: PyFunction) ->
706707

707708
print(stack.export()["Resources"])
708709
assert stack.export()["Resources"] == expected
710+
711+
712+
def test_rest_api_nested_resources(stack: Stack, lambda_fun: PyFunction) -> None:
713+
"""Test REST API with nested resources."""
714+
stack.s3_bucket = "cfn_bucket"
715+
stack.s3_key = "templates/"
716+
717+
# Lambda for the products resource
718+
products_lambda = PyFunction(
719+
name="productslambda",
720+
description="this is a test",
721+
role="somearn",
722+
code_dir="my_code_dir",
723+
handler="app.main",
724+
runtime="python3.8",
725+
logs_retention_in_days=None,
726+
)
727+
728+
rest_api = RestApi(
729+
name="testapi",
730+
description="this is a test",
731+
lambda_arn=lambda_fun.ref,
732+
resource_list=[
733+
Resource(path="accounts", method_list=[Method("ANY")]),
734+
Resource(
735+
path="products",
736+
# Specific lambda for this resource
737+
lambda_arn=products_lambda.ref,
738+
method_list=[Method("ANY")],
739+
resource_list=[Resource(path="abcd", method_list=[Method("ANY")])],
740+
),
741+
],
742+
)
743+
744+
stack.add(lambda_fun)
745+
stack.add(rest_api)
746+
747+
with open(
748+
os.path.join(TEST_DIR, "apigatewayv1_test_nested_resources.json"),
749+
) as fd:
750+
expected = json.load(fd)
751+
752+
print(stack.export()["Resources"])
753+
assert stack.export()["Resources"] == expected

0 commit comments

Comments
 (0)