diff --git a/.travis.yml b/.travis.yml index ec993f0..d17efa5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,12 @@ language: python python: - 2.7 - - 3.2 - - 3.3 - 3.4 - pypy install: - pip install codecov - - pip install -r test-requirements.txt + - pip install -r requires/installation.txt + - pip install -r requires/testing.txt script: nosetests after_success: - codecov diff --git a/MANIFEST.in b/MANIFEST.in index 949d311..e697e00 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include LICENSE -include README.rst -include test-requirements.txt +include tests.py +graft docs +graft requires diff --git a/README.rst b/README.rst index 54a43ae..6117dfb 100644 --- a/README.rst +++ b/README.rst @@ -1,93 +1,66 @@ sprockets.mixins.media_type =========================== -A mixin that performs Content-Type negotiation and request/response (de)serialization. +A mixin that performs Content-Type negotiation and request/response +(de)serialization. -|Version| |Downloads| |Status| |Coverage| |License| +|Documentation| |Build Badge| |Package Info| -Installation ------------- -``sprockets.mixins.media_type`` is available on the -`Python Package Index `_ -and can be installed via ``pip`` or ``easy_install``: +This mix-in adds two methods to a ``tornado.web.RequestHandler`` instance: -.. code:: bash +- ``get_request_body() -> dict``: deserializes the request body according + to the HTTP ``Content-Type`` header and returns the deserialized body. +- ``send_response(object)``: serializes the response into the content type + requested by the ``Accept`` header. - pip install sprockets.mixins.media_type +Support for a content types is enabled by calling either the +``add_binary_content_type`` or ``add_text_content_type`` function with the +``tornado.web.Application`` instance, the content type, encoding and decoding +functions as parameters: -Documentation -------------- -http://sprocketsmixinsmedia-type.readthedocs.org/en/latest/ +.. code-block:: python -Example -------- -The following example demonstrates how to use the Mix-in to handle media -type validation and serialization. + import json -.. code:: python + from sprockets.mixins import media_type + from tornado import web - from tornado import web, gen - from sprockets.mixins import media_type + def make_application(): + application = web.Application([ + # insert your handlers here + ]) + media_type.add_text_content_type(application, + 'application/json', 'utf-8', + json.dumps, json.loads) - class MyRequestHandler(media_type.MediaTypeMixin, web.RequestHandler): + return application - @gen.coroutine - def post(self, **kwargs): - # Validate the Content-Type header using the Mix-in - if not self.is_valid_content_type(): - self.set_status(415, 'Unsupported content type') - self.finish() - return +The *add content type* functions will add a attribute to the ``Application`` +instance that the mix-in uses to manipulate the request and response bodies. - # Deserialize your request payload - data = self.decode_request() +.. code-block:: python - # Ensure that you get some data out of it! - if not data: - self.set_status(400) - self.finish() - return + from sprockets.mixins import media_type + from tornado import web - # Manipulate your data and do business stuff with it - data.pop('the_key') + class SomeHandler(media_type.ContentMixin, web.RequestHandler): + def get(self): + self.send_response({'data': 'value'}) + self.finish() - self.set_status(200) + def post(self): + body = self.get_request_body() + # do whatever + self.send_response({'action': 'performed'}) + self.finish() - # Automatically serialize your data using the HTTP Accept headers - self.write(data) +Based on the settings stored in the ``Application`` instance and the HTTP +headers, the request and response data will be handled correctly or the +appropriate HTTP exceptions will be raised. - @gen.coroutine - def get(self, some_id): - # Validate the Accept headers using the Mix-in - if not self.is_valid_accept_header(): - self.set_status(406, 'Invalid Accept header') - self.finish() - return - - # Maybe do some lookups from the database or get some data from somewhere - data = {'some_id': some_id} - - self.set_status(200) - - # Automatically serialize your data using the HTTP Accept headers - self.write(data) - - -Version History ---------------- -Available at http://sprocketsmixinsmedia-type.readthedocs.org/en/latest//en/latest/history.html - -.. |Version| image:: https://img.shields.io/pypi/v/sprockets.mixins.media_type.svg? - :target: http://badge.fury.io/py/sprockets.mixins.media_type - -.. |Status| image:: https://img.shields.io/travis/sprockets/sprockets.mixins.media_type.svg? +.. |Documentation| image:: https://readthedocs.org/projects/sprocketsmixinsmedia-type/?badge=latest + :target: https://sprocketsmixinsmedia_type.readthedocs.org/ +.. |Build Badge| image:: https://travis-ci.org/sprockets/sprockets.mixins.media_type.svg :target: https://travis-ci.org/sprockets/sprockets.mixins.media_type - -.. |Coverage| image:: https://img.shields.io/codecov/c/github/sprockets/sprockets.mixins.media_type.svg? - :target: https://codecov.io/github/sprockets/sprockets.mixins.media_type?branch=master - -.. |Downloads| image:: https://img.shields.io/pypi/dm/sprockets.mixins.media_type.svg? - :target: https://pypi.python.org/pypi/sprockets.mixins.media_type - -.. |License| image:: http://img.shields.io/:license-mit-blue.svg - :target: http://doge.mit-license.org +.. |Package Info| image:: https://img.shields.io/pypi/v/sprockets.mixins.media_type.svg + :target: https://pypi.python.org/sprockets.mixins.media_type diff --git a/docs/api.rst b/docs/api.rst index 5543b46..5833dc8 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,2 +1,19 @@ -.. automodule:: sprockets.mixins.media_type +API Documentation +================= +.. currentmodule:: sprockets.mixins.media_type + +Content Type Handling +--------------------- +.. autoclass:: ContentMixin + :members: + +Content Type Registration +------------------------- +.. autofunction:: set_default_content_type + +.. autofunction:: add_binary_content_type + +.. autofunction:: add_text_content_type + +.. autoclass:: ContentSettings :members: diff --git a/docs/conf.py b/docs/conf.py index 8ea2725..dc3d18f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,9 +1,11 @@ +import alabaster from sprockets.mixins.media_type import __version__ needs_sphinx = '1.0' extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode', - 'sphinx.ext.intersphinx'] + 'sphinx.ext.intersphinx', + 'sphinxcontrib.autohttp.tornado'] source_suffix = '.rst' master_doc = 'index' project = 'sprockets.mixins.media_type' @@ -11,8 +13,26 @@ copyright = '2015, AWeber Communications' release = __version__ version = '.'.join(release.split('.')[0:1]) +pygments_style = 'sphinx' +html_theme = 'alabaster' +html_style = 'custom.css' +html_static_path = ['static'] +html_theme_path = [alabaster.get_path()] +html_sidebars = { + '**': ['about.html', 'navigation.html'], +} +html_theme_options = { + 'github_user': 'sprockets', + 'github_repo': 'sprockets.mixins.media_type', + 'description': 'Content-Type negotation mix-in', + 'github_banner': True, + 'travis_button': True, + 'sidebar_width': '230px', +} + intersphinx_mapping = { 'python': ('https://docs.python.org/', None), 'requests': ('https://requests.readthedocs.org/en/latest/', None), 'sprockets': ('https://sprockets.readthedocs.org/en/latest/', None), + 'tornado': ('http://tornadoweb.org/en/latest/', None), } diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..112ba2a --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,112 @@ +How to Contribute +================= +Do you want to contribute fixes or improvements? + + **AWesome!** *Thank you very much, and let's get started.* + +Set up a development environment +-------------------------------- +The first thing that you need is a development environment so that you can +run the test suite, update the documentation, and everything else that is +involved in contributing. The easiest way to do that is to create a virtual +environment for your endevours:: + + $ virtualenv -p python2.7 env + +Don't worry about writing code against previous versions of Python unless +you you don't have a choice. That is why we run our tests through `tox`_. +If you don't have a choice, then install `virtualenv`_ to create the +environment instead. The next step is to install the development tools +that this project uses. These are listed in *requires/development.txt*:: + + $ env/bin/pip install -qr requires/development.txt + +At this point, you will have everything that you need to develop at your +disposal. *setup.py* is the swiss-army knife in your development tool +chest. It provides the following commands: + +**./setup.py nosetests** + Run the test suite using `nose`_ and generate a nice coverage report. + +**./setup.py build_sphinx** + Generate the documentation using `sphinx`_. + +**./setup.py flake8** + Run `flake8`_ over the code and report style violations. + +If any of the preceding commands give you problems, then you will have to +fix them **before** your pull request will be accepted. + +Running Tests +------------- +The easiest (and quickest) way to run the test suite is to use the +*nosetests* command. It will run the test suite against the currently +installed python version and report not only the test result but the +test coverage as well:: + + $ ./setup.py nosetests + + running nosetests + running egg_info + writing dependency_links to sprockets.mixins.media_type.egg-info/dependency_links.txt + writing top-level names to sprockets.mixins.media_type.egg-info/top_level.txt + writing sprockets.mixins.media_type.egg-info/PKG-INFO + reading manifest file 'sprockets.mixins.media_type.egg-info/SOURCES.txt' + reading manifest template 'MANIFEST.in' + warning: no previously-included files matching '__pycache__'... + warning: no previously-included files matching '*.swp' found ... + writing manifest file 'sprockets.mixins.media_type.egg-info/SOURCES.txt' + ... + + Name Stmts Miss Branch BrMiss Cover Missing + ---------------------------------------------------------------------- + ... + ---------------------------------------------------------------------- + TOTAL 95 2 59 2 97% + ---------------------------------------------------------------------- + Ran 44 tests in 0.054s + + OK + +That's the quick way to run tests. The slightly longer way is to run +the `detox`_ utility. It will run the test suite against all of the +supported python versions in parallel. This is essentially what Travis-CI +will do when you issue a pull request anyway:: + + $ env/bin/detox + py27 recreate: /.../sprockets.mixins.media_type/build/tox/py27 + GLOB sdist-make: /.../sprockets.mixins.media_type/setup.py + py34 recreate: /.../sprockets.mixins.media_type/build/tox/py34 + py27 installdeps: -rtest-requirements.txt, mock + py34 installdeps: -rtest-requirements.txt + py27 inst: /.../sprockets.mixins.media_type/build/tox/dist/sprockets.mixins.media_type-0.0.0.zip + py27 runtests: PYTHONHASHSEED='2156646470' + py27 runtests: commands[0] | /../sprockets.mixins.media_type/build/tox/py27/bin/nosetests + py34 inst: /../sprockets.mixins.media_type/.build/tox/dist/sprockets.mixins.media_type-0.0.0.zip + py34 runtests: PYTHONHASHSEED='2156646470' + py34 runtests: commands[0] | /.../sprockets.mixins.media_type/build/tox/py34/bin/nosetests + _________________________________ summary _________________________________ + py27: commands succeeded + py34: commands succeeded + congratulations :) + +This is what you want to see. Now you can make your modifications and keep +the tests passing. + +Submitting a Pull Request +------------------------- +Once you have made your modifications, gotten all of the tests to pass, +and added any necessary documentation, it is time to contribute back for +posterity. You've probably already cloned this repository and created a +new branch. If you haven't, then checkout what you have as a branch and +roll back *master* to where you found it. Then push your repository up +to github and issue a pull request. Describe your changes in the request, +if Travis isn't too annoyed someone will review it, and eventually merge +it back. + +.. _flake8: http://flake8.readthedocs.org/ +.. _nose: http://nose.readthedocs.org/ +.. _sphinx: http://sphinx-doc.org/ +.. _detox: http://testrun.org/tox/ +.. _tox: http://testrun.org/tox/ +.. _virtualenv: http://virtualenv.pypa.io/ diff --git a/docs/examples.rst b/docs/examples.rst index aed8104..0ac43f1 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,54 +1,4 @@ Examples ======== -The following example demonstrates how to use the Mix-in to handle media -type validation and serialization. - -.. code:: python - - from tornado import web, gen - from sprockets.mixins import media_type - - - class MyRequestHandler(media_type.MediaTypeMixin, web.RequestHandler): - - @gen.coroutine - def post(self, **kwargs): - # Validate the Content-Type header using the Mix-in - if not self.is_valid_content_type(): - self.set_status(415, 'Unsupported content type') - self.finish() - return - - # Deserialize your request payload - data = self.decode_request() - - # Ensure that you get some data out of it! - if not data: - self.set_status(400) - self.finish() - return - - # Manipulate your data and do business stuff with it - data.pop('the_key') - - self.set_status(200) - - # Automatically serialize your data using the HTTP Accept headers - self.write(data) - - @gen.coroutine - def get(self, some_id): - # Validate the Accept headers using the Mix-in - if not self.is_valid_accept_header(): - self.set_status(406, 'Invalid Accept header') - self.finish() - return - - # Maybe do some lookups from the database or get some data from somewhere - data = {'some_id': some_id} - - self.set_status(200) - - # Automatically serialize your data using the HTTP Accept headers - self.write(data) +.. literalinclude:: ../examples.py diff --git a/docs/index.rst b/docs/index.rst index 7a68294..3b7c360 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,61 +1,16 @@ -sprockets.mixins.media_type -=========================== -A mixin that performs Content-Type negotiation and request/response (de)serialization. +.. include:: ../README.rst -|Version| |Downloads| |Status| |Coverage| |License| - -Installation ------------- -``sprockets.mixins.media_type`` is available on the -`Python Package Index `_ -and can be installed via ``pip`` or ``easy_install``: - -.. code:: bash - - pip install sprockets.mixins.media_type - -API Documentation ------------------ -.. toctree:: - :maxdepth: 2 - - api - examples - -Version History ---------------- -See :doc:`history` - -Issues ------- -Please report any issues to the Github project at `https://github.com/sprockets/sprockets.mixins.media_type/issues `_ - -Source ------- -``sprockets.mixins.media_type`` source is available on Github at `https://github.com/sprockets/sprockets.mixins.media_type `_ +Examples +-------- +.. literalinclude:: ../examples.py License ------- -``sprockets.mixins.media_type`` is released under the `3-Clause BSD license `_. +.. include:: ../LICENSE -Indices and tables ------------------- +.. toctree:: + :hidden: -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - -.. |Version| image:: https://img.shields.io/pypi/v/sprockets.mixins.media_type.svg? - :target: http://badge.fury.io/py/sprockets.mixins.media_type - -.. |Status| image:: https://img.shields.io/travis/sprockets/sprockets.mixins.media_type.svg? - :target: https://travis-ci.org/sprockets/sprockets.mixins.media_type - -.. |Coverage| image:: https://img.shields.io/codecov/c/github/sprockets/sprockets.mixins.media_type.svg? - :target: https://codecov.io/github/sprockets/sprockets.mixins.media_type?branch=master - -.. |Downloads| image:: https://img.shields.io/pypi/dm/sprockets.mixins.media_type.svg? - :target: https://pypi.python.org/pypi/sprockets.mixins.media_type - -.. |License| image:: http://img.shields.io/:license-mit-blue.svg - :target: http://doge.mit-license.org + api + contributing + history diff --git a/docs/static/custom.css b/docs/static/custom.css new file mode 100644 index 0000000..9e9b139 --- /dev/null +++ b/docs/static/custom.css @@ -0,0 +1,4 @@ +@import url("alabaster.css"); +h1.logo { + font-size: 12pt; +} diff --git a/examples.py b/examples.py new file mode 100644 index 0000000..370ba4d --- /dev/null +++ b/examples.py @@ -0,0 +1,43 @@ +import json +import logging +import signal + +from sprockets.mixins import media_type +from tornado import ioloop, web +import msgpack + + +class SimpleHandler(media_type.ContentMixin, web.RequestHandler): + + def post(self): + body = self.get_request_body() + self.set_status(200) + self.send_response(body) + self.finish() + + +def make_application(**settings): + application = web.Application([web.url(r'/', SimpleHandler)], **settings) + media_type.set_default_content_type(application, 'application/json', + encoding='utf-8') + media_type.add_binary_content_type(application, 'application/msgpack', + msgpack.packb, msgpack.unpackb) + media_type.add_text_content_type(application, 'application/json', 'utf-8', + json.dumps, json.loads) + return application + + +def _signal_handler(signo, _): + logging.info('received signal %d, stopping application', signo) + iol = ioloop.IOLoop.instance() + iol.add_callback_from_signal(iol.stop) + + +if __name__ == '__main__': + logging.basicConfig(level=logging.DEBUG, + format='%(levelname)1.1s - %(name)s: %(message)s') + application = make_application(debug=True) + application.listen(8000) + signal.signal(signal.SIGINT, _signal_handler) + signal.signal(signal.SIGTERM, _signal_handler) + ioloop.IOLoop.instance().start() diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index cff7c84..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -tornado -msgpack-python diff --git a/requires/development.txt b/requires/development.txt new file mode 100644 index 0000000..d49abfe --- /dev/null +++ b/requires/development.txt @@ -0,0 +1,4 @@ +-r testing.txt +-r installation.txt +sphinx>=1.2,<2 +sphinxcontrib-httpdomain>=1.3,<2 diff --git a/requires/installation.txt b/requires/installation.txt new file mode 100644 index 0000000..60d5fca --- /dev/null +++ b/requires/installation.txt @@ -0,0 +1,2 @@ +ietfparse>=1.2.2,<2 +tornado>=3.2,<5 diff --git a/requires/testing.txt b/requires/testing.txt new file mode 100644 index 0000000..c5be104 --- /dev/null +++ b/requires/testing.txt @@ -0,0 +1,4 @@ +coverage>=3.7,<3.99 # prevent installing 4.0b on ALL pip versions +mock>=1.3,<2 +msgpack-python>=0.4,<0.5 +nose>=1.3,<2 diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 6ff3bbb..8b30d54 --- a/setup.py +++ b/setup.py @@ -1,10 +1,37 @@ +#!/usr/bin/env python +# + +import os import setuptools + +def read_requirements(file_name): + requirements = [] + try: + with open(os.path.join('requires', file_name)) as req_file: + for req_line in req_file: + req_line = req_line.strip() + if '#' in req_line: + req_line = req_line[0:req_line.find('#')].strip() + if req_line.startswith('-r'): + req_line = req_line[2:].strip() + requirements.extend(read_requirements(req_line)) + else: + requirements.append(req_line) + except IOError: + pass + return requirements + + +install_requires = read_requirements('install.txt') +setup_requires = read_requirements('setup.txt') +tests_require = read_requirements('testing.txt') + setuptools.setup( name='sprockets.mixins.media_type', version='0.0.0', description='A mixin for reporting handling content-type/accept headers', - long_description=open('test-requirements.txt', 'r').read(), + long_description='\n' + open('README.rst').read(), url='https://github.com/sprockets/sprockets.mixins.media_type', author='AWeber Communications', author_email='api@aweber.com', @@ -27,12 +54,10 @@ setuptools.setup( 'Topic :: Software Development :: Libraries', 'Topic :: Software Development :: Libraries :: Python Modules' ], - packages=['sprockets', - 'sprockets.mixins', - 'sprockets.mixins.media_type'], - package_data={'': ['LICENSE', 'README.md']}, - include_package_data=True, - install_requires=['tornado'], - namespace_packages=['sprockets', - 'sprockets.mixins'], + packages=setuptools.find_packages(), + install_requires=install_requires, + setup_requires=setup_requires, + tests_require=tests_require, + namespace_packages=['sprockets', 'sprockets.mixins'], + test_suite='nose.collector', zip_safe=False) diff --git a/sprockets/mixins/media_type.py b/sprockets/mixins/media_type.py new file mode 100644 index 0000000..c706f71 --- /dev/null +++ b/sprockets/mixins/media_type.py @@ -0,0 +1,263 @@ +""" +sprockets.mixins.media_type +=========================== + +""" +import logging + +from ietfparse import algorithms, errors, headers +from tornado import escape, web + + +version_info = (0, 0, 0) +__version__ = '.'.join(str(v) for v in version_info) +logger = logging.getLogger(__name__) + + +class ContentSettings(object): + """ + Content selection settings. + + An instance of this class is stashed as the ``_content_settings`` + attribute on the application object. It contains the list of + available content types and handlers associated with them. Each + handler implements a simple interface: + + - ``to_bytes(dict, encoding:str) -> bytes`` + - ``from_bytes(bytes, encoding:str) -> dict`` + + Use the :func:`add_binary_content_type` and :func:`add_text_content_type` + helper functions to modify the settings for the application. + + This class acts as a mapping from content-type string to the + appropriate handler instance. Add new content types and find + handlers using the common ``dict`` syntax: + + .. code-block:: python + + class SomeHandler(web.RequestHandler): + + def get(self): + settings = ContentSettings.from_application(self.application) + response_body = settings['application/msgpack'].to_bytes( + response_dict, encoding='utf-8') + self.write(response_body) + self.finish() + + def make_application(): + app = web.Application([web.url('/', SomeHandler)]) + add_binary_content_type(app, 'application/msgpack', + msgpack.packb, msgpack.unpackb) + add_text_content_type(app, 'application/json', 'utf-8', + json.dumps, json.loads) + return app + + Of course, that is quite tedious, so use the :class:`.ContentMixin` + instead. + + """ + + def __init__(self): + self._handlers = {} + self._available_types = [] + self.default_content_type = None + self.default_encoding = None + + def __getitem__(self, content_type): + return self._handlers[content_type] + + def __setitem__(self, content_type, handler): + if content_type in self._handlers: + logger.warning('handler for %s already set to %r', + content_type, self._handers[content_type]) + return + + self._available_types.append(headers.parse_content_type(content_type)) + self._handlers[content_type] = handler + + def get(self, content_type, default=None): + return self._handlers.get(content_type, default) + + @classmethod + def from_application(cls, application): + """Retrieve the content settings from an application.""" + if not hasattr(application, '_content_settings'): + setattr(application, '_content_settings', cls()) + return application._content_settings + + @property + def available_content_types(self): + """ + List of the content types that are registered. + + This is a sequence of :class:`ietfparse.datastructures.ContentType` + instances. + + """ + return self._available_types + + +def add_binary_content_type(application, content_type, pack, unpack): + """ + Add handler for a binary content type. + + :param tornado.web.Application application: the application to modify + :param str content_type: the content type to add + :param pack: function that packs a dictionary to a byte string. + ``pack(dict) -> bytes`` + :param unpack: function that takes a byte string and returns a + dictionary. ``unpack(bytes) -> dict`` + + """ + settings = ContentSettings.from_application(application) + settings[content_type] = _BinaryContentHandler(content_type, pack, unpack) + + +def add_text_content_type(application, content_type, default_encoding, + dumps, loads): + """ + Add handler for a text content type. + + :param tornado.web.Application application: the application to modify + :param str content_type: the content type to add + :param str default_encoding: encoding to use when one is unspecified + :param dumps: function that dumps a dictionary to a string. + ``dumps(dict, encoding:str) -> str`` + :param loads: function that loads a dictionary from a string. + ``loads(str, encoding:str) -> dict`` + + """ + settings = ContentSettings.from_application(application) + settings[content_type] = _TextContentHandler(content_type, dumps, loads, + default_encoding) + + +def set_default_content_type(application, content_type, encoding=None): + """ + Store the default content type for an application. + + :param tornado.web.Application application: the application to modify + :param str content_type: the content type to default to + :param str|None encoding: encoding to use when one is unspecified + + """ + settings = ContentSettings.from_application(application) + settings.default_content_type = content_type + settings.default_encoding = encoding + + +class ContentMixin(object): + """ + Mix this in to add some content handling methods. + + .. code-block:: python + + class MyHandler(ContentMixin, web.RequestHandler): + def post(self): + body = self.get_request_body() + # do stuff --> response_dict + self.send_response(response_dict) + self.finish() + + :meth:`get_request_body` will deserialize the request data into + a dictionary based on the :http:header:`Content-Type` request + header. Similarly, :meth:`send_response` takes a dictionary, + serializes it based on the :http:header:`Accept` request header + and the application :class:`ContentSettings`, and writes it out, + using ``self.write()``. + + """ + + def initialize(self): + super(ContentMixin, self).initialize() + self._request_body = None + self._best_response_match = None + + def get_response_content_type(self): + """Figure out what content type will be used in the response.""" + if self._best_response_match is None: + settings = ContentSettings.from_application(self.application) + acceptable = headers.parse_http_accept_header( + self.request.headers.get('Accept', '*/*')) + try: + selected, _ = algorithms.select_content_type( + acceptable, settings.available_content_types) + self._best_response_match = '/'.join( + [selected.content_type, selected.content_subtype]) + except errors.NoMatch: + self._best_response_match = settings.default_content_type + + return self._best_response_match + + def get_request_body(self): + """ + Fetch (and cache) the request body as a dictionary. + + :raise web.HTTPError: if the content type cannot be decoded. + The status code is set to 415 Unsupported Media Type + + """ + if self._request_body is None: + settings = ContentSettings.from_application(self.application) + content_type_header = headers.parse_content_type( + self.request.headers.get('Content-Type', + settings.default_content_type)) + content_type = '/'.join([content_type_header.content_type, + content_type_header.content_subtype]) + try: + handler = settings[content_type] + self._request_body = handler.from_bytes(self.request.body) + + except KeyError: + raise web.HTTPError(415, 'cannot decode body of type %s', + content_type) + + return self._request_body + + def send_response(self, body, set_content_type=True): + """ + Serialize and send ``body`` in the response. + + :param dict body: the body to serialize + :param bool set_content_type: should the :http:header:`Content-Type` + header be set? Defaults to :data:`True` + + """ + settings = ContentSettings.from_application(self.application) + handler = settings[self.get_response_content_type()] + content_type, data_bytes = handler.to_bytes(body) + if set_content_type: + self.set_header('Content-Type', content_type) + self.write(data_bytes) + + +class _BinaryContentHandler(object): + + def __init__(self, content_type, pack, unpack): + self._pack = pack + self._unpack = unpack + self.content_type = content_type + + def to_bytes(self, data_dict, encoding=None): + return self.content_type, self._pack(data_dict) + + def from_bytes(self, data, encoding=None): + return self._unpack(data) + + +class _TextContentHandler(object): + + def __init__(self, content_type, dumps, loads, default_encoding): + self._dumps = dumps + self._loads = loads + self.content_type = content_type + self.default_encoding = default_encoding + + def to_bytes(self, data_dict, encoding=None): + selected = encoding or self.default_encoding + content_type = '{0}; charset="{1}"'.format(self.content_type, selected) + dumped = self._dumps(escape.recursive_unicode(data_dict)) + return content_type, dumped.encode(selected) + + def from_bytes(self, data, encoding=None): + return self._loads(data.decode(encoding or self.default_encoding)) diff --git a/sprockets/mixins/media_type/__init__.py b/sprockets/mixins/media_type/__init__.py deleted file mode 100644 index eb885a0..0000000 --- a/sprockets/mixins/media_type/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -Sprockets.Mixins.Media_type -=========================== - -""" - -version_info = (0, 0, 0) -__version__ = '.'.join(str(v) for v in version_info) diff --git a/test-requirements.txt b/test-requirements.txt deleted file mode 100644 index 06fd124..0000000 --- a/test-requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -coverage>=3.7,<4 -codecov -nose>=1.3,<2 -mock --r requirements.txt diff --git a/tests.py b/tests.py index e69de29..e815601 100644 --- a/tests.py +++ b/tests.py @@ -0,0 +1,61 @@ +import json + +from tornado import testing +import msgpack + +import examples + + +class SendResponseTests(testing.AsyncHTTPTestCase): + + def get_app(self): + return examples.make_application(debug=True) + + def test_that_content_type_default_works(self): + response = self.fetch('/', method='POST', body='{}', + headers={'Content-Type': 'application/json'}) + self.assertEqual(response.code, 200) + self.assertEqual(response.headers['Content-Type'], + 'application/json; charset="utf-8"') + + def test_that_missing_content_type_uses_default(self): + response = self.fetch('/', method='POST', body='{}', + headers={'Accept': 'application/xml', + 'Content-Type': 'application/json'}) + self.assertEqual(response.code, 200) + self.assertEqual(response.headers['Content-Type'], + 'application/json; charset="utf-8"') + + def test_that_accept_header_is_obeyed(self): + response = self.fetch('/', method='POST', body='{}', + headers={'Accept': 'application/msgpack', + 'Content-Type': 'application/json'}) + self.assertEqual(response.code, 200) + self.assertEqual(response.headers['Content-Type'], + 'application/msgpack') + + +class GetRequestBodyTests(testing.AsyncHTTPTestCase): + + def get_app(self): + return examples.make_application(debug=True) + + def test_that_request_with_unhandled_type_results_in_415(self): + response = self.fetch( + '/', method='POST', headers={'Content-Type': 'application/xml'}, + body=(u'value' + u'\u2731' + u'').encode('utf-8')) + self.assertEqual(response.code, 415) + + def test_that_msgpack_request_returns_default_type(self): + body = { + 'name': 'value', + 'embedded': { + 'utf8': u'\u2731' + } + } + response = self.fetch('/', method='POST', body=msgpack.packb(body), + headers={'Content-Type': 'application/msgpack'}) + self.assertEqual(response.code, 200) + self.assertEqual(json.loads(response.body.decode('utf-8')), body) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..71a7562 --- /dev/null +++ b/tox.ini @@ -0,0 +1,11 @@ +[tox] +envlist = py27,py34 +indexserver = + default = https://pypi.python.org/simple +toxworkdir = build/tox + +[testenv] +deps = + -rrequires/installation.txt + -rrequires/testing.txt +commands = nosetests []