From 69f3b71e18ceb85d26f9f38a5336876a1fe341ae Mon Sep 17 00:00:00 2001 From: Rehan Hawari Date: Sat, 15 Oct 2022 16:44:38 +0700 Subject: [PATCH] feat: implement handler for event with format version 2.0 --- tests/test_handler.py | 32 +++++++++ tests/tests.py | 152 ++++++++++++++++++++++++++++++++++++++++++ zappa/handler.py | 121 +++++++++++++++++++++++++++++++++ zappa/wsgi.py | 92 ++++++++++++++++--------- 4 files changed, 366 insertions(+), 31 deletions(-) diff --git a/tests/test_handler.py b/tests/test_handler.py index b8cb59fee..95134c9d6 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -577,3 +577,35 @@ def test_cloudwatch_subscription_event(self): response = lh.handler(event, None) self.assertEqual(response, True) + + def test_wsgi_script_name_on_v2_formatted_event(self): + """ + Ensure that requests with payload format version 2.0 succeed + """ + lh = LambdaHandler("tests.test_wsgi_script_name_settings") + + event = { + "version": "2.0", + "routeKey": "$default", + "rawPath": "/", + "rawQueryString": "", + "headers": { + "host": "1234567890.execute-api.us-east-1.amazonaws.com", + }, + "requestContext": { + "http": { + "method": "GET", + "path": "/return/request/url", + }, + }, + "isBase64Encoded": False, + "body": "", + "cookies": ["Cookie_1=Value1; Expires=21 Oct 2021 07:48 GMT", "Cookie_2=Value2; Max-Age=78000"], + } + response = lh.handler(event, None) + + self.assertEqual(response["statusCode"], 200) + self.assertEqual( + response["body"], + "https://1234567890.execute-api.us-east-1.amazonaws.com/dev/return/request/url", + ) diff --git a/tests/tests.py b/tests/tests.py index 563e158f5..abd47e73c 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -1086,6 +1086,158 @@ def test_wsgi_from_apigateway_testbutton(self): response_tuple = collections.namedtuple("Response", ["status_code", "content"]) response = response_tuple(200, "hello") + def test_wsgi_from_v2_event(self): + event = { + "version": "2.0", + "routeKey": "ANY /{proxy+}", + "rawPath": "/", + "rawQueryString": "", + "headers": { + "accept": "*/*", + "accept-encoding": "gzip, deflate, br", + "accept-language": "en-US,en;q=0.9", + "cache-control": "no-cache", + "content-length": "0", + "dnt": "1", + "host": "qw8klxioji.execute-api.eu-west-1.amazonaws.com", + "pragma": "no-cache", + "upgrade-insecure-requests": "1", + "user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36", + "x-forwarded-for": "50.191.225.98", + "x-forwarded-port": "443", + "x-forwarded-proto": "https", + }, + "requestContext": { + "accountId": "724336686645", + "apiId": "qw8klxioji", + "domainName": "qw8klxioji.execute-api.eu-west-1.amazonaws.com", + "domainPrefix": "qw8klxioji", + "http": { + "method": "GET", + "path": "/", + "protocol": "HTTP/1.1", + "sourceIp": "50.191.225.98", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36", + }, + "requestId": "xTG4wqXdSQ0RHpA=", + "routeKey": "ANY /{proxy+}", + "stage": "$default", + "time": "16/Oct/2022:11:17:12 +0000", + "timeEpoch": 1665919032135, + }, + "pathParameters": {"proxy": ""}, + "isBase64Encoded": False, + } + environ = create_wsgi_request(event, event_version="2.0") + self.assertTrue(environ) + + def test_wsgi_from_v2_event_with_lambda_authorizer(self): + principal_id = "user|a1b2c3d4" + authorizer = {"lambda": {"bool": True, "key": "value", "number": 1, "principalId": principal_id}} + event = { + "version": "2.0", + "routeKey": "ANY /{proxy+}", + "rawPath": "/", + "rawQueryString": "", + "headers": { + "accept": "*/*", + "accept-encoding": "gzip, deflate, br", + "authorization": "Bearer 1232314343", + "content-length": "28", + "content-type": "application/json", + "host": "qw8klxioji.execute-api.eu-west-1.amazonaws.com", + "user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36", + "x-forwarded-for": "50.191.225.98", + "x-forwarded-port": "443", + "x-forwarded-proto": "https", + }, + "requestContext": { + "accountId": "724336686645", + "apiId": "qw8klxioji", + "authorizer": authorizer, + "domainName": "qw8klxioji.execute-api.eu-west-1.amazonaws.com", + "domainPrefix": "qw8klxioji", + "http": { + "method": "POST", + "path": "/", + "protocol": "HTTP/1.1", + "sourceIp": "50.191.225.98", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36", + }, + "requestId": "aJ6Rqi93zQ0GPng=", + "routeKey": "ANY /{proxy+}", + "stage": "$default", + "time": "17/Oct/2022:14:58:44 +0000", + "timeEpoch": 1666018724000, + }, + "pathParameters": {"proxy": ""}, + "body": "{'data':'0123456789'}", + "isBase64Encoded": False, + } + environ = create_wsgi_request(event, event_version="2.0") + self.assertEqual(environ["API_GATEWAY_AUTHORIZER"], authorizer) + self.assertEqual(environ["REMOTE_USER"], principal_id) + + def test_wsgi_from_v2_event_with_iam_authorizer(self): + user_arn = "arn:aws:sts::724336686645:assumed-role/SAMLUSER/user.name" + authorizer = { + "iam": { + "accessKey": "AWSACCESSKEYID", + "accountId": "724336686645", + "callerId": "KFDJSURSUC8FU3ITCWEDJ:user.name", + "cognitoIdentity": None, + "principalOrgId": "aws:PrincipalOrgID", + "userArn": user_arn, + "userId": "KFDJSURSUC8FU3ITCWEDJ:user.name", + } + } + event = { + "version": "2.0", + "routeKey": "ANY /{proxy+}", + "rawPath": "/", + "rawQueryString": "", + "headers": { + "accept": "*/*", + "accept-encoding": "gzip, deflate", + "authorization": "AWS4-HMAC-SHA256 Credential=AWSACCESSKEYID/20221017/eu-west-1/execute-api/aws4_request, SignedHeaders=host;x-amz-date;x-amz-security-token, Signature=foosignature", + "content-length": "17", + "content-type": "application/json", + "host": "qw8klxioji.execute-api.eu-west-1.amazonaws.com", + "user-agent": "python-requests/2.28.1", + "x-amz-content-sha256": "foobar", + "x-amz-date": "20221017T150616Z", + "x-amz-security-token": "footoken", + "x-forwarded-for": "50.191.225.98", + "x-forwarded-port": "443", + "x-forwarded-proto": "https", + }, + "requestContext": { + "accountId": "724336686645", + "apiId": "qw8klxioji", + "authorizer": authorizer, + "domainName": "qw8klxioji.execute-api.eu-west-1.amazonaws.com", + "domainPrefix": "qw8klxioji", + "http": { + "method": "POST", + "path": "/", + "protocol": "HTTP/1.1", + "sourceIp": "50.191.225.98", + "userAgent": "python-requests/2.28.1", + }, + "requestId": "aJ5ZZgeYiQ0Rz-A=", + "routeKey": "ANY /{proxy+}", + "stage": "$default", + "time": "17/Oct/2022:15:06:16 +0000", + "timeEpoch": 1666019176656, + }, + "pathParameters": {"proxy": ""}, + "body": "{'data': '12345'}", + "isBase64Encoded": False, + } + environ = create_wsgi_request(event, event_version="2.0") + self.assertEqual(environ["API_GATEWAY_AUTHORIZER"], authorizer) + self.assertEqual(environ["REMOTE_USER"], user_arn) + ## # Handler ## diff --git a/zappa/handler.py b/zappa/handler.py index 1c6fdb0fd..208011ded 100644 --- a/zappa/handler.py +++ b/zappa/handler.py @@ -523,6 +523,127 @@ def handler(self, event, context): logger.error("Cannot find a function to process the triggered event.") return result + # This is an HTTP-protocol API Gateway event or Lambda url event with payload format version 2.0 + elif "version" in event and event["version"] == "2.0": + try: + time_start = datetime.datetime.now() + + script_name = "" + host = event.get("headers", {}).get("host") + if host: + if "amazonaws.com" in host: + logger.debug("amazonaws found in host") + # The path provided in th event doesn't include the + # stage, so we must tell Flask to include the API + # stage in the url it calculates. See https://github.com/Miserlou/Zappa/issues/1014 + script_name = f"/{settings.API_STAGE}" + else: + # This is a test request sent from the AWS console + if settings.DOMAIN: + # Assume the requests received will be on the specified + # domain. No special handling is required + pass + else: + # Assume the requests received will be to the + # amazonaws.com endpoint, so tell Flask to include the + # API stage + script_name = f"/{settings.API_STAGE}" + + base_path = getattr(settings, "BASE_PATH", None) + environ = create_wsgi_request( + event, + script_name=script_name, + base_path=base_path, + trailing_slash=self.trailing_slash, + binary_support=settings.BINARY_SUPPORT, + context_header_mappings=settings.CONTEXT_HEADER_MAPPINGS, + event_version="2.0", + ) + + # We are always on https on Lambda, so tell our wsgi app that. + environ["HTTPS"] = "on" + environ["wsgi.url_scheme"] = "https" + environ["lambda.context"] = context + environ["lambda.event"] = event + + # Execute the application + with Response.from_app(self.wsgi_app, environ) as response: + response_body = None + response_is_base_64_encoded = False + if response.data: + if ( + settings.BINARY_SUPPORT + and not response.mimetype.startswith("text/") + and response.mimetype != "application/json" + ): + response_body = base64.b64encode(response.data).decode("utf-8") + response_is_base_64_encoded = True + else: + response_body = response.get_data(as_text=True) + + response_status_code = response.status_code + + cookies = [] + response_headers = {} + for key, value in response.headers: + if key.lower() == "set-cookie": + cookies.append(value) + else: + if key in response_headers: + updated_value = f"{response_headers[key]},{value}" + response_headers[key] = updated_value + else: + response_headers[key] = value + + # Calculate the total response time, + # and log it in the Common Log format. + time_end = datetime.datetime.now() + delta = time_end - time_start + response_time_ms = delta.total_seconds() * 1000 + response.content = response.data + common_log(environ, response, response_time=response_time_ms) + + return { + "cookies": cookies, + "isBase64Encoded": response_is_base_64_encoded, + "statusCode": response_status_code, + "headers": response_headers, + "body": response_body, + } + except Exception as e: + # Print statements are visible in the logs either way + print(e) + exc_info = sys.exc_info() + message = ( + "An uncaught exception happened while servicing this request. " + "You can investigate this with the `zappa tail` command." + ) + + # If we didn't even build an app_module, just raise. + if not settings.DJANGO_SETTINGS: + try: + self.app_module + except NameError as ne: + message = "Failed to import module: {}".format(ne.message) + + # Call exception handler for unhandled exceptions + exception_handler = self.settings.EXCEPTION_HANDLER + self._process_exception( + exception_handler=exception_handler, + event=event, + context=context, + exception=e, + ) + + # Return this unspecified exception as a 500, using template that API Gateway expects. + content = collections.OrderedDict() + content["statusCode"] = 500 + body = {"message": message} + if settings.DEBUG: # only include traceback if debug is on. + body["traceback"] = traceback.format_exception(*exc_info) # traceback as a list for readability. + content["body"] = json.dumps(str(body), sort_keys=True, indent=4) + return content + # Normal web app flow try: # Timing diff --git a/zappa/wsgi.py b/zappa/wsgi.py index c1889c08f..4581d8aa6 100644 --- a/zappa/wsgi.py +++ b/zappa/wsgi.py @@ -19,31 +19,70 @@ def create_wsgi_request( binary_support=False, base_path=None, context_header_mappings={}, + event_version="1.0", ): """ Given some event_info via API Gateway, create and return a valid WSGI request environ. """ - method = event_info.get("httpMethod", None) - headers = merge_headers(event_info) or {} # Allow for the AGW console 'Test' button to work (Pull #735) - - # API Gateway and ALB both started allowing for multi-value querystring - # params in Nov. 2018. If there aren't multi-value params present, then - # it acts identically to 'queryStringParameters', so we can use it as a - # drop-in replacement. - # - # The one caveat here is that ALB will only include _one_ of - # queryStringParameters _or_ multiValueQueryStringParameters, which means - # we have to check for the existence of one and then fall back to the - # other. - - if "multiValueQueryStringParameters" in event_info: - query = event_info["multiValueQueryStringParameters"] - query_string = urlencode(query, doseq=True) if query else "" - else: + if event_version == "2.0": + # See the new format documentation + # here: https://docs.aws.amazon.com/lambda/latest/dg/urls-invocation.html#urls-payloads + method = event_info["requestContext"]["http"]["method"] + headers = event_info["headers"] + if event_info.get("cookies"): + headers["cookie"] = "; ".join(event_info["cookies"]) + + path = urls.url_unquote(event_info["requestContext"]["http"]["path"]) + query = event_info.get("queryStringParameters", {}) query_string = urlencode(query) if query else "" - query_string = urls.url_unquote(query_string) + query_string = urls.url_unquote(query_string) + + # Systems calling the Lambda (other than API Gateway) may not provide the field requestContext + # Extract remote_user, authorizer if Authorizer is enabled + remote_user = None + authorizer = event_info["requestContext"].get("authorizer", None) + if authorizer: + if authorizer.get("lambda"): + # Need to add principalId manually to lambda authorizer response context + remote_user = authorizer["lambda"].get("principalId") + elif authorizer.get("iam"): + remote_user = authorizer["iam"].get("userArn") + else: + method = event_info.get("httpMethod", None) + headers = merge_headers(event_info) or {} # Allow for the AGW console 'Test' button to work (Pull #735) + + path = urls.url_unquote(event_info["path"]) + + # API Gateway and ALB both started allowing for multi-value querystring + # params in Nov. 2018. If there aren't multi-value params present, then + # it acts identically to 'queryStringParameters', so we can use it as a + # drop-in replacement. + # + # The one caveat here is that ALB will only include _one_ of + # queryStringParameters _or_ multiValueQueryStringParameters, which means + # we have to check for the existence of one and then fall back to the + # other. + + if "multiValueQueryStringParameters" in event_info: + query = event_info["multiValueQueryStringParameters"] + query_string = urlencode(query, doseq=True) if query else "" + else: + query = event_info.get("queryStringParameters", {}) + query_string = urlencode(query) if query else "" + query_string = urls.url_unquote(query_string) + + # Systems calling the Lambda (other than API Gateway) may not provide the field requestContext + # Extract remote_user, authorizer if Authorizer is enabled + remote_user = None + authorizer = None + if "requestContext" in event_info: + authorizer = event_info["requestContext"].get("authorizer", None) + if authorizer: + remote_user = authorizer.get("principalId") + elif event_info["requestContext"].get("identity"): + remote_user = event_info["requestContext"]["identity"].get("userArn") if context_header_mappings: for key, value in context_header_mappings.items(): @@ -68,12 +107,12 @@ def create_wsgi_request( encoded_body = event_info["body"] body = base64.b64decode(encoded_body) else: - body = event_info["body"] + body = event_info.get("body") if isinstance(body, str): body = body.encode("utf-8") else: - body = event_info["body"] + body = event_info.get("body") if isinstance(body, str): body = body.encode("utf-8") @@ -81,7 +120,6 @@ def create_wsgi_request( # https://github.com/Miserlou/Zappa/issues/1188 headers = titlecase_keys(headers) - path = urls.url_unquote(event_info["path"]) if base_path: script_name = "/" + base_path @@ -115,16 +153,8 @@ def create_wsgi_request( "wsgi.run_once": False, } - # Systems calling the Lambda (other than API Gateway) may not provide the field requestContext - # Extract remote_user, authorizer if Authorizer is enabled - remote_user = None - if "requestContext" in event_info: - authorizer = event_info["requestContext"].get("authorizer", None) - if authorizer: - remote_user = authorizer.get("principalId") - environ["API_GATEWAY_AUTHORIZER"] = authorizer - elif event_info["requestContext"].get("identity"): - remote_user = event_info["requestContext"]["identity"].get("userArn") + if authorizer: + environ["API_GATEWAY_AUTHORIZER"] = authorizer # Input processing if method in ["POST", "PUT", "PATCH", "DELETE"]: