Skip to content

Commit bad24dd

Browse files
authored
(#908) Update BINARY_SUPPORT to use Content-Encoding to identify if data is binary (#1155)
* 🔧 migrate #971 to lastest master * 🎨 run black/isort * ♻️ refactor to allow for other binary ignore types based on mimetype. (currently openapi schema can't be passed as text. * 🎨 run black/fix flake8 * 🔧 add EXCEPTION_HANDLER setting * 🐛 fix zappa_returndict["body"] assignment * 📝 add temp debug info * 🔥 delete unnecessary print statements * ♻️ Update comments and minor refactor for clarity * ♻️ refactor for ease of testing and clarity * 🎨 fix flake8 * ✨ add `additional_text_mimetypes` setting ✅ add testcases for additional_text_mimetypes handling * 🔧 Expand default text mimetypes mentioned in #1023 ♻️ define "DEFAULT_TEXT_MIMETYPES" and move to utilities.py * 🎨 run black/isort * 🎨 run black/isort * 🎨 remove unnecesasry comment (black now reformats code) 🎨 change commented lines to docstring for test app
1 parent 34e3065 commit bad24dd

12 files changed

Lines changed: 397 additions & 18 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,7 @@ to change Zappa's behavior. Use these at your own risk!
854854
```javascript
855855
{
856856
"dev": {
857+
"additional_text_mimetypes": [], // allows you to provide additional mimetypes to be handled as text when binary_support is true.
857858
"alb_enabled": false, // enable provisioning of application load balancing resources. If set to true, you _must_ fill out the alb_vpc_config option as well.
858859
"alb_vpc_config": {
859860
"CertificateArn": "your_acm_certificate_arn", // ACM certificate ARN for ALB

test_settings.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,5 +120,12 @@
120120
"lambda_concurrency_enabled": {
121121
"extends": "ttt888",
122122
"lambda_concurrency": 6
123-
}
123+
},
124+
"addtextmimetypes": {
125+
"s3_bucket": "lmbda",
126+
"app_function": "tests.test_app.hello_world",
127+
"delete_local_zip": true,
128+
"binary_support": true,
129+
"additional_text_mimetypes": ["application/custommimetype"]
130+
}
124131
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"nobinarysupport": {
3+
"s3_bucket": "lmbda",
4+
"app_function": "tests.test_app.hello_world",
5+
"delete_local_zip": true,
6+
"binary_support": false,
7+
"additional_text_mimetypes": ["application/custommimetype"]
8+
}
9+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
API_STAGE = "dev"
2+
APP_FUNCTION = "app"
3+
APP_MODULE = "tests.test_wsgi_binary_support_app"
4+
BINARY_SUPPORT = True
5+
CONTEXT_HEADER_MAPPINGS = {}
6+
DEBUG = "True"
7+
DJANGO_SETTINGS = None
8+
DOMAIN = "api.example.com"
9+
ENVIRONMENT_VARIABLES = {}
10+
LOG_LEVEL = "DEBUG"
11+
PROJECT_NAME = "binary_support_settings"
12+
COGNITO_TRIGGER_MAPPING = {}
13+
EXCEPTION_HANDLER = None
14+
ADDITIONAL_TEXT_MIMETYPES = ["application/vnd.oai.openapi"]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
API_STAGE = "dev"
2+
APP_FUNCTION = "app"
3+
APP_MODULE = "tests.test_wsgi_binary_support_app"
4+
BINARY_SUPPORT = True
5+
CONTEXT_HEADER_MAPPINGS = {}
6+
DEBUG = "True"
7+
DJANGO_SETTINGS = None
8+
DOMAIN = "api.example.com"
9+
ENVIRONMENT_VARIABLES = {}
10+
LOG_LEVEL = "DEBUG"
11+
PROJECT_NAME = "binary_support_settings"
12+
COGNITO_TRIGGER_MAPPING = {}
13+
EXCEPTION_HANDLER = None

tests/test_handler.py

Lines changed: 184 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import sys
21
import unittest
32

43
from mock import Mock
54

65
from zappa.handler import LambdaHandler
76
from zappa.utilities import merge_headers
87

8+
from .utils import is_base64
9+
910

1011
def no_args():
1112
return
@@ -223,6 +224,188 @@ def test_exception_handler_on_web_request(self):
223224
self.assertEqual(response["statusCode"], 500)
224225
mocked_exception_handler.assert_called()
225226

227+
def test_wsgi_script_binary_support_with_content_encoding(self):
228+
"""
229+
Ensure that response body is base64 encoded when BINARY_SUPPORT is enabled and Content-Encoding header is present.
230+
"""
231+
lh = LambdaHandler("tests.test_binary_support_settings")
232+
233+
text_plain_event = {
234+
"body": "",
235+
"resource": "/{proxy+}",
236+
"requestContext": {},
237+
"queryStringParameters": {},
238+
"headers": {
239+
"Host": "1234567890.execute-api.us-east-1.amazonaws.com",
240+
},
241+
"pathParameters": {"proxy": "return/request/url"},
242+
"httpMethod": "GET",
243+
"stageVariables": {},
244+
"path": "/content_encoding_header_json1",
245+
}
246+
247+
# A likely scenario is that the application would be gzip compressing some json response. That's checked first.
248+
response = lh.handler(text_plain_event, None)
249+
250+
self.assertEqual(response["statusCode"], 200)
251+
self.assertIn("isBase64Encoded", response)
252+
self.assertTrue(is_base64(response["body"]))
253+
254+
# We also verify that some unknown mimetype with a Content-Encoding also encodes to b64. This route serves
255+
# bytes in the response.
256+
257+
text_arbitrary_event = {
258+
**text_plain_event,
259+
**{"path": "/content_encoding_header_textarbitrary1"},
260+
}
261+
262+
response = lh.handler(text_arbitrary_event, None)
263+
264+
self.assertEqual(response["statusCode"], 200)
265+
self.assertIn("isBase64Encoded", response)
266+
self.assertTrue(is_base64(response["body"]))
267+
268+
# This route is similar to the above, but it serves its response as text and not bytes. That the response
269+
# isn't bytes shouldn't matter because it still has a Content-Encoding header.
270+
271+
application_json_event = {
272+
**text_plain_event,
273+
**{"path": "/content_encoding_header_textarbitrary2"},
274+
}
275+
276+
response = lh.handler(application_json_event, None)
277+
278+
self.assertEqual(response["statusCode"], 200)
279+
self.assertIn("isBase64Encoded", response)
280+
self.assertTrue(is_base64(response["body"]))
281+
282+
def test_wsgi_script_binary_support_without_content_encoding_edgecases(
283+
self,
284+
):
285+
"""
286+
Ensure zappa response bodies are NOT base64 encoded when BINARY_SUPPORT is enabled and the mimetype is "application/json" or starts with "text/".
287+
"""
288+
289+
lh = LambdaHandler("tests.test_binary_support_settings")
290+
291+
text_plain_event = {
292+
"body": "",
293+
"resource": "/{proxy+}",
294+
"requestContext": {},
295+
"queryStringParameters": {},
296+
"headers": {
297+
"Host": "1234567890.execute-api.us-east-1.amazonaws.com",
298+
},
299+
"pathParameters": {"proxy": "return/request/url"},
300+
"httpMethod": "GET",
301+
"stageVariables": {},
302+
"path": "/textplain_mimetype_response1",
303+
}
304+
305+
for path in [
306+
"/textplain_mimetype_response1", # text/plain mimetype should not be turned to base64
307+
"/textarbitrary_mimetype_response1", # text/arbitrary mimetype should not be turned to base64
308+
"/json_mimetype_response1", # application/json mimetype should not be turned to base64
309+
]:
310+
event = {**text_plain_event, "path": path}
311+
response = lh.handler(event, None)
312+
313+
self.assertEqual(response["statusCode"], 200)
314+
self.assertNotIn("isBase64Encoded", response)
315+
self.assertFalse(is_base64(response["body"]))
316+
317+
def test_wsgi_script_binary_support_without_content_encoding(
318+
self,
319+
):
320+
"""
321+
Ensure zappa response bodies are base64 encoded when BINARY_SUPPORT is enabled and Content-Encoding is absent.
322+
"""
323+
324+
lh = LambdaHandler("tests.test_binary_support_settings")
325+
326+
text_plain_event = {
327+
"body": "",
328+
"resource": "/{proxy+}",
329+
"requestContext": {},
330+
"queryStringParameters": {},
331+
"headers": {
332+
"Host": "1234567890.execute-api.us-east-1.amazonaws.com",
333+
},
334+
"pathParameters": {"proxy": "return/request/url"},
335+
"httpMethod": "GET",
336+
"stageVariables": {},
337+
"path": "/textplain_mimetype_response1",
338+
}
339+
340+
for path in [
341+
"/arbitrarybinary_mimetype_response1",
342+
"/arbitrarybinary_mimetype_response2",
343+
]:
344+
event = {**text_plain_event, "path": path}
345+
response = lh.handler(event, None)
346+
347+
self.assertEqual(response["statusCode"], 200)
348+
self.assertIn("isBase64Encoded", response)
349+
self.assertTrue(is_base64(response["body"]))
350+
351+
def test_wsgi_script_binary_support_userdefined_additional_text_mimetypes__defined(
352+
self,
353+
):
354+
"""
355+
Ensure zappa response bodies are NOT base64 encoded when BINARY_SUPPORT is True, and additional_text_mimetypes are defined
356+
"""
357+
lh = LambdaHandler("tests.test_binary_support_additional_text_mimetypes_settings")
358+
expected_additional_mimetypes = ["application/vnd.oai.openapi"]
359+
self.assertEqual(lh.settings.ADDITIONAL_TEXT_MIMETYPES, expected_additional_mimetypes)
360+
361+
event = {
362+
"body": "",
363+
"resource": "/{proxy+}",
364+
"requestContext": {},
365+
"queryStringParameters": {},
366+
"headers": {
367+
"Host": "1234567890.execute-api.us-east-1.amazonaws.com",
368+
},
369+
"pathParameters": {"proxy": "return/request/url"},
370+
"httpMethod": "GET",
371+
"stageVariables": {},
372+
"path": "/userdefined_additional_mimetype_response1",
373+
}
374+
375+
response = lh.handler(event, None)
376+
377+
self.assertEqual(response["statusCode"], 200)
378+
self.assertNotIn("isBase64Encoded", response)
379+
self.assertFalse(is_base64(response["body"]))
380+
381+
def test_wsgi_script_binary_support_userdefined_additional_text_mimetypes__undefined(
382+
self,
383+
):
384+
"""
385+
Ensure zappa response bodies are base64 encoded when BINARY_SUPPORT is True and mimetype not defined in additional_text_mimetypes
386+
"""
387+
lh = LambdaHandler("tests.test_binary_support_settings")
388+
389+
event = {
390+
"body": "",
391+
"resource": "/{proxy+}",
392+
"requestContext": {},
393+
"queryStringParameters": {},
394+
"headers": {
395+
"Host": "1234567890.execute-api.us-east-1.amazonaws.com",
396+
},
397+
"pathParameters": {"proxy": "return/request/url"},
398+
"httpMethod": "GET",
399+
"stageVariables": {},
400+
"path": "/userdefined_additional_mimetype_response1",
401+
}
402+
403+
response = lh.handler(event, None)
404+
405+
self.assertEqual(response["statusCode"], 200)
406+
self.assertIn("isBase64Encoded", response)
407+
self.assertTrue(is_base64(response["body"]))
408+
226409
def test_wsgi_script_on_cognito_event_request(self):
227410
"""
228411
Ensure that requests sent by cognito behave sensibly
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
This test application exists to confirm how Zappa handles WSGI application
3+
_responses_ when Binary Support is enabled.
4+
"""
5+
6+
import gzip
7+
import json
8+
9+
from flask import Flask, Response
10+
11+
app = Flask(__name__)
12+
13+
14+
@app.route("/textplain_mimetype_response1", methods=["GET"])
15+
def text_mimetype_response_1():
16+
return Response(response="OK", mimetype="text/plain")
17+
18+
19+
@app.route("/textarbitrary_mimetype_response1", methods=["GET"])
20+
def text_mimetype_response_2():
21+
return Response(response="OK", mimetype="text/arbitary")
22+
23+
24+
@app.route("/json_mimetype_response1", methods=["GET"])
25+
def json_mimetype_response_1():
26+
return Response(response=json.dumps({"some": "data"}), mimetype="application/json")
27+
28+
29+
@app.route("/arbitrarybinary_mimetype_response1", methods=["GET"])
30+
def arbitrary_mimetype_response_1():
31+
return Response(response=b"some binary data", mimetype="arbitrary/binary_mimetype")
32+
33+
34+
@app.route("/arbitrarybinary_mimetype_response2", methods=["GET"])
35+
def arbitrary_mimetype_response_3():
36+
return Response(response="doesnt_matter", mimetype="definitely_not_text")
37+
38+
39+
@app.route("/content_encoding_header_json1", methods=["GET"])
40+
def response_with_content_encoding_1():
41+
return Response(
42+
response=gzip.compress(json.dumps({"some": "data"}).encode()),
43+
mimetype="application/json",
44+
headers={"Content-Encoding": "gzip"},
45+
)
46+
47+
48+
@app.route("/content_encoding_header_textarbitrary1", methods=["GET"])
49+
def response_with_content_encoding_2():
50+
return Response(
51+
response=b"OK",
52+
mimetype="text/arbitrary",
53+
headers={"Content-Encoding": "something_arbitrarily_binary"},
54+
)
55+
56+
57+
@app.route("/content_encoding_header_textarbitrary2", methods=["GET"])
58+
def response_with_content_encoding_3():
59+
return Response(
60+
response="OK",
61+
mimetype="text/arbitrary",
62+
headers={"Content-Encoding": "with_content_type_but_not_bytes_response"},
63+
)
64+
65+
66+
@app.route("/userdefined_additional_mimetype_response1", methods=["GET"])
67+
def response_with_userdefined_addtional_mimetype():
68+
return Response(
69+
response="OK",
70+
mimetype="application/vnd.oai.openapi",
71+
)

tests/tests.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,6 +1165,20 @@ def test_load_settings_toml(self):
11651165
zappa_cli.load_settings("tests/test_settings.toml")
11661166
self.assertEqual(False, zappa_cli.stage_config["touch"])
11671167

1168+
def test_load_settings_bad_additional_text_mimetypes(self):
1169+
zappa_cli = ZappaCLI()
1170+
zappa_cli.api_stage = "nobinarysupport"
1171+
with self.assertRaises(ClickException):
1172+
zappa_cli.load_settings("tests/test_bad_additional_text_mimetypes_settings.json")
1173+
1174+
def test_load_settings_additional_text_mimetypes(self):
1175+
zappa_cli = ZappaCLI()
1176+
zappa_cli.api_stage = "addtextmimetypes"
1177+
zappa_cli.load_settings("test_settings.json")
1178+
expected_additional_text_mimetypes = ["application/custommimetype"]
1179+
self.assertEqual(expected_additional_text_mimetypes, zappa_cli.stage_config["additional_text_mimetypes"])
1180+
self.assertEqual(True, zappa_cli.stage_config["binary_support"])
1181+
11681182
def test_settings_extension(self):
11691183
"""
11701184
Make sure Zappa uses settings in the proper order: JSON, TOML, YAML.

tests/utils.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
1+
import base64
12
import functools
23
import os
34
from collections import namedtuple
45
from contextlib import contextmanager
6+
from io import IOBase as file
57

68
import boto3
79
import placebo
810
from mock import MagicMock, patch
911

10-
try:
11-
file
12-
except NameError: # builtin 'file' was removed in Python 3
13-
from io import IOBase as file
14-
1512
PLACEBO_DIR = os.path.join(os.path.dirname(__file__), "placebo")
1613

1714

@@ -75,6 +72,14 @@ def stub_open(*args, **kwargs):
7572
yield mock_open, mock_file
7673

7774

75+
def is_base64(test_string: str) -> bool:
76+
# Taken from https://stackoverflow.com/a/45928164/3200002
77+
try:
78+
return base64.b64encode(base64.b64decode(test_string)).decode() == test_string
79+
except Exception:
80+
return False
81+
82+
7883
def get_unsupported_sys_versioninfo() -> tuple:
7984
"""Mock used to test the python unsupported version testcase"""
8085
invalid_versioninfo = namedtuple("version_info", ["major", "minor", "micro", "releaselevel", "serial"])

0 commit comments

Comments
 (0)