Repository: adamchainz/apig-wsgi

Issue:
Multivalue headers and parameters
APIG has at some point added support for multi-value request headers and parameters: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#apigateway-multivalue-headers-and-parameters

We should use those - (where available - check they're on ALBv2 as well).

Discussion:

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index bb2a4f1..293f90e 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -1,6 +1,14 @@
 History
 =======
 
+Pending
+-------
+
+* Support multi-value headers and query parameters as API Gateway added
+  (`Issue #103 <https://github.com/adamchainz/apig-wsgi/issues/103>`__).
+* Pass full event as a WSGI environ variable
+  (`PR #111 <https://github.com/adamchainz/apig-wsgi/issues/111>`__).
+
 2.4.1 (2020-01-13)
 ------------------
 
diff --git a/README.rst b/README.rst
index b481694..12a30bf 100644
--- a/README.rst
+++ b/README.rst
@@ -62,6 +62,13 @@ If the event from API Gateway contains the ``requestContext`` key, for example
 from custom request authorizers, this will be available in the WSGI environ
 at the key ``apig_wsgi.request_context``.
 
+If you want to inspect the full event from API Gateway, it's available in the
+WSGI environ at the key ``apig_wsgi.full_event``.
+
+Multiple values for headers and qurey parameters are supported. They are
+enabled automatically on API Gateway but need
+`explict activation on ALB's <https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html#multi-value-headers>`__.
+
 Example
 =======
 
diff --git a/example/deployment/playbook.yml b/example/deployment/playbook.yml
index 6c7020b..6834fd0 100644
--- a/example/deployment/playbook.yml
+++ b/example/deployment/playbook.yml
@@ -39,9 +39,9 @@
       chdir: '{{ abs_build_path.stdout }}'
 
   - name: install development version of apig-wsgi
-    command: pip3 install {{ abs_build_path.stdout|dirname|dirname|dirname }}
-    args:
-      chdir: '{{ abs_build_path.stdout }}'
+    copy:
+      src: '{{ abs_build_path.stdout|dirname|dirname|dirname }}/src/apig_wsgi.py'
+      dest: '{{ abs_build_path.stdout }}/apig_wsgi.py'
 
   - name: remove .pyc files from build directory
     command: find . -type f -name "*.py[co]" -delete
diff --git a/pyproject.toml b/pyproject.toml
index 0e459d1..196e863 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,2 +1,2 @@
 [tool.black]
-target-version = ['py36']
+target-version = ['py35']
diff --git a/src/apig_wsgi.py b/src/apig_wsgi.py
index e5c17ae..efb01d2 100644
--- a/src/apig_wsgi.py
+++ b/src/apig_wsgi.py
@@ -53,13 +53,11 @@ def get_environ(event, binary_support):
         body = b64decode(body)
     else:
         body = body.encode("utf-8")
-    params = event.get("queryStringParameters") or {}
-    params = "&".join("{}={}".format(key, val) for (key, val) in params.items())
+
     environ = {
         "CONTENT_LENGTH": str(len(body)),
         "HTTP": "on",
         "PATH_INFO": event["path"],
-        "QUERY_STRING": params,
         "REMOTE_ADDR": "127.0.0.1",
         "REQUEST_METHOD": method,
         "SCRIPT_NAME": "",
@@ -72,27 +70,51 @@ def get_environ(event, binary_support):
         "wsgi.version": (1, 0),
     }
 
-    headers = event.get("headers") or {}  # may be None when testing on console
-    for key, value in headers.items():
+    # Multi-value query strings need explicit activation on ALB
+    if "multiValueQueryStringParameters" in event:
+        # may be None when testing on console
+        multi_params = event["multiValueQueryStringParameters"] or {}
+    else:
+        single_params = event.get("queryStringParameters") or {}
+        multi_params = {key: [value] for key, value in single_params.items()}
+    environ["QUERY_STRING"] = "&".join(
+        "{}={}".format(key, val) for (key, vals) in multi_params.items() for val in vals
+    )
+
+    # Multi-value headers need explicit activation on ALB
+    if "multiValueHeaders" in event:
+        # may be None when testing on console
+        headers = event["multiValueHeaders"] or {}
+    else:
+        # may be None when testing on console
+        single_headers = event.get("headers") or {}
+        headers = {key: [value] for key, value in single_headers.items()}
+    for key, values in headers.items():
         key = key.upper().replace("-", "_")
 
         if key == "CONTENT_TYPE":
-            environ["CONTENT_TYPE"] = value
+            environ["CONTENT_TYPE"] = values[-1]
+            continue
         elif key == "HOST":
-            environ["SERVER_NAME"] = value
+            environ["SERVER_NAME"] = values[-1]
+            continue
         elif key == "X_FORWARDED_FOR":
-            environ["REMOTE_ADDR"] = value.split(", ")[0]
+            environ["REMOTE_ADDR"] = values[-1].split(", ")[0]
         elif key == "X_FORWARDED_PROTO":
-            environ["wsgi.url_scheme"] = value
+            environ["wsgi.url_scheme"] = values[-1]
         elif key == "X_FORWARDED_PORT":
-            environ["SERVER_PORT"] = value
+            environ["SERVER_PORT"] = values[-1]
 
-        environ["HTTP_" + key] = value
+        # Multi-value headers accumulate with ","
+        environ["HTTP_" + key] = ",".join(values)
 
     # Pass the AWS requestContext to the application
     if "requestContext" in event:
         environ["apig_wsgi.request_context"] = event["requestContext"]
 
+    # Pass the full event to the application as an escape hatch
+    environ["apig_wsgi.full_event"] = event
+
     return environ
 
 


Test patch:
diff --git a/example/app/testapp/views.py b/example/app/testapp/views.py
index d9915e8..f0971e1 100644
--- a/example/app/testapp/views.py
+++ b/example/app/testapp/views.py
@@ -1,14 +1,24 @@
-import html
+from html import escape
 from pprint import pformat
 
 from django.http import HttpResponse
 
 
 def index(request):
+    headers = pformat(dict(request.headers))
+    params = pformat(dict(request.GET))
+    request_context = pformat(request.environ.get("apig_wsgi.request_context", None))
+    full_event = pformat(request.environ.get("apig_wsgi.full_event", None))
     return HttpResponse(
         f"""
         <h1>Hello World!</h1>
+        <h2>Headers</h2>
+        <pre>{escape(headers)}</pre>
         <h2>Query Params</h2>
-        <pre>{html.escape(pformat(request.GET))}</pre>
+        <pre>{escape(params)}</pre>
+        <h2>Request Context</h2>
+        <pre>{escape(request_context)}</pre>
+        <h2>Full event</h2>
+        <pre>{escape(full_event)}</pre>
         """
     )
diff --git a/tests/test_apig_wsgi.py b/tests/test_apig_wsgi.py
index dd596ab..736d859 100644
--- a/tests/test_apig_wsgi.py
+++ b/tests/test_apig_wsgi.py
@@ -37,21 +37,37 @@ parametrize_custom_text_content_type = pytest.mark.parametrize(
 def make_event(
     method="GET",
     qs_params=None,
+    qs_params_multi=True,
     headers=None,
+    headers_multi=True,
     body="",
     binary=False,
     request_context=None,
 ):
     if headers is None:
-        headers = {"Host": "example.com"}
+        headers = {"Host": ["example.com"]}
 
     event = {
         "httpMethod": method,
         "path": "/",
-        "queryStringParameters": qs_params,
-        "headers": headers,
+        "multiValueHeaders": headers,
     }
 
+    if qs_params_multi:
+        event["multiValueQueryStringParameters"] = qs_params
+    else:
+        if qs_params is None:
+            event["queryStringParameters"] = None
+        else:
+            event["queryStringParameters"] = {
+                key: values[-1] for key, values in qs_params.items()
+            }
+
+    if headers_multi:
+        event["multiValueHeaders"] = headers
+    else:
+        event["headers"] = {key: values[-1] for key, values in headers.items()}
+
     if binary:
         event["body"] = b64encode(body.encode("utf-8"))
         event["isBase64Encoded"] = True
@@ -225,6 +241,14 @@ def test_querystring_none(simple_app):
     assert simple_app.environ["QUERY_STRING"] == ""
 
 
+def test_querystring_none_single(simple_app):
+    event = make_event(qs_params_multi=False)
+
+    simple_app.handler(event, None)
+
+    assert simple_app.environ["QUERY_STRING"] == ""
+
+
 def test_querystring_empty(simple_app):
     event = make_event(qs_params={})
 
@@ -233,8 +257,24 @@ def test_querystring_empty(simple_app):
     assert simple_app.environ["QUERY_STRING"] == ""
 
 
+def test_querystring_empty_single(simple_app):
+    event = make_event(qs_params={}, qs_params_multi=False)
+
+    simple_app.handler(event, None)
+
+    assert simple_app.environ["QUERY_STRING"] == ""
+
+
 def test_querystring_one(simple_app):
-    event = make_event(qs_params={"foo": "bar"})
+    event = make_event(qs_params={"foo": ["bar"]})
+
+    simple_app.handler(event, None)
+
+    assert simple_app.environ["QUERY_STRING"] == "foo=bar"
+
+
+def test_querystring_one_single(simple_app):
+    event = make_event(qs_params={"foo": ["bar"]}, qs_params_multi=False)
 
     simple_app.handler(event, None)
 
@@ -242,7 +282,7 @@ def test_querystring_one(simple_app):
 
 
 def test_querystring_encoding_value(simple_app):
-    event = make_event(qs_params={"foo": "a%20bar"})
+    event = make_event(qs_params={"foo": ["a%20bar"]})
 
     simple_app.handler(event, None)
 
@@ -250,29 +290,53 @@ def test_querystring_encoding_value(simple_app):
 
 
 def test_querystring_encoding_key(simple_app):
-    event = make_event(qs_params={"a%20foo": "bar"})
+    event = make_event(qs_params={"a%20foo": ["bar"]})
 
     simple_app.handler(event, None)
 
     assert simple_app.environ["QUERY_STRING"] == "a%20foo=bar"
 
 
+def test_querystring_multi(simple_app):
+    event = make_event(qs_params={"foo": ["bar", "baz"]})
+
+    simple_app.handler(event, None)
+
+    assert simple_app.environ["QUERY_STRING"] == "foo=bar&foo=baz"
+
+
 def test_plain_header(simple_app):
-    event = make_event(headers={"Test-Header": "foobar"})
+    event = make_event(headers={"Test-Header": ["foobar"]})
 
     simple_app.handler(event, None)
 
     assert simple_app.environ["HTTP_TEST_HEADER"] == "foobar"
 
 
+def test_plain_header_single(simple_app):
+    event = make_event(headers={"Test-Header": ["foobar"]}, headers_multi=False)
+
+    simple_app.handler(event, None)
+
+    assert simple_app.environ["HTTP_TEST_HEADER"] == "foobar"
+
+
+def test_plain_header_multi(simple_app):
+    event = make_event(headers={"Test-Header": ["foo", "bar"]})
+
+    simple_app.handler(event, None)
+
+    assert simple_app.environ["HTTP_TEST_HEADER"] == "foo,bar"
+
+
 def test_special_headers(simple_app):
     event = make_event(
         headers={
-            "Content-Type": "text/plain",
-            "Host": "example.com",
-            "X-Forwarded-For": "1.2.3.4, 5.6.7.8",
-            "X-Forwarded-Proto": "https",
-            "X-Forwarded-Port": "123",
+            "Content-Type": ["text/plain"],
+            "Host": ["example.com"],
+            "X-Forwarded-For": ["1.2.3.4, 5.6.7.8"],
+            "X-Forwarded-Proto": ["https"],
+            "X-Forwarded-Port": ["123"],
         }
     )
 
@@ -288,7 +352,7 @@ def test_special_headers(simple_app):
 def test_no_headers(simple_app):
     # allow headers to be missing from event
     event = make_event()
-    del event["headers"]
+    del event["multiValueHeaders"]
 
     simple_app.handler(event, None)
 
@@ -296,7 +360,7 @@ def test_no_headers(simple_app):
 def test_headers_None(simple_app):
     # allow headers to be 'None' from APIG test console
     event = make_event()
-    event["headers"] = None
+    event["multiValueHeaders"] = None
 
     simple_app.handler(event, None)
 
@@ -320,3 +384,11 @@ def test_request_context(simple_app):
     simple_app.handler(event, None)
 
     assert simple_app.environ["apig_wsgi.request_context"] == context
+
+
+def test_full_event(simple_app):
+    event = make_event()
+
+    simple_app.handler(event, None)
+
+    assert simple_app.environ["apig_wsgi.full_event"] == event