feat(Response): Add header attributes for setting Last-Modified, Retry-After, Location, Content-Location, and Content-Range

Closes #7
This commit is contained in:
Kurt Griffiths
2013-02-06 16:04:47 -05:00
parent bb01a48e0d
commit 218d80c640
8 changed files with 199 additions and 31 deletions

View File

@@ -5,7 +5,7 @@ Falcon [![Build Status](https://travis-ci.org/racker/falcon.png)](https://travis
**[Experimental]**
Falcon is a *really* fast, light-weight framework for building cloud APIs. It tries to do as little as possible while remaining highly effective.
Falcon is a high-performance Python framework for building cloud APIs. It tries to do as little as possible while remaining highly effective.
> Perfection is finally attained not when there is no longer anything to add, but when there is no longer anything to take away.
>
@@ -17,7 +17,9 @@ X is cool, and everyone is using it, so why mess with a good thing?
1. Unlike other Python web frameworks, Falcon won't bottleneck your API's performance. Most frameworks max out at serving simple "hello world" requests at a few thousand req/sec, while Falcon can easily serve 10 times as many on the same hardware. Even Bottle, one of the fastest frameworks we tested, takes twice as long to serve requests compared to Falcon.
2. Falcon isn't very opinionated. In other words, the framework leaves a lot of decisions and implementation details to you, the API developer, which means you will need to use your head a little more than other frameworks, and probably write a little more code. On the other hand, this gives you a lot of freedom to customize and tune your implementation in order to create a solution that stands out from the crowd.
3. I have no idea what you're talking about, but [here's a bunny][bunny] with a pancake on its head.
3. Falcon doesn't include a lot of cruft that is simply unnecessary when building web services. Less code and fewer dependencies means a smaller attack surface, lower memory usage, and fewer places for bugs to hide.
If you're still not convinced, check out this [bunny][bunny] with a pancake on its head.
[bunny]: http://www.thepartyanimal-blog.org/wp-content/uploads/2012/04/pancake_bunny.jpg
@@ -27,7 +29,7 @@ X is cool, and everyone is using it, so why mess with a good thing?
**Light.** Only the essentials are included, with the "six" Python 3 compatibility module being the only dependency outside the standard library. We work to keep the code lean and mean, making Falcon easier to test, optimize, and deploy.
**Cloudy.** Falcon uses the web-friendly Python language and speaks WSGI. Built-in diagnostics facilitate monitoring and debugging of production systems.
**Flexible.** Falcon uses the web-friendly Python language and speaks WSGI. Built-in diagnostics facilitate monitoring and debugging of production systems.
### Install ###

View File

@@ -1,9 +1,10 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import sys
import random
import argparse
from timeit import repeat
import timeit
from create import *
@@ -16,9 +17,9 @@ def avg(array):
return sum(array) / len(array)
def bench(name, iterations=10000):
def bench(name, iterations=10000, repeat=5):
func = create_bench(name)
results = repeat(func, number=iterations)
results = timeit.repeat(func, number=iterations, repeat=repeat)
sec_per_req = avg(results) / iterations
@@ -50,7 +51,8 @@ if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Falcon benchmark runner")
parser.add_argument('-b', '--benchmark', type=str, action='append',
choices=frameworks, dest='frameworks')
parser.add_argument('-i', '--iterations', type=int, default=10000)
parser.add_argument('-i', '--iterations', type=int, default=100000)
parser.add_argument('-r', '--repetitions', type=int, default=10)
args = parser.parse_args()
if args.frameworks:
@@ -63,7 +65,8 @@ if __name__ == '__main__':
sys.stdout.write('\nBenchmarking')
sys.stdout.flush()
results = [bench(framework, args.iterations) for framework in frameworks]
results = [bench(framework, args.iterations, args.repetitions)
for framework in frameworks]
print('done.\n')
results = sorted(results, key=lambda r: r[1])
@@ -72,9 +75,9 @@ if __name__ == '__main__':
for i, (name, sec_per_req) in enumerate(results):
req_per_sec = 1 / sec_per_req
ms_per_req = sec_per_req * 1000
factor = int(baseline / sec_per_req)
factor = int(baseline / sec_per_req + 0.1)
print('{3}. {0:.<15s}{1:.>06,.0f} req/sec or {2:0.3f} ms/req ({4}x)'.
format(name, req_per_sec, ms_per_req, i + 1, factor))
print('{3}. {0:.<15s}{1:.>06,.0f} req/sec or {2:0.1f} μs/req ({4}x)'.
format(name, req_per_sec, ms_per_req * 1000, i + 1, factor))
print('')

View File

@@ -22,17 +22,18 @@ del sys.path[-1]
def create_falcon(body, headers):
path = '/hello/{account_id}/test'
falcon_app = falcon.API()
falcon_app = falcon.API('text/plain')
class HelloResource:
def on_get(self, req, resp, account_id):
limit = req.get_param('limit', '10')
limit = req.get_param('limit', '10') # NOQA
if six.PY3:
resp.body = body
else:
resp.data = body
resp.set_header('Content-Type', 'text/plain')
resp.vary = ['accept-encoding', 'x-auth-token']
resp.content_range = (0, 499, 10240)
resp.set_headers(headers)
falcon_app.add_route(path, HelloResource())
@@ -47,7 +48,7 @@ def create_wheezy(body, headers):
try:
limit = query['limit']
except KeyError:
limit = '10'
limit = '10' # NOQA
response = wheezy.HTTPResponse(content_type='text/plain')
response.write_bytes(body)
@@ -97,7 +98,7 @@ def create_bottle(body, headers):
@bottle.route(path)
def hello(account_id):
limit = bottle.request.query.limit or '10'
limit = bottle.request.query.limit or '10' # NOQA
return bottle.Response(body, headers=headers)
return bottle.default_app()
@@ -109,7 +110,7 @@ def create_werkzeug(body, headers):
@werkzeug.Request.application
def hello(request):
limit = request.args.get('limit', '10')
limit = request.args.get('limit', '10') # NOQA
adapter = url_map.bind_to_environ(request.environ)
endpoint, values = adapter.match()
return werkzeug.Response(body, headers=headers,

View File

@@ -22,3 +22,4 @@ from falcon.api import API, DEFAULT_MEDIA_TYPE
from falcon.status_codes import *
from falcon.exceptions import *
from falcon.http_error import HTTPError
from falcon.util import dt_to_http

View File

@@ -16,6 +16,8 @@ limitations under the License.
"""
import falcon
CONTENT_TYPE_NAMES = set(['Content-Type', 'content-type', 'CONTENT-TYPE'])
@@ -25,18 +27,48 @@ class Response(object):
Attributes:
status: HTTP status code, such as "200 OK" (see also falcon.HTTP_*)
body: String representing response content. If Unicode, Falcon will
encode as UTF-8 in the response. If data is already a byte string,
use the data attribute instead (it's faster).
data: Byte string representing response content.
stream: Iterable stream-like object, representing response content.
stream_len: Expected length of stream (e.g., file size).
content_type: Value for the Content-Type header
etag: Value for the ETag header
cache_control: An array of cache directives (see http://goo.gl/fILS5
and http://goo.gl/sM9Xx for a good description.) The array will be
joined with ', ' to produce the value for the Cache-Control
header.
last_modified: A datetime (UTC) instance to use as the Last-Modified
header. Falcon will format the datetime as an HTTP date. See
also: http://goo.gl/R7So4
retry_after: Number of seconds to use as the value for the Retry-After
header. Note that the HTTP-date option is not supported. See
also: http://goo.gl/DIrWr
vary: Value to use for the Vary header. From Wikipedia: "Tells
downstream proxies how to match future request headers to decide
whether the cached response can be used rather than requesting a
fresh one from the origin server." See also: http://goo.gl/NGHdL
Assumed to be an array of values. For a single asterisk or field
value, simply pass a single-element array.
location: Value for the Location header. Note that relative URIs are
OK per http://goo.gl/DbVqR
content_location: Value for the Content-Location header. See
also: http://goo.gl/1slsA
content_range: A tuple to use in constructing a value for the
Content-Range header. The tuple has the form (start, end, length),
where start and end is the inclusive byte range, and length is the
total number of bytes, or '*' if unknown.
Note: You only need to use the alternate form, "bytes */1234", for
responses that use the status "416 Range Not Satisfiable". In this
case, raising falcon.HTTPRangeNotSatisfiable will do the right
thing.
See also: http://goo.gl/Iglhp
"""
@@ -44,13 +76,19 @@ class Response(object):
__slots__ = (
'body',
'cache_control',
'content_location',
'content_range',
'content_type',
'data',
'etag',
'_headers',
'last_modified',
'location',
'retry_after',
'status',
'stream',
'stream_len'
'stream_len',
'vary'
)
def __init__(self, default_media_type):
@@ -72,6 +110,12 @@ class Response(object):
self.content_type = None
self.etag = None
self.cache_control = None
self.last_modified = None
self.retry_after = None
self.vary = None
self.location = None
self.content_location = None
self.content_range = None
def set_header(self, name, value):
"""Set a header for this response to a given value.
@@ -108,6 +152,40 @@ class Response(object):
self._headers.extend(headers.items())
def _append_attribute_headers(self, headers): # NOQA
if self.etag is not None:
headers.append(('ETag', self.etag))
if self.cache_control is not None:
headers.append(('Cache-Control', ', '.join(self.cache_control)))
if self.last_modified is not None:
headers.append(('Last-Modified',
falcon.dt_to_http(self.last_modified)))
if self.retry_after is not None:
headers.append(('Retry-After', str(self.retry_after)))
if self.vary is not None:
headers.append(('Vary', ', '.join(self.vary)))
if self.location is not None:
headers.append(('Location', self.location))
if self.content_location is not None:
headers.append(('Content-Location', self.content_location))
content_range = self.content_range
if content_range is not None:
# PERF: Concatenation is faster than % string formatting as well
# as ''.join() in this case.
formatted_range = ('bytes ' +
str(content_range[0]) + '-' +
str(content_range[1]) + '/' +
str(content_range[2]))
headers.append(('Content-Range', formatted_range))
def _wsgi_headers(self, set_content_type):
"""Convert headers into the format expected by WSGI servers
@@ -122,10 +200,6 @@ class Response(object):
elif self.content_type is not None:
headers.append(('Content-Type', self.content_type))
if self.etag is not None:
headers.append(('ETag', self.etag))
if self.cache_control is not None:
headers.append(('Cache-Control', ', '.join(self.cache_control)))
self._append_attribute_headers(headers)
return headers

33
falcon/util.py Normal file
View File

@@ -0,0 +1,33 @@
"""Defines Falcon utility functions
Copyright 2013 by Rackspace Hosting, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
def dt_to_http(dt):
"""Converts a datetime instance to an HTTP date string.
Args:
dt: A datetime object, assumed to be UTC
Returns:
An HTTP date string, e.g., "Tue, 15 Nov 1994 12:45:26 GMT". See
also: http://goo.gl/R7So4
"""
# Tue, 15 Nov 1994 12:45:26 GMT
return dt.strftime('%a, %d %b %Y %H:%M:%S GMT')

View File

@@ -1,3 +1,5 @@
from datetime import datetime
from testtools.matchers import Contains, Not
import falcon
@@ -34,6 +36,12 @@ class DefaultContentTypeResource:
class HeaderHelpersResource:
def __init__(self, last_modified=None):
if last_modified is not None:
self.last_modified = last_modified
else:
self.last_modified = datetime.utcnow()
def on_get(self, req, resp):
resp.body = "{}"
resp.content_type = 'x-falcon/peregrine'
@@ -43,15 +51,15 @@ class HeaderHelpersResource:
]
resp.etag = 'fa0d1a60ef6616bb28038515c8ea4cb2'
# resp.set_last_modified() # http://goo.gl/M9Fs9
# resp.set_retry_after() # http://goo.gl/DIrWr
# resp.set_vary() # http://goo.gl/wyI7d
resp.last_modified = self.last_modified
resp.retry_after = 3601
# # Relative URI's are OK per http://goo.gl/DbVqR
# resp.set_location('/things/87')
# Relative URI's are OK per http://goo.gl/DbVqR
resp.location = '/things/87'
resp.content_location = '/things/78'
# bytes 0-499/10240
#resp.set_content_range(0, 499, 10 * 1024)
resp.content_range = (0, 499, 10 * 1024)
def on_head(self, req, resp):
# Alias of set_media_type
@@ -60,6 +68,16 @@ class HeaderHelpersResource:
resp.cache_control = ['no-store']
class VaryHeaderResource:
def __init__(self, vary):
self.vary = vary
def on_get(self, req, resp):
resp.body = "{}"
resp.vary = self.vary
class TestHeaders(helpers.TestSuite):
def prepare(self):
@@ -183,7 +201,8 @@ class TestHeaders(helpers.TestSuite):
self.assertIn(('Content-Type', content_type), self.srmock.headers)
def test_response_header_helpers_on_get(self):
self.resource = HeaderHelpersResource()
last_modified = datetime(2013, 1, 1, 10, 30, 30)
self.resource = HeaderHelpersResource(last_modified)
self.api.add_route(self.test_route, self.resource)
self._simulate_request(self.test_route)
@@ -199,6 +218,16 @@ class TestHeaders(helpers.TestSuite):
etag = 'fa0d1a60ef6616bb28038515c8ea4cb2'
self.assertIn(('ETag', etag), self.srmock.headers)
last_modified_http_date = 'Tue, 01 Jan 2013 10:30:30 GMT'
self.assertIn(('Last-Modified', last_modified_http_date),
self.srmock.headers)
self.assertIn(('Retry-After', '3601'), self.srmock.headers)
self.assertIn(('Location', '/things/87'), self.srmock.headers)
self.assertIn(('Content-Location', '/things/78'), self.srmock.headers)
self.assertIn(('Content-Range', 'bytes 0-499/10240'),
self.srmock.headers)
def test_response_header_helpers_on_head(self):
self.resource = HeaderHelpersResource()
self.api.add_route(self.test_route, self.resource)
@@ -209,6 +238,28 @@ class TestHeaders(helpers.TestSuite):
self.assertIn(('Cache-Control', 'no-store'), self.srmock.headers)
def test_vary_star(self):
self.resource = VaryHeaderResource(['*'])
self.api.add_route(self.test_route, self.resource)
self._simulate_request(self.test_route)
self.assertIn(('Vary', '*'), self.srmock.headers)
def test_vary_header(self):
self.resource = VaryHeaderResource(['accept-encoding'])
self.api.add_route(self.test_route, self.resource)
self._simulate_request(self.test_route)
self.assertIn(('Vary', 'accept-encoding'), self.srmock.headers)
def test_vary_headers(self):
self.resource = VaryHeaderResource(['accept-encoding', 'x-auth-token'])
self.api.add_route(self.test_route, self.resource)
self._simulate_request(self.test_route)
vary = 'accept-encoding, x-auth-token'
self.assertIn(('Vary', vary), self.srmock.headers)
def test_no_content_type(self):
self.resource = DefaultContentTypeResource()
self.api.add_route(self.test_route, self.resource)

View File

@@ -1,8 +1,11 @@
[tox]
envlist = py27,py33
deps = -r{toxinidir}/tests/requirements.txt
[testenv]
deps = -r{toxinidir}/tests/requirements.txt
[testenv:py27]
commands = nosetests --with-progressive
[testenv:py33]
commands = nosetests