Merge branch 'master' into forbidden-exception

This commit is contained in:
François 2017-06-28 17:25:40 +02:00 committed by GitHub
commit 76e62779ba
11 changed files with 305 additions and 96 deletions

View File

@ -1,75 +0,0 @@
# Cookies
Cookies are pieces of data which persist inside a user's browser. Sanic can
both read and write cookies, which are stored as key-value pairs.
## Reading cookies
A user's cookies can be accessed via the `Request` object's `cookies` dictionary.
```python
from sanic.response import text
@app.route("/cookie")
async def test(request):
test_cookie = request.cookies.get('test')
return text("Test cookie set to: {}".format(test_cookie))
```
## Writing cookies
When returning a response, cookies can be set on the `Response` object.
```python
from sanic.response import text
@app.route("/cookie")
async def test(request):
response = text("There's a cookie up in this response")
response.cookies['test'] = 'It worked!'
response.cookies['test']['domain'] = '.gotta-go-fast.com'
response.cookies['test']['httponly'] = True
return response
```
## Deleting cookies
Cookies can be removed semantically or explicitly.
```python
from sanic.response import text
@app.route("/cookie")
async def test(request):
response = text("Time to eat some cookies muahaha")
# This cookie will be set to expire in 0 seconds
del response.cookies['kill_me']
# This cookie will self destruct in 5 seconds
response.cookies['short_life'] = 'Glad to be here'
response.cookies['short_life']['max-age'] = 5
del response.cookies['favorite_color']
# This cookie will remain unchanged
response.cookies['favorite_color'] = 'blue'
response.cookies['favorite_color'] = 'pink'
del response.cookies['favorite_color']
return response
```
Response cookies can be set like dictionary values and have the following
parameters available:
- `expires` (datetime): The time for the cookie to expire on the
client's browser.
- `path` (string): The subset of URLs to which this cookie applies. Defaults to /.
- `comment` (string): A comment (metadata).
- `domain` (string): Specifies the domain for which the cookie is valid. An
explicitly specified domain must always start with a dot.
- `max-age` (number): Number of seconds the cookie should live for.
- `secure` (boolean): Specifies whether the cookie will only be sent via
HTTPS.
- `httponly` (boolean): Specifies whether the cookie cannot be read by
Javascript.

87
docs/sanic/cookies.rst Normal file
View File

@ -0,0 +1,87 @@
Cookies
=======
Cookies are pieces of data which persist inside a user's browser. Sanic can
both read and write cookies, which are stored as key-value pairs.
.. warning::
Cookies can be freely altered by the client. Therefore you cannot just store
data such as login information in cookies as-is, as they can be freely altered
by the client. To ensure data you store in cookies is not forged or tampered
with by the client, use something like `itsdangerous`_ to cryptographically
sign the data.
Reading cookies
---------------
A user's cookies can be accessed via the ``Request`` object's ``cookies`` dictionary.
.. code-block:: python
from sanic.response import text
@app.route("/cookie")
async def test(request):
test_cookie = request.cookies.get('test')
return text("Test cookie set to: {}".format(test_cookie))
Writing cookies
---------------
When returning a response, cookies can be set on the ``Response`` object.
.. code-block:: python
from sanic.response import text
@app.route("/cookie")
async def test(request):
response = text("There's a cookie up in this response")
response.cookies['test'] = 'It worked!'
response.cookies['test']['domain'] = '.gotta-go-fast.com'
response.cookies['test']['httponly'] = True
return response
Deleting cookies
----------------
Cookies can be removed semantically or explicitly.
.. code-block:: python
from sanic.response import text
@app.route("/cookie")
async def test(request):
response = text("Time to eat some cookies muahaha")
# This cookie will be set to expire in 0 seconds
del response.cookies['kill_me']
# This cookie will self destruct in 5 seconds
response.cookies['short_life'] = 'Glad to be here'
response.cookies['short_life']['max-age'] = 5
del response.cookies['favorite_color']
# This cookie will remain unchanged
response.cookies['favorite_color'] = 'blue'
response.cookies['favorite_color'] = 'pink'
del response.cookies['favorite_color']
return response
Response cookies can be set like dictionary values and have the following
parameters available:
- ``expires`` (datetime): The time for the cookie to expire on the client's browser.
- ``path`` (string): The subset of URLs to which this cookie applies. Defaults to /.
- ``comment`` (string): A comment (metadata).
- ``domain`` (string): Specifies the domain for which the cookie is valid. An
explicitly specified domain must always start with a dot.
- ``max-age`` (number): Number of seconds the cookie should live for.
- ``secure`` (boolean): Specifies whether the cookie will only be sent via HTTPS.
- ``httponly`` (boolean): Specifies whether the cookie cannot be read by Javascript.
.. _itsdangerous: https://pythonhosted.org/itsdangerous/

View File

@ -57,6 +57,12 @@ for Gunicorn `worker-class` argument:
gunicorn myapp:app --bind 0.0.0.0:1337 --worker-class sanic.worker.GunicornWorker gunicorn myapp:app --bind 0.0.0.0:1337 --worker-class sanic.worker.GunicornWorker
``` ```
If your application suffers from memory leaks, you can configure Gunicorn to gracefully restart a worker
after it has processed a given number of requests. This can be a convenient way to help limit the effects
of the memory leak.
See the [Gunicorn Docs](http://docs.gunicorn.org/en/latest/settings.html#max-requests) for more information.
## Asynchronous support ## Asynchronous support
This is suitable if you *need* to share the sanic process with other applications, in particular the `loop`. This is suitable if you *need* to share the sanic process with other applications, in particular the `loop`.
However be advised that this method does not support using multiple processes, and is not the preferred way However be advised that this method does not support using multiple processes, and is not the preferred way

View File

@ -201,4 +201,10 @@ class Config(dict):
for k, v in os.environ.items(): for k, v in os.environ.items():
if k.startswith(SANIC_PREFIX): if k.startswith(SANIC_PREFIX):
_, config_key = k.split(SANIC_PREFIX, 1) _, config_key = k.split(SANIC_PREFIX, 1)
self[config_key] = v try:
self[config_key] = int(v)
except ValueError:
try:
self[config_key] = float(v)
except ValueError:
self[config_key] = v

View File

@ -203,6 +203,34 @@ class InvalidRangeType(ContentRangeError):
pass pass
@add_status_code(401)
class Unauthorized(SanicException):
"""
Unauthorized exception (401 HTTP status code).
:param scheme: Name of the authentication scheme to be used.
:param realm: Description of the protected area. (optional)
:param challenge: A dict containing values to add to the WWW-Authenticate
header that is generated. This is especially useful when dealing with the
Digest scheme. (optional)
"""
pass
def __init__(self, message, scheme, realm="", challenge=None):
super().__init__(message)
adds = ""
if challenge is not None:
values = ["{!s}={!r}".format(k, v) for k, v in challenge.items()]
adds = ', '.join(values)
adds = ', {}'.format(adds)
self.headers = {
"WWW-Authenticate": "{} realm='{}'{}".format(scheme, realm, adds)
}
def abort(status_code, message=None): def abort(status_code, message=None):
""" """
Raise an exception based on SanicException. Returns the HTTP response Raise an exception based on SanicException. Returns the HTTP response

View File

@ -86,11 +86,15 @@ class Request(dict):
:return: token related to request :return: token related to request
""" """
prefixes = ('Token ', 'Bearer ')
auth_header = self.headers.get('Authorization') auth_header = self.headers.get('Authorization')
if auth_header is not None and 'Token ' in auth_header:
return auth_header.partition('Token ')[-1] if auth_header is not None:
else: for prefix in prefixes:
return auth_header if prefix in auth_header:
return auth_header.partition(prefix)[-1]
return auth_header
@property @property
def form(self): def form(self):

View File

@ -75,7 +75,7 @@ class HttpProtocol(asyncio.Protocol):
signal=Signal(), connections=set(), request_timeout=60, signal=Signal(), connections=set(), request_timeout=60,
request_max_size=None, request_class=None, has_log=True, request_max_size=None, request_class=None, has_log=True,
keep_alive=True, is_request_stream=False, router=None, keep_alive=True, is_request_stream=False, router=None,
**kwargs): state=None, debug=False, **kwargs):
self.loop = loop self.loop = loop
self.transport = None self.transport = None
self.request = None self.request = None
@ -99,12 +99,17 @@ class HttpProtocol(asyncio.Protocol):
self._request_handler_task = None self._request_handler_task = None
self._request_stream_task = None self._request_stream_task = None
self._keep_alive = keep_alive self._keep_alive = keep_alive
self.state = state if state else {}
if 'requests_count' not in self.state:
self.state['requests_count'] = 0
self._debug = debug
@property @property
def keep_alive(self): def keep_alive(self):
return (self._keep_alive return (
and not self.signal.stopped self._keep_alive and
and self.parser.should_keep_alive()) not self.signal.stopped and
self.parser.should_keep_alive())
# -------------------------------------------- # # -------------------------------------------- #
# Connection # Connection
@ -154,11 +159,17 @@ class HttpProtocol(asyncio.Protocol):
self.headers = [] self.headers = []
self.parser = HttpRequestParser(self) self.parser = HttpRequestParser(self)
# requests count
self.state['requests_count'] = self.state['requests_count'] + 1
# Parse request chunk or close connection # Parse request chunk or close connection
try: try:
self.parser.feed_data(data) self.parser.feed_data(data)
except HttpParserError: except HttpParserError:
exception = InvalidUsage('Bad Request') message = 'Bad Request'
if self._debug:
message += '\n' + traceback.format_exc()
exception = InvalidUsage(message)
self.write_error(exception) self.write_error(exception)
def on_url(self, url): def on_url(self, url):
@ -389,7 +400,7 @@ def serve(host, port, request_handler, error_handler, before_start=None,
register_sys_signals=True, run_async=False, connections=None, register_sys_signals=True, run_async=False, connections=None,
signal=Signal(), request_class=None, has_log=True, keep_alive=True, signal=Signal(), request_class=None, has_log=True, keep_alive=True,
is_request_stream=False, router=None, websocket_max_size=None, is_request_stream=False, router=None, websocket_max_size=None,
websocket_max_queue=None): websocket_max_queue=None, state=None):
"""Start asynchronous HTTP Server on an individual process. """Start asynchronous HTTP Server on an individual process.
:param host: Address to host on :param host: Address to host on
@ -427,8 +438,6 @@ def serve(host, port, request_handler, error_handler, before_start=None,
if debug: if debug:
loop.set_debug(debug) loop.set_debug(debug)
trigger_events(before_start, loop)
connections = connections if connections is not None else set() connections = connections if connections is not None else set()
server = partial( server = partial(
protocol, protocol,
@ -445,7 +454,9 @@ def serve(host, port, request_handler, error_handler, before_start=None,
is_request_stream=is_request_stream, is_request_stream=is_request_stream,
router=router, router=router,
websocket_max_size=websocket_max_size, websocket_max_size=websocket_max_size,
websocket_max_queue=websocket_max_queue websocket_max_queue=websocket_max_queue,
state=state,
debug=debug,
) )
server_coroutine = loop.create_server( server_coroutine = loop.create_server(
@ -457,6 +468,7 @@ def serve(host, port, request_handler, error_handler, before_start=None,
sock=sock, sock=sock,
backlog=backlog backlog=backlog
) )
# Instead of pulling time at the end of every request, # Instead of pulling time at the end of every request,
# pull it once per minute # pull it once per minute
loop.call_soon(partial(update_current_time, loop)) loop.call_soon(partial(update_current_time, loop))
@ -464,6 +476,8 @@ def serve(host, port, request_handler, error_handler, before_start=None,
if run_async: if run_async:
return server_coroutine return server_coroutine
trigger_events(before_start, loop)
try: try:
http_server = loop.run_until_complete(server_coroutine) http_server = loop.run_until_complete(server_coroutine)
except: except:

View File

@ -29,7 +29,7 @@ class GunicornWorker(base.Worker):
self.ssl_context = self._create_ssl_context(cfg) self.ssl_context = self._create_ssl_context(cfg)
else: else:
self.ssl_context = None self.ssl_context = None
self.servers = [] self.servers = {}
self.connections = set() self.connections = set()
self.exit_code = 0 self.exit_code = 0
self.signal = Signal() self.signal = Signal()
@ -96,11 +96,16 @@ class GunicornWorker(base.Worker):
async def _run(self): async def _run(self):
for sock in self.sockets: for sock in self.sockets:
self.servers.append(await serve( state = dict(requests_count=0)
self._server_settings["host"] = None
self._server_settings["port"] = None
server = await serve(
sock=sock, sock=sock,
connections=self.connections, connections=self.connections,
state=state,
**self._server_settings **self._server_settings
)) )
self.servers[server] = state
async def _check_alive(self): async def _check_alive(self):
# If our parent changed then we shut down. # If our parent changed then we shut down.
@ -109,7 +114,15 @@ class GunicornWorker(base.Worker):
while self.alive: while self.alive:
self.notify() self.notify()
if pid == os.getpid() and self.ppid != os.getppid(): req_count = sum(
self.servers[srv]["requests_count"] for srv in self.servers
)
if self.max_requests and req_count > self.max_requests:
self.alive = False
self.log.info(
"Max requests exceeded, shutting down: %s", self
)
elif pid == os.getpid() and self.ppid != os.getppid():
self.alive = False self.alive = False
self.log.info("Parent changed, shutting down: %s", self) self.log.info("Parent changed, shutting down: %s", self)
else: else:
@ -166,3 +179,4 @@ class GunicornWorker(base.Worker):
self.alive = False self.alive = False
self.exit_code = 1 self.exit_code = 1
self.cfg.worker_abort(self) self.cfg.worker_abort(self)
sys.exit(1)

View File

@ -3,8 +3,8 @@ from bs4 import BeautifulSoup
from sanic import Sanic from sanic import Sanic
from sanic.response import text from sanic.response import text
from sanic.exceptions import InvalidUsage, ServerError, NotFound, Forbidden from sanic.exceptions import InvalidUsage, ServerError, NotFound, Unauthorized
from sanic.exceptions import abort from sanic.exceptions import Forbidden, abort
class SanicExceptionTestException(Exception): class SanicExceptionTestException(Exception):
@ -31,6 +31,20 @@ def exception_app():
def handler_403(request): def handler_403(request):
raise Forbidden("Forbidden") raise Forbidden("Forbidden")
@app.route('/401/basic')
def handler_401_basic(request):
raise Unauthorized("Unauthorized", "Basic", "Sanic")
@app.route('/401/digest')
def handler_401_digest(request):
challenge = {
"qop": "auth, auth-int",
"algorithm": "MD5",
"nonce": "abcdef",
"opaque": "zyxwvu",
}
raise Unauthorized("Unauthorized", "Digest", "Sanic", challenge)
@app.route('/invalid') @app.route('/invalid')
def handler_invalid(request): def handler_invalid(request):
raise InvalidUsage("OK") raise InvalidUsage("OK")
@ -54,8 +68,10 @@ def exception_app():
return app return app
def test_catch_exception_list(): def test_catch_exception_list():
app = Sanic('exception_list') app = Sanic('exception_list')
@app.exception([SanicExceptionTestException, NotFound]) @app.exception([SanicExceptionTestException, NotFound])
def exception_list(request, exception): def exception_list(request, exception):
return text("ok") return text("ok")
@ -101,6 +117,25 @@ def test_forbidden_exception(exception_app):
request, response = exception_app.test_client.get('/403') request, response = exception_app.test_client.get('/403')
assert response.status == 403 assert response.status == 403
def test_unauthorized_exception(exception_app):
"""Test the built-in Unauthorized exception"""
request, response = exception_app.test_client.get('/401/basic')
assert response.status == 401
assert response.headers.get('WWW-Authenticate') is not None
assert response.headers.get('WWW-Authenticate') == "Basic realm='Sanic'"
request, response = exception_app.test_client.get('/401/digest')
assert response.status == 401
auth_header = response.headers.get('WWW-Authenticate')
assert auth_header is not None
assert auth_header.startswith('Digest')
assert "qop='auth, auth-int'" in auth_header
assert "algorithm='MD5'" in auth_header
assert "nonce='abcdef'" in auth_header
assert "opaque='zyxwvu'" in auth_header
def test_handled_unhandled_exception(exception_app): def test_handled_unhandled_exception(exception_app):
"""Test that an exception not built into sanic is handled""" """Test that an exception not built into sanic is handled"""

View File

@ -180,6 +180,16 @@ def test_token():
request, response = app.test_client.get('/', headers=headers) request, response = app.test_client.get('/', headers=headers)
assert request.token == token
token = 'a1d895e0-553a-421a-8e22-5ff8ecb48cbf'
headers = {
'content-type': 'application/json',
'Authorization': 'Bearer {}'.format(token)
}
request, response = app.test_client.get('/', headers=headers)
assert request.token == token assert request.token == token
# no Authorization headers # no Authorization headers

View File

@ -3,7 +3,11 @@ import json
import shlex import shlex
import subprocess import subprocess
import urllib.request import urllib.request
from unittest import mock
from sanic.worker import GunicornWorker
from sanic.app import Sanic
import asyncio
import logging
import pytest import pytest
@ -20,3 +24,79 @@ def test_gunicorn_worker(gunicorn_worker):
with urllib.request.urlopen('http://localhost:1337/') as f: with urllib.request.urlopen('http://localhost:1337/') as f:
res = json.loads(f.read(100).decode()) res = json.loads(f.read(100).decode())
assert res['test'] assert res['test']
class GunicornTestWorker(GunicornWorker):
def __init__(self):
self.app = mock.Mock()
self.app.callable = Sanic("test_gunicorn_worker")
self.servers = {}
self.exit_code = 0
self.cfg = mock.Mock()
self.notify = mock.Mock()
@pytest.fixture
def worker():
return GunicornTestWorker()
def test_worker_init_process(worker):
with mock.patch('sanic.worker.asyncio') as mock_asyncio:
try:
worker.init_process()
except TypeError:
pass
assert mock_asyncio.get_event_loop.return_value.close.called
assert mock_asyncio.new_event_loop.called
assert mock_asyncio.set_event_loop.called
def test_worker_init_signals(worker):
worker.loop = mock.Mock()
worker.init_signals()
assert worker.loop.add_signal_handler.called
def test_handle_abort(worker):
with mock.patch('sanic.worker.sys') as mock_sys:
worker.handle_abort(object(), object())
assert not worker.alive
assert worker.exit_code == 1
mock_sys.exit.assert_called_with(1)
def test_handle_quit(worker):
worker.handle_quit(object(), object())
assert not worker.alive
assert worker.exit_code == 0
def test_run_max_requests_exceeded(worker):
loop = asyncio.new_event_loop()
worker.ppid = 1
worker.alive = True
sock = mock.Mock()
sock.cfg_addr = ('localhost', 8080)
worker.sockets = [sock]
worker.wsgi = mock.Mock()
worker.connections = set()
worker.log = mock.Mock()
worker.loop = loop
worker.servers = {
"server1": {"requests_count": 14},
"server2": {"requests_count": 15},
}
worker.max_requests = 10
worker._run = mock.Mock(wraps=asyncio.coroutine(lambda *a, **kw: None))
# exceeding request count
_runner = asyncio.ensure_future(worker._check_alive(), loop=loop)
loop.run_until_complete(_runner)
assert worker.alive == False
worker.notify.assert_called_with()
worker.log.info.assert_called_with("Max requests exceeded, shutting down: %s",
worker)