mirror of
https://github.com/sprockets/sprockets.mixins.mediatype.git
synced 2024-11-21 19:28:38 +00:00
Implement basic content handling framework.
This commit is contained in:
parent
e5023a420f
commit
c67d1af19d
5 changed files with 285 additions and 5 deletions
43
examples.py
Normal file
43
examples.py
Normal file
|
@ -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()
|
2
requires/development.txt
Normal file
2
requires/development.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
-r testing.txt
|
||||
-r installation.txt
|
|
@ -1,5 +1,4 @@
|
|||
coverage>=3.7,<4
|
||||
codecov
|
||||
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
|
||||
mock
|
||||
-r requirements.txt
|
||||
|
|
|
@ -1,8 +1,220 @@
|
|||
"""
|
||||
Sprockets.Mixins.media_type
|
||||
sprockets.mixins.media_type
|
||||
===========================
|
||||
|
||||
"""
|
||||
import logging
|
||||
|
||||
|
||||
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.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._handlers[content_type] = handler
|
||||
|
||||
@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
|
||||
|
||||
def _get_content_settings(application):
|
||||
if not hasattr(application, '_content_settings'):
|
||||
setattr(application, '_content_settings', ContentSettings())
|
||||
return getattr(application, '_content_settings')
|
||||
|
||||
|
||||
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."""
|
||||
settings = ContentSettings.from_application(self.application)
|
||||
return settings.default_content_type
|
||||
|
||||
def get_request_body(self):
|
||||
"""Fetch (and cache) the request body as a dictionary."""
|
||||
if self._request_body is None:
|
||||
settings = ContentSettings.from_application(self.application)
|
||||
handler = settings[settings.default_content_type]
|
||||
self._request_body = handler.from_bytes(self.request.body)
|
||||
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)
|
||||
return content_type, self._dumps(data_dict).encode(selected)
|
||||
|
||||
def from_bytes(self, data, encoding=None):
|
||||
return self._loads(data.decode(encoding or self.default_encoding))
|
||||
|
|
24
tests.py
24
tests.py
|
@ -0,0 +1,24 @@
|
|||
from tornado import testing
|
||||
|
||||
import examples
|
||||
|
||||
|
||||
class ContentTypeTests(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='{"attribute": "value"}',
|
||||
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'})
|
||||
self.assertEqual(response.code, 200)
|
||||
self.assertEqual(response.headers['Content-Type'],
|
||||
'application/json; charset="utf-8"')
|
Loading…
Reference in a new issue