Skip to content

Commit 79a657c

Browse files
mndevecijonifevalerena
committed
feat: Support for Lambda URLs (#92)
* feat: Support for Lambda URL (#86) * feat: changed CorsConfig property to Cors (#89) Co-authored-by: jonife <[email protected]> Co-authored-by: Renato Valenzuela <[email protected]>
1 parent 0bc383f commit 79a657c

File tree

29 files changed

+1569
-3
lines changed

29 files changed

+1569
-3
lines changed

samtranslator/model/lambda_.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,12 @@ class LambdaLayerVersion(Resource):
121121
}
122122

123123
runtime_attrs = {"name": lambda self: ref(self.logical_id), "arn": lambda self: fnGetAtt(self.logical_id, "Arn")}
124+
125+
126+
class LambdaUrl(Resource):
127+
resource_type = "AWS::Lambda::Url"
128+
property_types = {
129+
"TargetFunctionArn": PropertyType(True, one_of(is_str(), is_type(dict))),
130+
"AuthorizationType": PropertyType(True, is_str()),
131+
"Cors": PropertyType(False, is_type(dict)),
132+
}

samtranslator/model/sam_resources.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
LambdaAlias,
3535
LambdaLayerVersion,
3636
LambdaEventInvokeConfig,
37+
LambdaUrl,
3738
)
3839
from samtranslator.model.types import dict_of, is_str, is_type, list_of, one_of, any_type
3940
from samtranslator.translator import logical_id_generator
@@ -93,6 +94,7 @@ class SamFunction(SamResourceMacro):
9394
"ImageConfig": PropertyType(False, is_type(dict)),
9495
"CodeSigningConfigArn": PropertyType(False, is_str()),
9596
"Architectures": PropertyType(False, list_of(one_of(is_str(), is_type(dict)))),
97+
"FunctionUrlConfig": PropertyType(False, is_type(dict)),
9698
}
9799
event_resolver = ResourceTypeResolver(
98100
samtranslator.model.eventsources,
@@ -169,6 +171,10 @@ def to_cloudformation(self, **kwargs):
169171
resources.append(lambda_version)
170172
resources.append(lambda_alias)
171173

174+
if self.FunctionUrlConfig:
175+
lambda_url = self._construct_lambda_url(lambda_function, lambda_alias)
176+
resources.append(lambda_url)
177+
172178
if self.DeploymentPreference:
173179
self._validate_deployment_preference_and_add_update_policy(
174180
kwargs.get("deployment_preference_collection", None),
@@ -843,6 +849,84 @@ def _validate_deployment_preference_and_add_update_policy(
843849
"UpdatePolicy", deployment_preference_collection.update_policy(self.logical_id).to_dict()
844850
)
845851

852+
def _construct_lambda_url(self, lambda_function, lambda_alias):
853+
"""
854+
This method is used to construct a lambda url resource
855+
856+
Parameters
857+
----------
858+
lambda_function : LambdaFunction
859+
Lambda Function resource
860+
lambda_alias : LambdaAlias
861+
Lambda Alias resource
862+
863+
Returns
864+
-------
865+
LambdaUrl
866+
Lambda Url resource
867+
"""
868+
self._validate_function_url_params(lambda_function)
869+
870+
logical_id = "{id}Url".format(id=lambda_function.logical_id)
871+
lambda_url = LambdaUrl(logical_id=logical_id)
872+
873+
cors = self.FunctionUrlConfig.get("Cors")
874+
if cors:
875+
lambda_url.Cors = cors
876+
lambda_url.AuthorizationType = self.FunctionUrlConfig.get("AuthorizationType")
877+
lambda_url.TargetFunctionArn = (
878+
lambda_alias.get_runtime_attr("arn") if lambda_alias else lambda_function.get_runtime_attr("name")
879+
)
880+
return lambda_url
881+
882+
def _validate_function_url_params(self, lambda_function):
883+
"""
884+
Validate parameters provided to configure Lambda Urls
885+
"""
886+
self._validate_url_auth_type(lambda_function)
887+
self._validate_cors_config_parameter(lambda_function)
888+
889+
def _validate_url_auth_type(self, lambda_function):
890+
if is_intrinsic(self.FunctionUrlConfig):
891+
return
892+
893+
if not self.FunctionUrlConfig.get("AuthorizationType"):
894+
raise InvalidResourceException(
895+
lambda_function.logical_id,
896+
"AuthorizationType is required to configure function property `FunctionUrlConfig`. Please provide either an IAM role name or NONE.",
897+
)
898+
899+
def _validate_cors_config_parameter(self, lambda_function):
900+
if is_intrinsic(self.FunctionUrlConfig):
901+
return
902+
903+
cors_property_data_type = {
904+
"AllowOrigins": list,
905+
"AllowMethods": list,
906+
"AllowCredentials": bool,
907+
"AllowHeaders": list,
908+
"ExposeHeaders": list,
909+
"MaxAge": int,
910+
}
911+
912+
cors = self.FunctionUrlConfig.get("Cors")
913+
914+
if not cors or is_intrinsic(cors):
915+
return
916+
917+
for prop_name, prop_value in cors.items():
918+
if prop_name not in cors_property_data_type:
919+
raise InvalidResourceException(
920+
lambda_function.logical_id,
921+
"{} is not a valid property for configuring Cors.".format(prop_name),
922+
)
923+
prop_type = cors_property_data_type.get(prop_name)
924+
if not is_intrinsic(prop_value) and not isinstance(prop_value, prop_type):
925+
raise InvalidResourceException(
926+
lambda_function.logical_id,
927+
"{} must be of type {}.".format(prop_name, str(prop_type).split("'")[1]),
928+
)
929+
846930

847931
class SamApi(SamResourceMacro):
848932
"""SAM rest API macro."""

samtranslator/plugins/globals/globals.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class Globals(object):
4343
"FileSystemConfigs",
4444
"CodeSigningConfigArn",
4545
"Architectures",
46+
"FunctionUrlConfig",
4647
],
4748
# Everything except
4849
# DefinitionBody: because its hard to reason about merge of Swagger dictionaries
@@ -80,6 +81,12 @@ class Globals(object):
8081
],
8182
SamResourceType.SimpleTable.value: ["SSESpecification"],
8283
}
84+
# unreleased_properties *must be* part of supported_properties too
85+
unreleased_properties = {
86+
SamResourceType.Function.value: [
87+
"FunctionUrlConfig",
88+
]
89+
}
8390

8491
def __init__(self, template):
8592
"""
@@ -195,14 +202,17 @@ def _parse(self, globals_dict):
195202
if not isinstance(properties, dict):
196203
raise InvalidGlobalsSectionException(self._KEYWORD, "Value of ${section} must be a dictionary")
197204

205+
supported = self.supported_properties[resource_type]
206+
supported_displayed = [
207+
prop for prop in supported if prop not in self.unreleased_properties.get(resource_type, [])
208+
]
198209
for key, value in properties.items():
199-
supported = self.supported_properties[resource_type]
200210
if key not in supported:
201211
raise InvalidGlobalsSectionException(
202212
self._KEYWORD,
203213
"'{key}' is not a supported property of '{section}'. "
204214
"Must be one of the following values - {supported}".format(
205-
key=key, section=section_name, supported=supported
215+
key=key, section=section_name, supported=supported_displayed
206216
),
207217
)
208218

tests/model/test_sam_resources.py

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from samtranslator.intrinsics.resolver import IntrinsicsResolver
66
from samtranslator.model import InvalidResourceException
77
from samtranslator.model.apigatewayv2 import ApiGatewayV2HttpApi
8-
from samtranslator.model.lambda_ import LambdaFunction, LambdaLayerVersion, LambdaVersion
8+
from samtranslator.model.lambda_ import LambdaFunction, LambdaLayerVersion, LambdaVersion, LambdaUrl
99
from samtranslator.model.apigateway import ApiGatewayDeployment, ApiGatewayRestApi
1010
from samtranslator.model.apigateway import ApiGatewayStage
1111
from samtranslator.model.iam import IAMRole
@@ -461,3 +461,162 @@ def test_invalid_compatible_architectures(self):
461461
layer.CompatibleArchitectures = architecturea
462462
with pytest.raises(InvalidResourceException):
463463
layer.to_cloudformation(**self.kwargs)
464+
465+
466+
class TestFunctionUrlConfig(TestCase):
467+
kwargs = {
468+
"intrinsics_resolver": IntrinsicsResolver({}),
469+
"event_resources": [],
470+
"managed_policy_map": {"foo": "bar"},
471+
}
472+
473+
@patch("boto3.session.Session.region_name", "ap-southeast-1")
474+
def test_with_function_url_config_with_no_authorization_type(self):
475+
function = SamFunction("foo")
476+
function.CodeUri = "s3://foobar/foo.zip"
477+
function.Runtime = "foo"
478+
function.Handler = "bar"
479+
function.FunctionUrlConfig = {"Cors": {"AllowOrigins": ["example1.com"]}}
480+
with pytest.raises(InvalidResourceException) as e:
481+
function.to_cloudformation(**self.kwargs)
482+
self.assertEqual(
483+
str(e.value.message),
484+
"Resource with id [foo] is invalid. AuthorizationType is required to configure"
485+
+ " function property `FunctionUrlConfig`. Please provide either an IAM role name or NONE.",
486+
)
487+
488+
@patch("boto3.session.Session.region_name", "ap-southeast-1")
489+
def test_with_function_url_config_with_no_cors_config(self):
490+
function = SamFunction("foo")
491+
function.CodeUri = "s3://foobar/foo.zip"
492+
function.Runtime = "foo"
493+
function.Handler = "bar"
494+
function.FunctionUrlConfig = {"AuthorizationType": "sample_IAM_role"}
495+
cfnResources = function.to_cloudformation(**self.kwargs)
496+
generatedUrlList = [x for x in cfnResources if isinstance(x, LambdaUrl)]
497+
self.assertEqual(generatedUrlList.__len__(), 1)
498+
self.assertEqual(generatedUrlList[0].AuthorizationType, "sample_IAM_role")
499+
500+
@patch("boto3.session.Session.region_name", "ap-southeast-1")
501+
def test_validate_function_url_config_properties_with_intrinsic(self):
502+
function = SamFunction("foo")
503+
function.CodeUri = "s3://foobar/foo.zip"
504+
function.Runtime = "foo"
505+
function.Handler = "bar"
506+
function.FunctionUrlConfig = {"AuthorizationType": {"Ref": "MyIAMRef"}, "Cors": {"Ref": "MyCorConfigRef"}}
507+
508+
cfnResources = function.to_cloudformation(**self.kwargs)
509+
generatedUrlList = [x for x in cfnResources if isinstance(x, LambdaUrl)]
510+
self.assertEqual(generatedUrlList.__len__(), 1)
511+
generatedUrlList = [x for x in cfnResources if isinstance(x, LambdaUrl)]
512+
self.assertEqual(generatedUrlList.__len__(), 1)
513+
self.assertEqual(generatedUrlList[0].AuthorizationType, {"Ref": "MyIAMRef"})
514+
self.assertEqual(generatedUrlList[0].Cors, {"Ref": "MyCorConfigRef"})
515+
516+
@patch("boto3.session.Session.region_name", "ap-southeast-1")
517+
def test_with_valid_function_url_config(self):
518+
cors = {
519+
"AllowOrigins": ["example1.com", "example2.com", "example2.com"],
520+
"AllowMethods": ["GET"],
521+
"AllowCredentials": True,
522+
"AllowHeaders": ["X-Custom-Header"],
523+
"ExposeHeaders": ["x-amzn-header"],
524+
"MaxAge": 10,
525+
}
526+
function = SamFunction("foo")
527+
function.CodeUri = "s3://foobar/foo.zip"
528+
function.Runtime = "foo"
529+
function.Handler = "bar"
530+
function.FunctionUrlConfig = {"AuthorizationType": "NONE", "Cors": cors}
531+
532+
cfnResources = function.to_cloudformation(**self.kwargs)
533+
generatedUrlList = [x for x in cfnResources if isinstance(x, LambdaUrl)]
534+
self.assertEqual(generatedUrlList.__len__(), 1)
535+
self.assertEqual(generatedUrlList[0].AuthorizationType, "NONE")
536+
self.assertEqual(generatedUrlList[0].Cors, cors)
537+
538+
@patch("boto3.session.Session.region_name", "ap-southeast-1")
539+
def test_with_valid_function_url_config_with_Intrinsics(self):
540+
function = SamFunction("foo")
541+
function.CodeUri = "s3://foobar/foo.zip"
542+
function.Runtime = "foo"
543+
function.Handler = "bar"
544+
function.FunctionUrlConfig = {"Ref": "MyFunctionUrlConfig"}
545+
546+
cfnResources = function.to_cloudformation(**self.kwargs)
547+
generatedUrlList = [x for x in cfnResources if isinstance(x, LambdaUrl)]
548+
self.assertEqual(generatedUrlList.__len__(), 1)
549+
550+
@patch("boto3.session.Session.region_name", "ap-southeast-1")
551+
def test_with_function_url_config_with_invalid_cors_parameter(self):
552+
function = SamFunction("foo")
553+
function.CodeUri = "s3://foobar/foo.zip"
554+
function.Runtime = "foo"
555+
function.Handler = "bar"
556+
function.FunctionUrlConfig = {"AuthorizationType": "NONE", "Cors": {"AllowOrigin": ["example1.com"]}}
557+
with pytest.raises(InvalidResourceException) as e:
558+
function.to_cloudformation(**self.kwargs)
559+
self.assertEqual(
560+
str(e.value.message),
561+
"Resource with id [foo] is invalid. AllowOrigin is not a valid property for configuring Cors.",
562+
)
563+
564+
@patch("boto3.session.Session.region_name", "ap-southeast-1")
565+
def test_with_function_url_config_with_invalid_cors_parameter_data_type(self):
566+
function = SamFunction("foo")
567+
function.CodeUri = "s3://foobar/foo.zip"
568+
function.Runtime = "foo"
569+
function.Handler = "bar"
570+
function.FunctionUrlConfig = {"AuthorizationType": "NONE", "Cors": {"AllowOrigins": "example1.com"}}
571+
with pytest.raises(InvalidResourceException) as e:
572+
function.to_cloudformation(**self.kwargs)
573+
self.assertEqual(
574+
str(e.value.message),
575+
"Resource with id [foo] is invalid. AllowOrigins must be of type list.",
576+
)
577+
578+
@patch("boto3.session.Session.region_name", "ap-southeast-1")
579+
def test_with_valid_function_url_config_without_auto_publish_alias(self):
580+
function = SamFunction("foo")
581+
function.CodeUri = "s3://foobar/foo.zip"
582+
function.Runtime = "foo"
583+
function.Handler = "bar"
584+
function.FunctionUrlConfig = {"AuthorizationType": "NONE", "Cors": {"AllowOrigins": ["example1.com"]}}
585+
586+
cfnResources = function.to_cloudformation(**self.kwargs)
587+
generatedUrlList = [x for x in cfnResources if isinstance(x, LambdaUrl)]
588+
self.assertEqual(generatedUrlList.__len__(), 1)
589+
expected_url_logicalid = {"Ref": "foo"}
590+
self.assertEqual(generatedUrlList[0].TargetFunctionArn, expected_url_logicalid)
591+
592+
@patch("boto3.session.Session.region_name", "ap-southeast-1")
593+
def test_with_valid_function_url_config_with_auto_publish_alias(self):
594+
function = SamFunction("foo")
595+
function.CodeUri = "s3://foobar/foo.zip"
596+
function.Runtime = "foo"
597+
function.Handler = "bar"
598+
function.AutoPublishAlias = "live"
599+
function.FunctionUrlConfig = {"AuthorizationType": "NONE", "Cors": {"AllowOrigins": ["example1.com"]}}
600+
601+
cfnResources = function.to_cloudformation(**self.kwargs)
602+
generatedUrlList = [x for x in cfnResources if isinstance(x, LambdaUrl)]
603+
self.assertEqual(generatedUrlList.__len__(), 1)
604+
expected_url_logicalid = {"Ref": "fooAliaslive"}
605+
self.assertEqual(generatedUrlList[0].TargetFunctionArn, expected_url_logicalid)
606+
607+
@patch("boto3.session.Session.region_name", "ap-southeast-1")
608+
def test_with_valid_function_url_config_with_authorization_type_value_as_None(self):
609+
610+
function = SamFunction("foo")
611+
function.CodeUri = "s3://foobar/foo.zip"
612+
function.Runtime = "foo"
613+
function.Handler = "bar"
614+
function.FunctionUrlConfig = {"AuthorizationType": None}
615+
616+
with pytest.raises(InvalidResourceException) as e:
617+
cfnResources = function.to_cloudformation(**self.kwargs)
618+
self.assertEqual(
619+
str(e.value.message),
620+
"Resource with id [foo] is invalid. AuthorizationType is required to configure function property "
621+
+ "`FunctionUrlConfig`. Please provide either an IAM role name or NONE.",
622+
)

tests/plugins/globals/test_globals.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,12 +206,16 @@ def setUp(self):
206206
self._originals = {
207207
"resource_prefix": Globals._RESOURCE_PREFIX,
208208
"supported_properties": Globals.supported_properties,
209+
"unreleased_properties": Globals.unreleased_properties,
209210
}
210211
Globals._RESOURCE_PREFIX = "prefix_"
211212
Globals.supported_properties = {
212213
"prefix_type1": ["prop1", "prop2"],
213214
"prefix_type2": ["otherprop1", "otherprop2"],
214215
}
216+
Globals.unreleased_properties = {
217+
"prefix_type1": ["prop2"],
218+
}
215219

216220
self.template = {
217221
"Globals": {
@@ -223,6 +227,7 @@ def setUp(self):
223227
def tearDown(self):
224228
Globals._RESOURCE_PREFIX = self._originals["resource_prefix"]
225229
Globals.supported_properties = self._originals["supported_properties"]
230+
Globals.unreleased_properties = self._originals["unreleased_properties"]
226231

227232
def test_parse_should_parse_all_known_resource_types(self):
228233
globals = Globals(self.template)
@@ -387,6 +392,17 @@ def test_merge_end_to_end_unknown_type(self):
387392

388393
self.assertEqual(expected, result)
389394

395+
def test_should_not_include_unreleased_properties_in_error_message(self):
396+
template = {"Globals": {"type1": {"unsupported_property": "value"}}}
397+
398+
with self.assertRaises(InvalidGlobalsSectionException) as exc:
399+
Globals(template)
400+
expected_message = (
401+
"'Globals' section is invalid. 'unsupported_property' is not a supported property of 'type1'. "
402+
+ "Must be one of the following values - ['prop1']"
403+
)
404+
self.assertEqual(exc.exception.message, expected_message)
405+
390406

391407
class TestGlobalsOpenApi(TestCase):
392408
template = {"Globals": {"Api": {"OpenApiVersion": "3.0"}}}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
AWSTemplateFormatVersion: '2010-09-09'
2+
Parameters: {}
3+
Resources:
4+
MyFunction:
5+
Type: AWS::Serverless::Function
6+
Properties:
7+
CodeUri: s3://sam-demo-bucket/hello.zip
8+
Description: Created by SAM
9+
Handler: index.handler
10+
MemorySize: 1024
11+
Runtime: nodejs12.x
12+
Timeout: 3
13+
FunctionUrlConfig:
14+
AuthorizationType: NONE
15+
Cors:
16+
AllowOrigin:
17+
- "https://example.com"

0 commit comments

Comments
 (0)