diff --git a/docs/sanic/config.md b/docs/sanic/config.md index 2152a16c..ab63f7c8 100644 --- a/docs/sanic/config.md +++ b/docs/sanic/config.md @@ -31,10 +31,10 @@ There are several ways how to load configuration. ### From environment variables. -Any variables defined with the `SANIC_` prefix will be applied to the sanic config. For example, setting `SANIC_REQUEST_TIMEOUT` will be loaded by the application automatically. You can pass the `load_vars` boolean to the Sanic constructor to override that: +Any variables defined with the `SANIC_` prefix will be applied to the sanic config. For example, setting `SANIC_REQUEST_TIMEOUT` will be loaded by the application automatically. You can pass the `load_env` boolean to the Sanic constructor to override that: ```python -app = Sanic(load_vars=False) +app = Sanic(load_env=False) ``` ### From an Object diff --git a/docs/sanic/cookies.md b/docs/sanic/cookies.md deleted file mode 100644 index e71bcc47..00000000 --- a/docs/sanic/cookies.md +++ /dev/null @@ -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. diff --git a/docs/sanic/cookies.rst b/docs/sanic/cookies.rst new file mode 100644 index 00000000..c4e0c0a1 --- /dev/null +++ b/docs/sanic/cookies.rst @@ -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/ \ No newline at end of file diff --git a/docs/sanic/extensions.md b/docs/sanic/extensions.md index da49da89..92b61f8c 100644 --- a/docs/sanic/extensions.md +++ b/docs/sanic/extensions.md @@ -23,3 +23,4 @@ A list of Sanic extensions created by the community. - [sanic-prometheus](https://github.com/dkruchinin/sanic-prometheus): Prometheus metrics for Sanic - [Sanic-RestPlus](https://github.com/ashleysommer/sanic-restplus): A port of Flask-RestPlus for Sanic. Full-featured REST API with SwaggerUI generation. - [sanic-transmute](https://github.com/yunstanford/sanic-transmute): A Sanic extension that generates APIs from python function and classes, and also generates Swagger UI/documentation automatically. +- [pytest-sanic](https://github.com/yunstanford/pytest-sanic): A pytest plugin for Sanic. It helps you to test your code asynchronously. diff --git a/docs/sanic/testing.md b/docs/sanic/testing.md index d4f61b4c..b8427a00 100644 --- a/docs/sanic/testing.md +++ b/docs/sanic/testing.md @@ -57,3 +57,71 @@ def test_post_json_request_includes_data(): More information about the available arguments to aiohttp can be found [in the documentation for ClientSession](https://aiohttp.readthedocs.io/en/stable/client_reference.html#client-session). + + +# pytest-sanic + +[pytest-sanic](https://github.com/yunstanford/pytest-sanic) is a pytest plugin, it helps you to test your code asynchronously. +Just write tests like, + +```python +async def test_sanic_db_find_by_id(app): + """ + Let's assume that, in db we have, + { + "id": "123", + "name": "Kobe Bryant", + "team": "Lakers", + } + """ + doc = await app.db["players"].find_by_id("123") + assert doc.name == "Kobe Bryant" + assert doc.team == "Lakers" +``` + +[pytest-sanic](https://github.com/yunstanford/pytest-sanic) also provides some useful fixtures, like loop, unused_port, +test_server, test_client. + +```python +@pytest.yield_fixture +def app(): + app = Sanic("test_sanic_app") + + @app.route("/test_get", methods=['GET']) + async def test_get(request): + return response.json({"GET": True}) + + @app.route("/test_post", methods=['POST']) + async def test_post(request): + return response.json({"POST": True}) + + yield app + + +@pytest.fixture +def test_cli(loop, app, test_client): + return loop.run_until_complete(test_client(app, protocol=WebSocketProtocol)) + + +######### +# Tests # +######### + +async def test_fixture_test_client_get(test_cli): + """ + GET request + """ + resp = await test_cli.get('/test_get') + assert resp.status == 200 + resp_json = await resp.json() + assert resp_json == {"GET": True} + +async def test_fixture_test_client_post(test_cli): + """ + POST request + """ + resp = await test_cli.post('/test_post') + assert resp.status == 200 + resp_json = await resp.json() + assert resp_json == {"POST": True} +``` diff --git a/sanic/config.py b/sanic/config.py index 2d24098c..4af31532 100644 --- a/sanic/config.py +++ b/sanic/config.py @@ -202,4 +202,10 @@ class Config(dict): for k, v in os.environ.items(): if k.startswith(SANIC_PREFIX): _, 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 diff --git a/sanic/exceptions.py b/sanic/exceptions.py index 95e41b4a..1bc55db4 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -194,6 +194,11 @@ class ContentRangeError(SanicException): } +@add_status_code(403) +class Forbidden(SanicException): + pass + + class InvalidRangeType(ContentRangeError): pass diff --git a/sanic/request.py b/sanic/request.py index 29cb83f6..e21b8282 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -86,13 +86,13 @@ class Request(dict): :return: token related to request """ - prefixes = ('Token ', 'Bearer ') + prefixes = ('Bearer', 'Token') auth_header = self.headers.get('Authorization') if auth_header is not None: for prefix in prefixes: if prefix in auth_header: - return auth_header.partition(prefix)[-1] + return auth_header.partition(prefix)[-1].strip() return auth_header diff --git a/sanic/server.py b/sanic/server.py index f4babb89..2ee48688 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -75,7 +75,7 @@ class HttpProtocol(asyncio.Protocol): signal=Signal(), connections=set(), request_timeout=60, request_max_size=None, request_class=None, has_log=True, keep_alive=True, is_request_stream=False, router=None, - state=None, **kwargs): + state=None, debug=False, **kwargs): self.loop = loop self.transport = None self.request = None @@ -99,15 +99,18 @@ class HttpProtocol(asyncio.Protocol): self._request_handler_task = None self._request_stream_task = None self._keep_alive = keep_alive + self._header_fragment = b'' self.state = state if state else {} if 'requests_count' not in self.state: self.state['requests_count'] = 0 + self._debug = debug @property def keep_alive(self): - return (self._keep_alive - and not self.signal.stopped - and self.parser.should_keep_alive()) + return ( + self._keep_alive and + not self.signal.stopped and + self.parser.should_keep_alive()) # -------------------------------------------- # # Connection @@ -164,18 +167,32 @@ class HttpProtocol(asyncio.Protocol): try: self.parser.feed_data(data) except HttpParserError: - exception = InvalidUsage('Bad Request') + message = 'Bad Request' + if self._debug: + message += '\n' + traceback.format_exc() + exception = InvalidUsage(message) self.write_error(exception) def on_url(self, url): - self.url = url + if not self.url: + self.url = url + else: + self.url += url def on_header(self, name, value): - if name == b'Content-Length' and int(value) > self.request_max_size: - exception = PayloadTooLarge('Payload Too Large') - self.write_error(exception) + self._header_fragment += name - self.headers.append((name.decode().casefold(), value.decode())) + if value is not None: + if self._header_fragment == b'Content-Length' \ + and int(value) > self.request_max_size: + exception = PayloadTooLarge('Payload Too Large') + self.write_error(exception) + + self.headers.append( + (self._header_fragment.decode().casefold(), + value.decode())) + + self._header_fragment = b'' def on_headers_complete(self): self.request = self.request_class( @@ -459,7 +476,8 @@ def serve(host, port, request_handler, error_handler, before_start=None, router=router, websocket_max_size=websocket_max_size, websocket_max_queue=websocket_max_queue, - state=state + state=state, + debug=debug, ) server_coroutine = loop.create_server( diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index dcdecabd..45bdab88 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -4,7 +4,7 @@ from bs4 import BeautifulSoup from sanic import Sanic from sanic.response import text from sanic.exceptions import InvalidUsage, ServerError, NotFound, Unauthorized -from sanic.exceptions import abort +from sanic.exceptions import Forbidden, abort class SanicExceptionTestException(Exception): @@ -27,6 +27,10 @@ def exception_app(): def handler_404(request): raise NotFound("OK") + @app.route('/403') + def handler_403(request): + raise Forbidden("Forbidden") + @app.route('/401/basic') def handler_401_basic(request): raise Unauthorized("Unauthorized", "Basic", "Sanic") @@ -108,6 +112,12 @@ def test_not_found_exception(exception_app): assert response.status == 404 +def test_forbidden_exception(exception_app): + """Test the built-in Forbidden exception""" + request, response = exception_app.test_client.get('/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') diff --git a/tests/test_requests.py b/tests/test_requests.py index 671febeb..81fe1a5c 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -172,16 +172,6 @@ def test_token(): assert request.token == token - token = 'a1d895e0-553a-421a-8e22-5ff8ecb48cbf' - headers = { - 'content-type': 'application/json', - 'Authorization': 'Bearer Token {}'.format(token) - } - - request, response = app.test_client.get('/', headers=headers) - - assert request.token == token - token = 'a1d895e0-553a-421a-8e22-5ff8ecb48cbf' headers = { 'content-type': 'application/json',