diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b08a75993..d50747a8b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,15 @@ CHANGELOG ========= +Next Release (TBD) +================== + +* Fix bug with case insensitive headers + (`#129 `__) +* Add initial support for CORS + (`#133 `__) + + 0.2.0 ===== diff --git a/chalice/app.py b/chalice/app.py index 889445d16..299183069 100644 --- a/chalice/app.py +++ b/chalice/app.py @@ -1,6 +1,7 @@ """Chalice app and routing code.""" import re import base64 +from collections import Mapping # Implementation note: This file is intended to be a standalone file # that gets copied into the lambda deployment package. It has no dependencies @@ -57,21 +58,38 @@ class TooManyRequestsError(ChaliceViewError): TooManyRequestsError] +class CaseInsensitiveMapping(Mapping): + """Case insensitive and read-only mapping.""" + + def __init__(self, mapping): + self._dict = {k.lower(): v for k, v in mapping.items()} + + def __getitem__(self, key): + return self._dict[key.lower()] + + def __iter__(self): + return iter(self._dict) + + def __len__(self): + return len(self._dict) + + def __repr__(self): + return 'CaseInsensitiveMapping(%s)' % repr(self._dict) + + class Request(object): """The current request from API gateway.""" def __init__(self, query_params, headers, uri_params, method, body, base64_body, context, stage_vars): self.query_params = query_params - self.headers = headers + self.headers = CaseInsensitiveMapping(headers) self.uri_params = uri_params self.method = method #: The parsed JSON from the body. This value should #: only be set if the Content-Type header is application/json, #: which is the default content type value in chalice. - if self.headers.get('Content-Type') == 'application/json': - # We'll need to address case insensitive header lookups - # eventually. + if self.headers.get('content-type') == 'application/json': self.json_body = body else: self.json_body = None @@ -91,7 +109,11 @@ def raw_body(self): return self._raw_body def to_dict(self): - return self.__dict__.copy() + copied = self.__dict__.copy() + # We want the output of `to_dict()` to be + # JSON serializable, so we need to remove the CaseInsensitive dict. + copied['headers'] = dict(copied['headers']) + return copied class RouteEntry(object): diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 7a2b71354..79d31a104 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -124,7 +124,7 @@ def test_can_raise_bad_request(smoke_test_app): response = requests.get(smoke_test_app.url + '/badrequest') assert response.status_code == 400 assert response.json()['Code'] == 'BadRequestError' - assert response.json()['Message'] == 'Bad request.' + assert response.json()['Message'] == 'BadRequestError: Bad request.' def test_can_raise_not_found(smoke_test_app): @@ -169,4 +169,8 @@ def test_can_support_cors(smoke_test_app): assert headers['Access-Control-Allow-Origin'] == '*' assert headers['Access-Control-Allow-Headers'] == ( 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token') - assert headers['Access-Control-Allow-Methods'] == 'POST,GET,PUT,OPTIONS' + assert headers['Access-Control-Allow-Methods'] == 'GET,POST,PUT,OPTIONS' + + +def test_to_dict_is_also_json_serializable(smoke_test_app): + assert 'headers' in smoke_test_app.get_json('/todict') diff --git a/tests/integration/testapp/app.py b/tests/integration/testapp/app.py index 4ce3617bb..3f72ec44c 100644 --- a/tests/integration/testapp/app.py +++ b/tests/integration/testapp/app.py @@ -73,3 +73,8 @@ def supports_cors(): # It doesn't really matter what we return here because # we'll be checking the response headers to verify CORS support. return {'cors': True} + + +@app.route('/todict', methods=['GET']) +def todict(): + return app.current_request.to_dict() diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index 9d955751c..e26feb156 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -111,6 +111,10 @@ def todict(): # out a few keys as a basic sanity test. assert response['method'] == 'GET' assert response['json_body'] == {} + # We also want to verify that to_dict() is always + # JSON serializable so we check we can roundtrip + # the data to/from JSON. + assert isinstance(json.loads(json.dumps(response)), dict) def test_will_pass_captured_params_to_view(sample_app): @@ -233,6 +237,22 @@ def index(): assert raw_body == '{"foo": "bar"}' +def test_json_body_available_with_lowercase_content_type_key(): + demo = app.Chalice('demo-app') + + @demo.route('/', methods=['POST']) + def index(): + return (demo.current_request.json_body, demo.current_request.raw_body) + + event = create_event_with_body({'foo': 'bar'}) + del event['params']['header']['Content-Type'] + event['params']['header']['content-type'] = 'application/json' + + json_body, raw_body = demo(event, context=None) + assert json_body == {'foo': 'bar'} + assert raw_body == '{"foo": "bar"}' + + def test_content_types_must_be_lists(): demo = app.Chalice('app-name') @@ -333,3 +353,12 @@ def notfound(): event = create_event('/notfound', 'GET', {}) with pytest.raises(NotFoundError): sample_app(event, context=None) + + +def test_case_insensitive_mapping(): + mapping = app.CaseInsensitiveMapping({'HEADER': 'Value'}) + + assert mapping['hEAdEr'] + assert mapping.get('hEAdEr') + assert 'hEAdEr' in mapping + assert repr({'header': 'Value'}) in repr(mapping)