glinda¶
Glinda is a companion library for tornado. It is an attempt to make your time with the framework less painful. In fact, I want to make it downright enjoyable. I started down the path of developing HTTP endpoints in Tornado and needing to test them. The tornado.testing package is handy for testing endpoints in isolation. But what do you do when you have a HTTP service that is calling other HTTP services asynchronously. It turns out that testing that is not as easy as it should be. That is the first thing that I tackled and it is the first thing that this library is going to offer – a way to test non-trivial services.
Once you can test your application, the next step is to write a well-behaved application that fits into the WWW nicely. Tornado does a pretty nice job of handling the nitty gritty HTTP details (e.g., CTE, transfer encodings). It doesn’t provide a clean way to handle representations transparently so I decided to add that into this library as well.
Content Handling¶
Tornado has some internal content decoding accessible by calling the
get_body_arguments
method of tornado.web.RequestHandler
. It will
decode basic form data, application/x-www-form-urlencoded
and
multipart/form-data
specifically. Anything else is left up to you.
glinda
exposes a content handling mix-in that imbues a standard
RequestHandler
with a property that is the decoded request body and
a new method to encode a response. Here’s what it looks like:
class MyHandler(glinda.content.HandlerMixin, web.RequestHandler):
def post(self, *args, **kwargs):
body_argument = self.request_body['arg']
# do stuff
self.send_response(response_dict)
self.finish()
if __name__ == '__main__':
glinda.content.register_text_type('application/json',
default_charset='utf-8',
dumper=json.dumps, loader=json.loads)
glinda.content.register_binary_type('application/msgpack',
msgpack.dumpb, msgpack.loadb)
When the client sends a post with a content type of application/json
, it
will decode the binary body to a string according to the HTTP headers and
call json.loads
to decode the body when you reference the request_body
property. Failures are handled by raising a HTTPError(400)
so you don’t
have to worry about handling malformed messages. The send_response
method will take care of figuring out the appropriate content type based on
any included Accept
headers. All that you have to do is install
encoding and decoding handlers for expected content types.
The glinda.content
package implements content handling as described in
RFC7231. Specifically, it decodes request bodies as described in
section 3.1 and proactive content negotiation as described in sections
3.4.1 and 5.3.
Testing¶
Here’s an example of testing a Tornado endpoint that asynchronously calls
another service. In this case, the application interacts with with the
/add
endpoint of some other service. Testing in isolation can be tricky
without having to have a copy of the service running. You could mock out
the AsyncHTTPClient
and return fake futures and what not but that has
the nasty side-effect of hiding defects around how content type or headers
are handled – no HTTP requests means that you have untested assumptions.
The following snippet tests the application under test using the
ServiceLayer
abstraction that glinda.testing
provides.
from tornado import testing
from glinda.testing import services
class MyServiceTests(testing.AsyncHTTPTestCase):
def setUp(self):
service_layer = services.ServiceLayer()
self.service = service_layer['adder']
# TODO configured your application here using
# self.service.url_for('/add') or self.service.host
super(MyServiceTests, self).setUp()
def get_app(self):
return MyApplication()
def test_that_my_service_calls_other_service(self):
self.service.add_response(
services.Request('POST', '/add'),
services.Response(200, body='{"result": 10}'))
self.fetch(self.get_url('/do-stuff'), method='GET')
recorded = self.service.get_request('/add')
self.assertEqual(recorded.method, 'POST')
self.assertEqual(recorded.body, '[1,2,3,4]')
self.assertEqual(recorded.headers['Content-Type'], 'application/json')
The application under test is linked in by implementing the standard
tornado.testing.AsyncHTTPTestCase.get_app
method. Then you add in
a glinda.testing.services.ServiceLayer
object and configure it to look
like the services that you depend on by adding endpoints and then configuring
your application to point at the service layer. When you invoke the
application under test using self.fetch(...)
, it will send HTTP requests
through the Tornado stack (using the testing ioloop
) to the service layer
which will respond appropriately. The beauty is that the entire HTTP stack is
exercised locally so that you can easily test edge cases such as correct
handling of status codes, custom headers, or malformed bodies without
resorting to deep mocking.
Where?¶
Source |
|
Status |
|
Download |
|
Documentation |
|
Issues |
Documentation¶
Content Handling¶
The glinda.content
module includes functions and classes that make
content negotiation in Tornado easier to extend. Tornado’s web framework
includes get_body_argument()
which handles
a handful of body encodings. It is difficult to add new content type handlers
in the current framework. Instead of adding all of the logic into the
RequestHandler
, glinda.content
maintains a mapping
from content type to encoder and decoder callables and exposes a mix-in that
implements content negotiation over a RequestHandler
.
The get_request_body()
method added by the
HandlerMixin
class will decode and cache the request body based on
a set of registered content types. You register encoder and decoder functions
associated with specific content types when your application starts up and
the HandlerMixin
will call them when it decodes the request body.
Request bodies are exposed from Tornado as raw byte strings. Calling
register_binary_type()
associates binary transcoding functions with a
specific MIME content type.
from glinda import content
import msgpack
content.register_binary_type('application/msgpack', msgpack.dumpb,
msgpack.loadb)
The transcoding functions are called to translate between dict
and
byte
representations when the Content-Type header matches
the specified value.
Many HTTP payloads are text-based and the protocol includes character set
negotiation separately from the content type. The character set of the
request body is usually indicated by the charset
content parameter ala
Content-Type: application/json; charset=utf-8
. You can register string-
based transcoding functions with register_text_type()
. Request body
processing will decode the byte string into a str
instance according
to the detected character set before calling text-based decoding functions.
If a character set is not included in the request headers, then an application
specified default value is used.
from glinda import content
import json
content.register_text_type('application/json', 'utf-8',
json.dumps, json.loads)
Binary registrations are preferred over text since they do not require the character transcoding process.
Once you have registered some content handlers, use the HandlerMixin
class to de-serialize requests and serialize responses. The following class
mimics the GET and POST functionality of the excellent http://httpbin.org
utility site.
class HttpbinHandler(content.HandlerMixin, web.RequestHandler):
"""Mimics http://httpbin.org/{get,post}"""
def get(self):
self.send_response(self.standard_response_dict)
self.finish()
def post(self):
response = self.standard_response_dict
response['data'] = repr(self.request.body)
response['files'] = {}
response['form'] = {}
response['body'] = self.get_request_body()
self.send_response(response)
self.finish()
@property
def standard_response_dict(self):
return {
'args': httpcompat.parse_qs(self.request.query),
'headers': dict(self.request.headers),
'origin': self.request.remote_ip,
'url': self.request.uri,
}
When you run examples/contentneg.py, it will run a Tornado application
listening on port 8000 with at least the JSON content handler enabled.
If the msgpack
module is available, then the application/x-msgpack
content type will be enabled. Likewise for the yaml
module and
application/yaml. Assuming that you have the PyYAML package installed,
then the following examples should work.
A request that explicitly requests a JSON response will get one.
GET / HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8000
User-Agent: HTTPie/0.9.2
HTTP/1.1 200 OK
Content-Length: 204
Content-Type: application/json; charset=utf-8
Date: Sun, 09 Aug 2015 17:00:30 GMT
Etag: "7bccfbf9d3f99b4b9bc88ec4f27b1913e5c0b27e"
Server: TornadoServer/4.2
{
"args": {},
"headers": {
"Accept": "application/json",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
"Host": "localhost:8000",
"User-Agent": "HTTPie/0.9.2"
},
"origin": "::1",
"url": "/"
}
If you explicitly request application/yaml, then the same data will be encoded as a YAML document.
GET / HTTP/1.1
Accept: application/yaml
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8000
User-Agent: HTTPie/0.9.2
HTTP/1.1 200 OK
Content-Length: 174
Content-Type: application/yaml; charset=utf-8
Date: Sun, 09 Aug 2015 17:04:23 GMT
Etag: "3d88b7fc99bb1b31807e88e4ea3d312d391c037b"
Server: TornadoServer/4.2
args: {}
headers: {Accept: application/yaml, Accept-Encoding: 'gzip, deflate',
Connection: keep-alive, Host: 'localhost:8000', User-Agent: HTTPie/0.9.2}
origin: ::1
url: /
The request handler simple needs to use HandlerMixin.get_request_body()
method to retrieve the request body and HandlerMixin.send_response()
to
transmit a response body.
Functions¶
-
glinda.content.
register_binary_type
(content_type, dumper, loader)[source]¶ Register handling for a binary content type.
- Parameters
content_type (str) – content type to register the hooks for
dumper – called to decode bytes into a dictionary. Calling convention:
dumper(obj_dict) -> bytes
.loader – called to encode a dictionary into a byte string. Calling convention:
loader(obj_bytes) -> dict
-
glinda.content.
register_text_type
(content_type, default_encoding, dumper, loader)[source]¶ Register handling for a text-based content type.
- Parameters
content_type (str) – content type to register the hooks for
default_encoding (str) – encoding to use if none is present in the request
dumper – called to decode a string into a dictionary. Calling convention:
dumper(obj_dict).encode(encoding) -> bytes
loader – called to encode a dictionary to a string. Calling convention:
loader(obj_bytes.decode(encoding)) -> dict
The decoding of a text content body takes into account decoding the binary request body into a string before calling the underlying dump/load routines.
Classes¶
-
class
glinda.content.
HandlerMixin
(*args, **kwargs)[source]¶ Mix this in over
RequestHandler
to enable content handling.-
get_request_body
()[source]¶ Decodes the request body and returns it.
- Returns
the decoded request body as a
dict
instance.- Raises
tornado.web.HTTPError
if the body cannot be decoded (415) or if decoding fails (400)
-
property
registered_content_types
¶ Yields the currently registered content types in some order.
-
send_response
(response_dict)[source]¶ Encode a response according to the request.
- Parameters
response_dict (dict) – the response to send
- Raises
tornado.web.HTTPError
if no acceptable content type exists
This method will encode response_dict using the most appropriate encoder based on the Accept request header and the available encoders. The result is written to the client by calling
self.write
after setting the response content type usingself.set_header
.
-
Testing Utilities¶
glinda.testing.services
contains four classes that make
testing Tornado applications that make HTTP requests against other
services much easier. The problem that I set out to solve was how
to exercise as much of the HTTP stack as possible without running
an external service in it’s entirety. What I came up with is the
ServiceLayer
class. It is responsible for creating and
maintaining a Application
instance that you
configure to look and act like the external services that the
application under test interacts with during the test run.
The expected usage pattern is as follows:
create an instance of
ServiceLayer
insetUp
and save it in an instance variable for future useadd services by calling
ServiceLayer.get_service
for each service that your application uses.configure your application to connect to the fake services. The
Service.url_for()
method can be used to build URLs or you can retrieve the IP address and port from theService.host
attribute.add requests and responses using
Service.add_response()
to configure each specific test before calling your application endpoints
There is a fully functional example below in Example Test
Classes¶
ServiceLayer¶
-
class
glinda.testing.services.
ServiceLayer
[source]¶ Represents any number of HTTP services.
Create an instance of this class to represent any number of HTTP services that your application depends on. It attaches to the
IOLoop
instance that the standardAsyncTestCase
supplies and manages the Tornado machinery necessary to glueService
instances to the ioloop. Each external service that you need to test with is represented by a namedService
instance. TheService
instance maintains the list of programmed responses. TheServiceLayer
exists to make sure that the requests get to the appropriate handler.Each managed service is exposed as named
Service
instance that is owned by theServiceLayer
instance. They are created on-demand by callingget_service()
with the name that you assign to them. TheServiceLayer
instance implements the same behavior as item lookup as well (e.g.,__getitem__
):>>> from tornado.ioloop import IOLoop >>> services = ServiceLayer() >>> services['service-name'] is services.get_service('service-name') True
Once you have a named service instance, you can configure it to respond by calling
Service.add_response()
method.>>> from tornado import httpclient, ioloop >>> services = ServiceLayer() >>> service = services.get_service('endpoint') >>> service.add_response(Request('GET', '/endpoint'), ... Response(222)) >>> def get(): ... client = httpclient.AsyncHTTPClient() ... return client.fetch(service.url_for('endpoint')) >>> rsp = ioloop.IOLoop.instance().run_sync(get) >>> rsp.code 222 >>>
Note that the
get
function in the example mimics the application under test asynchronously interacting with a service within the service layer.
Service¶
-
class
glinda.testing.services.
Service
(name, add_resource_callback)[source]¶ Represents a logical HTTP service.
A service is a collection of HTTP resources that are available from a ephemeral port that the service sets up when it is created. When the instance is create, it is isolated behind a unique socket (and port number). The
url_for()
method will construct and return a URL containing the ephemeral port number and host name so that you can easily interact with the service.A service is a collection of related endpoints that is attached to a specific port on localhost. It is responsible for keeping track of the programmed responses and dispatching them when requests come in. Responses are added to the service with
add_response()
. They will be returned from the request handler associated with a resource path in the order that they are added.Note that you should not create
Service
instances yourself. If you do, they will not be wired into the Tornado framework appropriately. Instead, you should create aServiceLayer
instance and callServiceLayer.get_service()
to create services.-
add_endpoint
(*path)[source]¶ Add an endpoint without configuring a response.
- Parameters
path – resource path
You only need to call this method if you want to create a resource without configuring a response. Otherwise, you should call
add_response()
which will create the resource if necessary.
-
assert_request
(method, *path, **query)[source]¶ Assert that a specific request was made to the service.
- Parameters
method (str) – the HTTP method to match
path – the resource to match
query – optional query parameters to match
- Raises
AssertionError
if no matching request to the service is found
-
get_next_response
(tornado_request)[source]¶ Retrieve the next response for a request.
- Parameters
tornado_request (tornado.httputil.HTTPRequest) –
Responses are matched to the request using the request method and URI as a key. If a response was registered for the method and URI, then it is popped and returned. If there is no response configured for tornado_request, then an exception is raised.
- Raises
tornado.web.HTTPError – with a status of 456 if the service doesn’t have a response configured for tornado_request
-
get_requests_for
(*path)[source]¶ Retrieve the requests made for path.
- Parameters
path – resource to retrieve requests for
This method returns the requests made for path in the order that they were made. It uses a generator to return value values so either call it in a loop or use
next()
to get the first value.- Returns
Request
instances made for the resource by way of a generator- Raises
AssertionError
if no requests were made for the resource
-
Request¶
Example Test¶
"""
Example of using glinda to test a service.
This file is an example of using the ``glinda.testing`` package to
test a very simple Tornado application.
"""
import os
from tornado import gen, httpclient, web
import tornado.testing
from glinda.testing import services
class MyHandler(web.RequestHandler):
"""
Simple handler that makes a few asynchronous requests.
The "backing" service is configured by setting the ``SERVICE_NETLOC``
environment variable to the host and port of the service endpoint.
This is pretty typical for 12 Factor Applications and it makes it
very easy to test the service using ``glinda``.
"""
@gen.coroutine
def get(self):
netloc = os.environ['SERVICE_NETLOC']
client = httpclient.AsyncHTTPClient()
try:
yield client.fetch('http://{0}/status'.format(netloc),
method='HEAD')
except web.HTTPError as error:
if error.code >= 300:
raise web.HTTPError(504)
try:
response = yield client.fetch('http://{0}/do-stuff'.format(netloc),
method='POST',
body='important stuff')
except web.HTTPError as error:
if error.code >= 300:
raise web.HTTPError(500)
self.set_status(200)
self.set_header('Custom', response.headers.get('Custom', ''))
self.write(response.body)
self.finish()
class HandlerTests(tornado.testing.AsyncHTTPTestCase):
"""Traditional :class:`tornado.testing.AsyncHTTPTestCase`"""
def setUp(self):
super(HandlerTests, self).setUp()
service_layer = services.ServiceLayer()
self.external_service = service_layer['status']
os.environ['SERVICE_NETLOC'] = self.external_service.host
def get_app(self):
return web.Application([web.url('/do-the-things', MyHandler)])
def test_that_status_failure_results_in_504(self):
self.external_service.add_response(
services.Request('HEAD', '/status'), services.Response(500))
try:
self.fetch('/do-the-things')
except web.HTTPError as error:
self.assertEqual(error.status_code, 504)
def test_that_service_failure_results_in_500(self):
self.external_service.add_response(
services.Request('HEAD', '/status'), services.Response(200))
self.external_service.add_response(
services.Request('POST', '/do-stuff'), services.Response(400))
try:
self.fetch('/do-the-things')
except web.HTTPError as error:
self.assertEqual(error.status_code, 500)
def test_that_status_is_fetched(self):
self.external_service.add_response(
services.Request('HEAD', '/status'), services.Response(200))
self.external_service.add_response(
services.Request('POST', '/do-stuff'), services.Response(200))
self.fetch('/do-the-things')
self.external_service.assert_request('HEAD', '/status')
def test_that_stuff_is_posted(self):
self.external_service.add_response(
services.Request('HEAD', '/status'), services.Response(200))
self.external_service.add_response(
services.Request('POST', '/do-stuff'), services.Response(200))
self.fetch('/do-the-things')
request = self.external_service.get_request('do-stuff')
self.assertEqual(request.body, b'important stuff')
def test_that_post_response_is_preserved(self):
self.external_service.add_response(
services.Request('HEAD', '/status'), services.Response(200))
self.external_service.add_response(
services.Request('POST', '/do-stuff'),
services.Response(200, body='foo', headers={'Custom': 'header'}))
response = self.fetch('/do-the-things')
self.assertEqual(response.body.decode(), 'foo')
self.assertEqual(response.headers['Custom'], 'header')
Hacking on this Code¶
I’m always open to contributions from others provided that the are not completely worthless. I do request that you follow a few basic rules though.
(Almost) All code is tested: if you think that your contributions do not require tests for whatever reason, please justify it.
All tests pass. This is non-negotiable. If you cannot get a particular test case to pass. Submit the PR on github and we can probably work through it. What I won’t do is accept code that breaks a pile of tests without any effort to fix them. Run
env/bin/tox
to run the tests.Features are documented. I use sphinx for all documentation. Edit the documentation sources as required and make sure that running
python setup.py build_sphinx
produces something reasonable.
I apologize for being pretty opinionated about this but code quality doesn’t disappear suddenly, it continually degrades. This set of minimum requirements is my attempt to help prevent quality issues.
Getting Started¶
Congratulations for not being immediately put off by my attitude ;) Let’s
get started. First thing is to create a new sandbox for you to play in.
If you are using Python 3.4 or newer, use pyvenv env
to create a new
virtual environment in the env directory. Otherwise, install
virtualenv and run virtualenv env
to create the environment.
PLEASE DO NOT USE SUDO! DO NOT INSTALL INTO YOUR SYSTEM ENVIRONMENT!!
If you did not chuckle at the previous line, then you really do need to pay careful attention to it. Use virtual environments for developing code.
Once you have an environment, install the requirements listed in dev-requirements.txt. This is a pip-formatted requirements file that will pull in packages that the code depends on as well as packages required to test, document, and generally hack on the code base.
TL;DR
$ python3 -mvenv env
$ env/bin/pip install -r dev-requirements.txt
Running Tests¶
Tests are written using the standard unittest package and run using nose and tox.
$ env/bin/tox
Documentation¶
All documentation is written in ReStructuredText with HTML generated by the sphinx utility.
$ env/bin/python setup.py build_sphinx
The HTML output is written to build/sphinx/html.
Changelog¶
1.0.1 (27 Jun 2019)
Fix errant usage of
tornado.web.ErrorHandler
1.0.0 (22 Apr 2019)
Adjust supported Python versions
Add support for 3.5, 3.6, & 3.7
Drop support for 3.3, & 3.4
Adjust supported Tornado versions
Add support for 4.5, 5, and 6
Drop support for 3-4.5
0.1.0 (3 Jul 2017)
Modify content negotiation to 406 when asked for an unknown character set
Add directory of examples
Remove support for tornado newer than 4.5. This is tempory due to changes in the Tornado API.
Change Application.add_resource so that it inserts handlers before the default error handler instead of after.
0.0.3 (30 May 2015)
0.0.2 (29 May 2015)
Make testing layer actually return the headers and bodies that are programmatically added via
glinda.testing.services.Response
.
0.0.1 (21 May 2015)