Skip to content

Commit 8c755fb

Browse files
authored
feat: Support SAM API MergeDefinitions property (#2943)
1 parent ed4b015 commit 8c755fb

File tree

33 files changed

+3165
-35
lines changed

33 files changed

+3165
-35
lines changed

docs/globals.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ Currently, the following resources and properties are being supported:
8181
Auth:
8282
Name:
8383
DefinitionUri:
84+
MergeDefinitions:
8485
CacheClusterEnabled:
8586
CacheClusterSize:
8687
Variables:

samtranslator/internal/schema_source/aws_serverless_api.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ class EndpointConfiguration(BaseModel):
158158

159159
Name = Optional[PassThroughProp]
160160
DefinitionUriType = Optional[Union[str, DefinitionUri]]
161+
MergeDefinitions = Optional[bool]
161162
CacheClusterEnabled = Optional[PassThroughProp]
162163
CacheClusterSize = Optional[PassThroughProp]
163164
Variables = Optional[PassThroughProp]
@@ -184,6 +185,7 @@ class Properties(BaseModel):
184185
Cors: Optional[CorsType] = properties("Cors")
185186
DefinitionBody: Optional[DictStrAny] = properties("DefinitionBody")
186187
DefinitionUri: Optional[DefinitionUriType] = properties("DefinitionUri")
188+
MergeDefinitions: Optional[MergeDefinitions] # TODO: update docs when live
187189
Description: Optional[PassThroughProp] = properties("Description")
188190
DisableExecuteApiEndpoint: Optional[PassThroughProp] = properties("DisableExecuteApiEndpoint")
189191
Domain: Optional[Domain] = properties("Domain")
@@ -208,6 +210,7 @@ class Globals(BaseModel):
208210
DefinitionUri: Optional[PassThroughProp] = properties("DefinitionUri")
209211
CacheClusterEnabled: Optional[CacheClusterEnabled] = properties("CacheClusterEnabled")
210212
CacheClusterSize: Optional[CacheClusterSize] = properties("CacheClusterSize")
213+
MergeDefinitions: Optional[MergeDefinitions] # TODO: update docs when live
211214
Variables: Optional[Variables] = properties("Variables")
212215
EndpointConfiguration: Optional[PassThroughProp] = properties("EndpointConfiguration")
213216
MethodSettings: Optional[MethodSettings] = properties("MethodSettings")

samtranslator/internal/schema_source/aws_serverless_function.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,12 +233,17 @@ class RequestParameters(BaseModel):
233233
Required: Optional[bool] = requestparameters("Required")
234234

235235

236+
# TODO: docs says either str or RequestParameter but implementation is an array of str or RequestParameter
237+
# remove this comment once updated documentation
238+
RequestModelProperty = List[Union[str, Dict[str, RequestParameters]]]
239+
240+
236241
class ApiEventProperties(BaseModel):
237242
Auth: Optional[ApiAuth] = apieventproperties("Auth")
238243
Method: str = apieventproperties("Method")
239244
Path: str = apieventproperties("Path")
240245
RequestModel: Optional[RequestModel] = apieventproperties("RequestModel")
241-
RequestParameters: Optional[Union[str, RequestParameters]] = apieventproperties("RequestParameters")
246+
RequestParameters: Optional[RequestModelProperty] = apieventproperties("RequestParameters")
242247
RestApiId: Optional[Union[str, Ref]] = apieventproperties("RestApiId")
243248

244249

samtranslator/model/api/api_generator.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ def __init__( # noqa: too-many-arguments
172172
stage_name: Optional[Intrinsicable[str]],
173173
shared_api_usage_plan: Any,
174174
template_conditions: Any,
175+
merge_definitions: Optional[bool] = None,
175176
tags: Optional[Dict[str, Any]] = None,
176177
endpoint_configuration: Optional[Dict[str, Any]] = None,
177178
method_settings: Optional[List[Any]] = None,
@@ -221,6 +222,7 @@ def __init__( # noqa: too-many-arguments
221222
self.depends_on = depends_on
222223
self.definition_body = definition_body
223224
self.definition_uri = definition_uri
225+
self.merge_definitions = merge_definitions
224226
self.name = name
225227
self.stage_name = stage_name
226228
self.tags = tags
@@ -254,6 +256,7 @@ def _construct_rest_api(self) -> ApiGatewayRestApi:
254256
:returns: the RestApi to which this SAM Api corresponds
255257
:rtype: model.apigateway.ApiGatewayRestApi
256258
"""
259+
self._validate_properties()
257260
rest_api = ApiGatewayRestApi(self.logical_id, depends_on=self.depends_on, attributes=self.resource_attributes)
258261
# NOTE: For backwards compatibility we need to retain BinaryMediaTypes on the CloudFormation Property
259262
# Removing this and only setting x-amazon-apigateway-binary-media-types results in other issues.
@@ -268,16 +271,6 @@ def _construct_rest_api(self) -> ApiGatewayRestApi:
268271
# to Regional which is the only supported config.
269272
self._set_endpoint_configuration(rest_api, "REGIONAL")
270273

271-
if self.definition_uri and self.definition_body:
272-
raise InvalidResourceException(
273-
self.logical_id, "Specify either 'DefinitionUri' or 'DefinitionBody' property and not both."
274-
)
275-
276-
if self.open_api_version and not SwaggerEditor.safe_compare_regex_with_string(
277-
SwaggerEditor.get_openapi_versions_supported_regex(), self.open_api_version
278-
):
279-
raise InvalidResourceException(self.logical_id, "The OpenApiVersion value must be of the format '3.0.0'.")
280-
281274
self._add_cors()
282275
self._add_auth()
283276
self._add_gateway_responses()
@@ -311,6 +304,22 @@ def _construct_rest_api(self) -> ApiGatewayRestApi:
311304

312305
return rest_api
313306

307+
def _validate_properties(self) -> None:
308+
if self.definition_uri and self.definition_body:
309+
raise InvalidResourceException(
310+
self.logical_id, "Specify either 'DefinitionUri' or 'DefinitionBody' property and not both."
311+
)
312+
313+
if self.definition_uri and self.merge_definitions:
314+
raise InvalidResourceException(
315+
self.logical_id, "Cannot set 'MergeDefinitions' to True when using `DefinitionUri`."
316+
)
317+
318+
if self.open_api_version and not SwaggerEditor.safe_compare_regex_with_string(
319+
SwaggerEditor.get_openapi_versions_supported_regex(), self.open_api_version
320+
):
321+
raise InvalidResourceException(self.logical_id, "The OpenApiVersion value must be of the format '3.0.0'.")
322+
314323
def _add_endpoint_extension(self) -> None:
315324
"""Add disableExecuteApiEndpoint if it is set in SAM
316325
Note:

samtranslator/model/eventsources/push.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from samtranslator.translator import logical_id_generator
2626
from samtranslator.translator.arn_generator import ArnGenerator
2727
from samtranslator.utils.py27hash_fix import Py27Dict, Py27UniStr
28+
from samtranslator.utils.utils import InvalidValueType, dict_deep_get
2829
from samtranslator.validator.value_validator import sam_expect
2930

3031
CONDITION = "Condition"
@@ -705,7 +706,7 @@ def to_cloudformation(self, **kwargs): # type: ignore[no-untyped-def]
705706

706707
explicit_api = kwargs["explicit_api"]
707708
api_id = kwargs["api_id"]
708-
if explicit_api.get("__MANAGE_SWAGGER"):
709+
if explicit_api.get("__MANAGE_SWAGGER") or explicit_api.get("MergeDefinitions"):
709710
self._add_swagger_integration(explicit_api, api_id, function, intrinsics_resolver) # type: ignore[no-untyped-call]
710711

711712
return resources
@@ -757,8 +758,13 @@ def _add_swagger_integration( # type: ignore[no-untyped-def] # noqa: too-many-s
757758
:param model.apigateway.ApiGatewayRestApi rest_api: the RestApi to which the path and method should be added.
758759
"""
759760
swagger_body = api.get("DefinitionBody")
761+
merge_definitions = api.get("MergeDefinitions")
760762
if swagger_body is None:
761763
return
764+
if merge_definitions:
765+
# Use a skeleton swagger body for API event source to make sure the generated definition body
766+
# is unaffected by the inline/customer defined DefinitionBody
767+
swagger_body = SwaggerEditor.gen_skeleton()
762768

763769
partition = ArnGenerator.get_partition_name()
764770
uri = _build_apigw_integration_uri(function, partition) # type: ignore[no-untyped-call]
@@ -921,7 +927,38 @@ def _add_swagger_integration( # type: ignore[no-untyped-def] # noqa: too-many-s
921927
path=self.Path, method_name=self.Method, request_parameters=parameters
922928
)
923929

924-
api["DefinitionBody"] = editor.swagger
930+
if merge_definitions:
931+
api["DefinitionBody"] = self._get_merged_definitions(api_id, api["DefinitionBody"], editor.swagger)
932+
else:
933+
api["DefinitionBody"] = editor.swagger
934+
935+
def _get_merged_definitions(
936+
self, api_id: str, source_definition_body: Dict[str, Any], dest_definition_body: Dict[str, Any]
937+
) -> Dict[str, Any]:
938+
"""
939+
Merge SAM generated swagger definition(dest_definition_body) into inline DefinitionBody(source_definition_body):
940+
- for a conflicting key, use SAM generated value
941+
- otherwise include key-value pairs from both definitions
942+
"""
943+
merged_definition_body = source_definition_body.copy()
944+
source_body_paths = merged_definition_body.get("paths", {})
945+
946+
try:
947+
path_method_body = dict_deep_get(source_body_paths, [self.Path, self.Method]) or {}
948+
except InvalidValueType as e:
949+
raise InvalidResourceException(api_id, f"Property 'DefinitionBody' is invalid: {str(e)}") from e
950+
951+
sam_expect(path_method_body, api_id, f"DefinitionBody.paths.{self.Path}.{self.Method}").to_be_a_map()
952+
953+
generated_path_method_body = dest_definition_body["paths"][self.Path][self.Method]
954+
# this guarantees that the merged definition use SAM generated value for a conflicting key
955+
merged_path_method_body = {**path_method_body, **generated_path_method_body}
956+
957+
if self.Path not in source_body_paths:
958+
source_body_paths[self.Path] = {self.Method: merged_path_method_body}
959+
source_body_paths[self.Path][self.Method] = merged_path_method_body
960+
961+
return merged_definition_body
925962

926963
@staticmethod
927964
def get_rest_api_id_string(rest_api_id: Any) -> Any:

samtranslator/model/sam_resources.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,6 +1146,7 @@ class SamApi(SamResourceMacro):
11461146
"Tags": PropertyType(False, IS_DICT),
11471147
"DefinitionBody": PropertyType(False, IS_DICT),
11481148
"DefinitionUri": PropertyType(False, one_of(IS_STR, IS_DICT)),
1149+
"MergeDefinitions": Property(False, is_type(bool)),
11491150
"CacheClusterEnabled": PropertyType(False, is_type(bool)),
11501151
"CacheClusterSize": PropertyType(False, IS_STR),
11511152
"Variables": PropertyType(False, IS_DICT),
@@ -1174,6 +1175,7 @@ class SamApi(SamResourceMacro):
11741175
Tags: Optional[Dict[str, Any]]
11751176
DefinitionBody: Optional[Dict[str, Any]]
11761177
DefinitionUri: Optional[Intrinsicable[str]]
1178+
MergeDefinitions: Optional[bool]
11771179
CacheClusterEnabled: Optional[Intrinsicable[bool]]
11781180
CacheClusterSize: Optional[Intrinsicable[str]]
11791181
Variables: Optional[Dict[str, Any]]
@@ -1239,6 +1241,7 @@ def to_cloudformation(self, **kwargs): # type: ignore[no-untyped-def]
12391241
template_conditions,
12401242
tags=self.Tags,
12411243
endpoint_configuration=self.EndpointConfiguration,
1244+
merge_definitions=self.MergeDefinitions,
12421245
method_settings=self.MethodSettings,
12431246
binary_media=self.BinaryMediaTypes,
12441247
minimum_compression_size=self.MinimumCompressionSize,

samtranslator/schema/schema.json

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -196857,6 +196857,10 @@
196857196857
"title": "GatewayResponses",
196858196858
"type": "object"
196859196859
},
196860+
"MergeDefinitions": {
196861+
"title": "Mergedefinitions",
196862+
"type": "boolean"
196863+
},
196860196864
"MethodSettings": {
196861196865
"allOf": [
196862196866
{
@@ -197091,6 +197095,10 @@
197091197095
"title": "GatewayResponses",
197092197096
"type": "object"
197093197097
},
197098+
"MergeDefinitions": {
197099+
"title": "Mergedefinitions",
197100+
"type": "boolean"
197101+
},
197094197102
"MethodSettings": {
197095197103
"allOf": [
197096197104
{
@@ -197723,17 +197731,23 @@
197723197731
"title": "RequestModel"
197724197732
},
197725197733
"RequestParameters": {
197726-
"anyOf": [
197727-
{
197728-
"type": "string"
197729-
},
197730-
{
197731-
"$ref": "#/definitions/RequestParameters"
197732-
}
197733-
],
197734197734
"description": "Request parameters configuration for this specific Api\\+Path\\+Method\\. All parameter names must start with `method.request` and must be limited to `method.request.header`, `method.request.querystring`, or `method.request.path`\\. \nIf a parameter is a string and not a Function Request Parameter Object, then `Required` and `Caching` will default to false\\. \n*Type*: String \\| [RequestParameter](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-requestparameter.html) \n*Required*: No \n*AWS CloudFormation compatibility*: This property is unique to AWS SAM and doesn't have an AWS CloudFormation equivalent\\.",
197735+
"items": {
197736+
"anyOf": [
197737+
{
197738+
"type": "string"
197739+
},
197740+
{
197741+
"additionalProperties": {
197742+
"$ref": "#/definitions/RequestParameters"
197743+
},
197744+
"type": "object"
197745+
}
197746+
]
197747+
},
197735197748
"markdownDescription": "Request parameters configuration for this specific Api\\+Path\\+Method\\. All parameter names must start with `method.request` and must be limited to `method.request.header`, `method.request.querystring`, or `method.request.path`\\. \nIf a parameter is a string and not a Function Request Parameter Object, then `Required` and `Caching` will default to false\\. \n*Type*: String \\| [RequestParameter](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-requestparameter.html) \n*Required*: No \n*AWS CloudFormation compatibility*: This property is unique to AWS SAM and doesn't have an AWS CloudFormation equivalent\\.",
197736-
"title": "RequestParameters"
197749+
"title": "RequestParameters",
197750+
"type": "array"
197737197751
},
197738197752
"RestApiId": {
197739197753
"anyOf": [

samtranslator/utils/utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import copy
2-
from typing import Any, List, Optional, cast
2+
from typing import Any, List, Optional, Union, cast
33

44

55
def as_array(x: Any) -> List[Any]:
@@ -31,15 +31,15 @@ def __init__(self, relative_path: str) -> None:
3131
super().__init__("It should be a map")
3232

3333

34-
def dict_deep_get(d: Any, path: str) -> Optional[Any]:
34+
def dict_deep_get(d: Any, path: Union[str, List[str]]) -> Optional[Any]:
3535
"""
3636
Get the value deep in the dict.
3737
3838
If any value along the path doesn't exist, return None.
3939
If any parent node exists but is not a dict, raise InvalidValueType.
4040
"""
4141
relative_path = ""
42-
_path_nodes = path.split(".")
42+
_path_nodes = path.split(".") if isinstance(path, str) else path
4343
while _path_nodes:
4444
if d is None:
4545
return None

samtranslator/validator/sam_schema/definitions/api.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@
9999
],
100100
"$ref": "#definitions/AWS::Serverless::Api.S3Location"
101101
},
102+
"MergeDefinitions": {
103+
"type": [
104+
"boolean",
105+
"intrinsic"
106+
]
107+
},
102108
"Description": {
103109
"type": [
104110
"string",

schema_source/sam.schema.json

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3256,6 +3256,10 @@
32563256
"title": "GatewayResponses",
32573257
"type": "object"
32583258
},
3259+
"MergeDefinitions": {
3260+
"title": "Mergedefinitions",
3261+
"type": "boolean"
3262+
},
32593263
"MethodSettings": {
32603264
"allOf": [
32613265
{
@@ -3490,6 +3494,10 @@
34903494
"title": "GatewayResponses",
34913495
"type": "object"
34923496
},
3497+
"MergeDefinitions": {
3498+
"title": "Mergedefinitions",
3499+
"type": "boolean"
3500+
},
34933501
"MethodSettings": {
34943502
"allOf": [
34953503
{
@@ -4122,17 +4130,23 @@
41224130
"title": "RequestModel"
41234131
},
41244132
"RequestParameters": {
4125-
"anyOf": [
4126-
{
4127-
"type": "string"
4128-
},
4129-
{
4130-
"$ref": "#/definitions/RequestParameters"
4131-
}
4132-
],
41334133
"description": "Request parameters configuration for this specific Api\\+Path\\+Method\\. All parameter names must start with `method.request` and must be limited to `method.request.header`, `method.request.querystring`, or `method.request.path`\\. \nIf a parameter is a string and not a Function Request Parameter Object, then `Required` and `Caching` will default to false\\. \n*Type*: String \\| [RequestParameter](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-requestparameter.html) \n*Required*: No \n*AWS CloudFormation compatibility*: This property is unique to AWS SAM and doesn't have an AWS CloudFormation equivalent\\.",
4134+
"items": {
4135+
"anyOf": [
4136+
{
4137+
"type": "string"
4138+
},
4139+
{
4140+
"additionalProperties": {
4141+
"$ref": "#/definitions/RequestParameters"
4142+
},
4143+
"type": "object"
4144+
}
4145+
]
4146+
},
41344147
"markdownDescription": "Request parameters configuration for this specific Api\\+Path\\+Method\\. All parameter names must start with `method.request` and must be limited to `method.request.header`, `method.request.querystring`, or `method.request.path`\\. \nIf a parameter is a string and not a Function Request Parameter Object, then `Required` and `Caching` will default to false\\. \n*Type*: String \\| [RequestParameter](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-property-function-requestparameter.html) \n*Required*: No \n*AWS CloudFormation compatibility*: This property is unique to AWS SAM and doesn't have an AWS CloudFormation equivalent\\.",
4135-
"title": "RequestParameters"
4148+
"title": "RequestParameters",
4149+
"type": "array"
41364150
},
41374151
"RestApiId": {
41384152
"anyOf": [

0 commit comments

Comments
 (0)