diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 8ba12654b..d6bd15a10 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -3725,7 +3725,7 @@ ## lambda
-49% implemented +55% implemented - [ ] add_layer_version_permission - [X] add_permission @@ -3733,7 +3733,7 @@ - [ ] create_code_signing_config - [X] create_event_source_mapping - [X] create_function -- [ ] create_function_url_config +- [X] create_function_url_config - [X] delete_alias - [ ] delete_code_signing_config - [X] delete_event_source_mapping @@ -3741,7 +3741,7 @@ - [ ] delete_function_code_signing_config - [X] delete_function_concurrency - [ ] delete_function_event_invoke_config -- [ ] delete_function_url_config +- [X] delete_function_url_config - [X] delete_layer_version - [ ] delete_provisioned_concurrency_config - [ ] get_account_settings @@ -3753,7 +3753,7 @@ - [X] get_function_concurrency - [ ] get_function_configuration - [ ] get_function_event_invoke_config -- [ ] get_function_url_config +- [X] get_function_url_config - [X] get_layer_version - [ ] get_layer_version_by_arn - [ ] get_layer_version_policy @@ -3789,7 +3789,7 @@ - [X] update_function_code - [X] update_function_configuration - [ ] update_function_event_invoke_config -- [ ] update_function_url_config +- [X] update_function_url_config
## logs diff --git a/docs/docs/services/lambda.rst b/docs/docs/services/lambda.rst index 4ea379fcc..b51c4e10c 100644 --- a/docs/docs/services/lambda.rst +++ b/docs/docs/services/lambda.rst @@ -33,7 +33,12 @@ lambda - [ ] create_code_signing_config - [X] create_event_source_mapping - [X] create_function -- [ ] create_function_url_config +- [X] create_function_url_config + + The Qualifier-parameter is not yet implemented. + Function URLs are not yet mocked, so invoking them will fail + + - [X] delete_alias - [ ] delete_code_signing_config - [X] delete_event_source_mapping @@ -41,7 +46,11 @@ lambda - [ ] delete_function_code_signing_config - [X] delete_function_concurrency - [ ] delete_function_event_invoke_config -- [ ] delete_function_url_config +- [X] delete_function_url_config + + The Qualifier-parameter is not yet implemented + + - [X] delete_layer_version - [ ] delete_provisioned_concurrency_config - [ ] get_account_settings @@ -53,7 +62,11 @@ lambda - [X] get_function_concurrency - [ ] get_function_configuration - [ ] get_function_event_invoke_config -- [ ] get_function_url_config +- [X] get_function_url_config + + The Qualifier-parameter is not yet implemented + + - [X] get_layer_version - [ ] get_layer_version_by_arn - [ ] get_layer_version_policy @@ -97,5 +110,9 @@ lambda - [X] update_function_code - [X] update_function_configuration - [ ] update_function_event_invoke_config -- [ ] update_function_url_config +- [X] update_function_url_config + + The Qualifier-parameter is not yet implemented + + diff --git a/moto/awslambda/exceptions.py b/moto/awslambda/exceptions.py index 364bccd9d..1f4808cf1 100644 --- a/moto/awslambda/exceptions.py +++ b/moto/awslambda/exceptions.py @@ -49,6 +49,15 @@ class UnknownFunctionException(LambdaClientError): super().__init__("ResourceNotFoundException", f"Function not found: {arn}") +class FunctionUrlConfigNotFound(LambdaClientError): + code = 404 + + def __init__(self): + super().__init__( + "ResourceNotFoundException", "The resource you requested does not exist." + ) + + class UnknownLayerException(LambdaClientError): code = 404 diff --git a/moto/awslambda/models.py b/moto/awslambda/models.py index 819a5cd44..cf3e7554b 100644 --- a/moto/awslambda/models.py +++ b/moto/awslambda/models.py @@ -35,6 +35,7 @@ from moto.s3.exceptions import MissingBucket, MissingKey from moto import settings from .exceptions import ( CrossAccountNotAllowed, + FunctionUrlConfigNotFound, InvalidRoleFormat, InvalidParameterValueException, UnknownLayerException, @@ -404,6 +405,7 @@ class LambdaFunction(CloudFormationModel, DockerModel): self.logs_backend = logs_backends[account_id][self.region] self.environment_vars = spec.get("Environment", {}).get("Variables", {}) self.policy = None + self.url_config = None self.state = "Active" self.reserved_concurrency = spec.get("ReservedConcurrentExecutions", None) @@ -914,6 +916,48 @@ class LambdaFunction(CloudFormationModel, DockerModel): alias.update(description, function_version, routing_config) return alias + def create_url_config(self, config): + self.url_config = FunctionUrlConfig(function=self, config=config) + return self.url_config + + def delete_url_config(self): + self.url_config = None + + def get_url_config(self): + if not self.url_config: + raise FunctionUrlConfigNotFound() + return self.url_config + + def update_url_config(self, config): + self.url_config.update(config) + return self.url_config + + +class FunctionUrlConfig: + def __init__(self, function: LambdaFunction, config): + self.function = function + self.config = config + self.url = f"https://{uuid.uuid4().hex}.lambda-url.{function.region}.on.aws" + self.created = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S") + self.last_modified = self.created + + def to_dict(self): + return { + "FunctionUrl": self.url, + "FunctionArn": self.function.function_arn, + "AuthType": self.config.get("AuthType"), + "Cors": self.config.get("Cors"), + "CreationTime": self.created, + "LastModifiedTime": self.last_modified, + } + + def update(self, new_config): + if new_config.get("Cors"): + self.config["Cors"] = new_config["Cors"] + if new_config.get("AuthType"): + self.config["AuthType"] = new_config["AuthType"] + self.last_modified = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S") + class EventSourceMapping(CloudFormationModel): def __init__(self, spec): @@ -1138,7 +1182,9 @@ class LambdaStorage(object): arn = ":".join(arn.split(":")[0:-1]) return self._arns.get(arn, None) - def get_function_by_name_or_arn(self, name_or_arn, qualifier=None): + def get_function_by_name_or_arn( + self, name_or_arn, qualifier=None + ) -> LambdaFunction: fn = self.get_function_by_name(name_or_arn, qualifier) or self.get_arn( name_or_arn ) @@ -1409,6 +1455,37 @@ class LambdaBackend(BaseBackend): fn.version = ver.version return fn + def create_function_url_config(self, name_or_arn, config): + """ + The Qualifier-parameter is not yet implemented. + Function URLs are not yet mocked, so invoking them will fail + """ + function = self._lambdas.get_function_by_name_or_arn(name_or_arn) + return function.create_url_config(config) + + def delete_function_url_config(self, name_or_arn): + """ + The Qualifier-parameter is not yet implemented + """ + function = self._lambdas.get_function_by_name_or_arn(name_or_arn) + function.delete_url_config() + + def get_function_url_config(self, name_or_arn): + """ + The Qualifier-parameter is not yet implemented + """ + function = self._lambdas.get_function_by_name_or_arn(name_or_arn) + if not function: + raise UnknownFunctionException(arn=name_or_arn) + return function.get_url_config() + + def update_function_url_config(self, name_or_arn, config): + """ + The Qualifier-parameter is not yet implemented + """ + function = self._lambdas.get_function_by_name_or_arn(name_or_arn) + return function.update_url_config(config) + def create_event_source_mapping(self, spec): required = ["EventSourceArn", "FunctionName"] for param in required: diff --git a/moto/awslambda/responses.py b/moto/awslambda/responses.py index ca355d583..e64c1c9dd 100644 --- a/moto/awslambda/responses.py +++ b/moto/awslambda/responses.py @@ -188,6 +188,19 @@ class LambdaResponse(BaseResponse): else: raise ValueError("Cannot handle request") + def function_url_config(self, request, full_url, headers): + http_method = request.method + self.setup_class(request, full_url, headers) + + if http_method == "DELETE": + return self._delete_function_url_config() + elif http_method == "GET": + return self._get_function_url_config() + elif http_method == "POST": + return self._create_function_url_config() + elif http_method == "PUT": + return self._update_function_url_config() + def _add_policy(self, request): path = request.path if hasattr(request, "path") else path_url(request.url) function_name = unquote(path.split("/")[-2]) @@ -284,6 +297,26 @@ class LambdaResponse(BaseResponse): config = fn.get_configuration(on_create=True) return 201, {}, json.dumps(config) + def _create_function_url_config(self): + function_name = unquote(self.path.split("/")[-2]) + config = self.backend.create_function_url_config(function_name, self.json_body) + return 201, {}, json.dumps(config.to_dict()) + + def _delete_function_url_config(self): + function_name = unquote(self.path.split("/")[-2]) + self.backend.delete_function_url_config(function_name) + return 204, {}, "{}" + + def _get_function_url_config(self): + function_name = unquote(self.path.split("/")[-2]) + config = self.backend.get_function_url_config(function_name) + return 201, {}, json.dumps(config.to_dict()) + + def _update_function_url_config(self): + function_name = unquote(self.path.split("/")[-2]) + config = self.backend.update_function_url_config(function_name, self.json_body) + return 200, {}, json.dumps(config.to_dict()) + def _create_event_source_mapping(self): fn = self.backend.create_event_source_mapping(self.json_body) config = fn.get_configuration() diff --git a/moto/awslambda/urls.py b/moto/awslambda/urls.py index a567197ed..348014251 100644 --- a/moto/awslambda/urls.py +++ b/moto/awslambda/urls.py @@ -22,6 +22,7 @@ url_paths = { r"{0}/(?P[^/]+)/functions/(?P[\w_:%-]+)/code/?$": response.code, r"{0}/(?P[^/]+)/functions/(?P[\w_:%-]+)/code-signing-config$": response.code_signing_config, r"{0}/(?P[^/]+)/functions/(?P[\w_:%-]+)/concurrency/?$": response.function_concurrency, + r"{0}/(?P[^/]+)/functions/(?P[\w_:%-]+)/url/?$": response.function_url_config, r"{0}/(?P[^/]+)/layers/?$": response.list_layers, r"{0}/(?P[^/]+)/layers/(?P[\w_-]+)/versions/?$": response.layers_versions, r"{0}/(?P[^/]+)/layers/(?P[\w_-]+)/versions/(?P[\w_-]+)$": response.layers_version, diff --git a/tests/terraformtests/terraform-tests.success.txt b/tests/terraformtests/terraform-tests.success.txt index 3a577cd5d..87f270543 100644 --- a/tests/terraformtests/terraform-tests.success.txt +++ b/tests/terraformtests/terraform-tests.success.txt @@ -140,6 +140,7 @@ kms: lambda: - TestAccLambdaAlias_ - TestAccLambdaLayerVersion_ + - TestAccLambdaFunctionURL meta: - TestAccMetaBillingServiceAccountDataSource mq: diff --git a/tests/test_awslambda/test_lambda_function_urls.py b/tests/test_awslambda/test_lambda_function_urls.py new file mode 100644 index 000000000..3353b3018 --- /dev/null +++ b/tests/test_awslambda/test_lambda_function_urls.py @@ -0,0 +1,125 @@ +import boto3 +import pytest +from botocore.exceptions import ClientError +from moto import mock_lambda +from uuid import uuid4 +from .utilities import get_test_zip_file1, get_role_name + + +@mock_lambda +@pytest.mark.parametrize("key", ["FunctionName", "FunctionArn"]) +def test_create_function_url_config(key): + client = boto3.client("lambda", "us-east-2") + function_name = str(uuid4())[0:6] + fxn = client.create_function( + FunctionName=function_name, + Runtime="python3.7", + Role=get_role_name(), + Handler="lambda_function.lambda_handler", + Code={"ZipFile": get_test_zip_file1()}, + ) + name_or_arn = fxn[key] + + resp = client.create_function_url_config( + AuthType="AWS_IAM", FunctionName=name_or_arn + ) + resp.should.have.key("FunctionArn").equals(fxn["FunctionArn"]) + resp.should.have.key("AuthType").equals("AWS_IAM") + resp.should.have.key("FunctionUrl") + + resp = client.get_function_url_config(FunctionName=name_or_arn) + resp.should.have.key("FunctionArn").equals(fxn["FunctionArn"]) + resp.should.have.key("AuthType").equals("AWS_IAM") + resp.should.have.key("FunctionUrl") + + +@mock_lambda +def test_create_function_url_config_with_cors(): + client = boto3.client("lambda", "us-east-2") + function_name = str(uuid4())[0:6] + fxn = client.create_function( + FunctionName=function_name, + Runtime="python3.7", + Role=get_role_name(), + Handler="lambda_function.lambda_handler", + Code={"ZipFile": get_test_zip_file1()}, + ) + name_or_arn = fxn["FunctionName"] + + resp = client.create_function_url_config( + AuthType="AWS_IAM", + FunctionName=name_or_arn, + Cors={ + "AllowCredentials": True, + "AllowHeaders": ["date", "keep-alive"], + "AllowMethods": ["*"], + "AllowOrigins": ["*"], + "ExposeHeaders": ["date", "keep-alive"], + "MaxAge": 86400, + }, + ) + resp.should.have.key("Cors").equals( + { + "AllowCredentials": True, + "AllowHeaders": ["date", "keep-alive"], + "AllowMethods": ["*"], + "AllowOrigins": ["*"], + "ExposeHeaders": ["date", "keep-alive"], + "MaxAge": 86400, + } + ) + + +@mock_lambda +def test_update_function_url_config_with_cors(): + client = boto3.client("lambda", "us-east-2") + function_name = str(uuid4())[0:6] + fxn = client.create_function( + FunctionName=function_name, + Runtime="python3.7", + Role=get_role_name(), + Handler="lambda_function.lambda_handler", + Code={"ZipFile": get_test_zip_file1()}, + ) + name_or_arn = fxn["FunctionName"] + + resp = client.create_function_url_config( + AuthType="AWS_IAM", + FunctionName=name_or_arn, + Cors={ + "AllowCredentials": True, + "AllowHeaders": ["date", "keep-alive"], + "AllowMethods": ["*"], + "AllowOrigins": ["*"], + "ExposeHeaders": ["date", "keep-alive"], + "MaxAge": 86400, + }, + ) + + resp = client.update_function_url_config( + FunctionName=name_or_arn, AuthType="NONE", Cors={"AllowCredentials": False} + ) + resp.should.have.key("Cors").equals({"AllowCredentials": False}) + + +@mock_lambda +@pytest.mark.parametrize("key", ["FunctionName", "FunctionArn"]) +def test_delete_function_url_config(key): + client = boto3.client("lambda", "us-east-2") + function_name = str(uuid4())[0:6] + fxn = client.create_function( + FunctionName=function_name, + Runtime="python3.7", + Role=get_role_name(), + Handler="lambda_function.lambda_handler", + Code={"ZipFile": get_test_zip_file1()}, + ) + name_or_arn = fxn[key] + + client.create_function_url_config(AuthType="AWS_IAM", FunctionName=name_or_arn) + + client.delete_function_url_config(FunctionName=name_or_arn) + + # It no longer exists + with pytest.raises(ClientError): + client.get_function_url_config(FunctionName=name_or_arn)