Hello world!
') +``` + +## JSON + + +```python +from sanic import response + + +@app.route('/json') +def handle_request(request): + return response.json({'message': 'Hello world!'}) +``` + +## File + +```python +from sanic import response + + +@app.route('/file') +async def handle_request(request): + return await response.file('/srv/www/whatever.png') +``` + +## Streaming + +```python +from sanic import response + +@app.route("/streaming") +async def index(request): + async def streaming_fn(response): + response.write('foo') + response.write('bar') + return response.stream(streaming_fn, content_type='text/plain') +``` + +## File Streaming +For large files, a combination of File and Streaming above +```python +from sanic import response + +@app.route('/big_file.png') +async def handle_request(request): + return await response.file_stream('/srv/www/whatever.png') +``` + +## Redirect + +```python +from sanic import response + + +@app.route('/redirect') +def handle_request(request): + return response.redirect('/json') +``` + +## Raw + +Response without encoding the body + +```python +from sanic import response + + +@app.route('/raw') +def handle_request(request): + return response.raw(b'raw data') +``` + +## Modify headers or status + +To modify headers or status code, pass the `headers` or `status` argument to those functions: + +```python +from sanic import response + + +@app.route('/json') +def handle_request(request): + return response.json( + {'message': 'Hello world!'}, + headers={'X-Served-By': 'sanic'}, + status=200 + ) +``` diff --git a/docs/sanic/routing.md b/docs/sanic/routing.md new file mode 100644 index 00000000..e9cb0aef --- /dev/null +++ b/docs/sanic/routing.md @@ -0,0 +1,335 @@ +# Routing + +Routing allows the user to specify handler functions for different URL endpoints. + +A basic route looks like the following, where `app` is an instance of the +`Sanic` class: + +```python +from sanic.response import json + +@app.route("/") +async def test(request): + return json({ "hello": "world" }) +``` + +When the url `http://server.url/` is accessed (the base url of the server), the +final `/` is matched by the router to the handler function, `test`, which then +returns a JSON object. + +Sanic handler functions must be defined using the `async def` syntax, as they +are asynchronous functions. + +## Request parameters + +Sanic comes with a basic router that supports request parameters. + +To specify a parameter, surround it with angle quotes like so: ``. +Request parameters will be passed to the route handler functions as keyword +arguments. + +```python +from sanic.response import text + +@app.route('/tag/Hello world!
') +``` + +Then with curl: + +```bash +curl localhost/v1/html +``` diff --git a/docs/sanic/websocket.rst b/docs/sanic/websocket.rst new file mode 100644 index 00000000..8b813bf8 --- /dev/null +++ b/docs/sanic/websocket.rst @@ -0,0 +1,51 @@ +WebSocket +========= + +Sanic supports websockets, to setup a WebSocket: + +.. code:: python + + from sanic import Sanic + from sanic.response import json + from sanic.websocket import WebSocketProtocol + + app = Sanic() + + @app.websocket('/feed') + async def feed(request, ws): + while True: + data = 'hello!' + print('Sending: ' + data) + await ws.send(data) + data = await ws.recv() + print('Received: ' + data) + + if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000, protocol=WebSocketProtocol) + + +Alternatively, the ``app.add_websocket_route`` method can be used instead of the +decorator: + +.. code:: python + + async def feed(request, ws): + pass + + app.add_websocket_route(feed, '/feed') + + +Handlers for a WebSocket route are passed the request as first argument, and a +WebSocket protocol object as second argument. The protocol object has ``send`` +and ``recv`` methods to send and receive data respectively. + + +You could setup your own WebSocket configuration through ``app.config``, like + +.. code:: python + app.config.WEBSOCKET_MAX_SIZE = 2 ** 20 + app.config.WEBSOCKET_MAX_QUEUE = 32 + app.config.WEBSOCKET_READ_LIMIT = 2 ** 16 + app.config.WEBSOCKET_WRITE_LIMIT = 2 ** 16 + +Find more in ``Configuration`` section. diff --git a/environment.yml b/environment.yml new file mode 100644 index 00000000..1c1dd82f --- /dev/null +++ b/environment.yml @@ -0,0 +1,20 @@ +name: py35 +dependencies: +- openssl=1.0.2g=0 +- pip=8.1.1=py35_0 +- python=3.5.1=0 +- readline=6.2=2 +- setuptools=20.3=py35_0 +- sqlite=3.9.2=0 +- tk=8.5.18=0 +- wheel=0.29.0=py35_0 +- xz=5.0.5=1 +- zlib=1.2.8=0 +- pip: + - uvloop>=0.5.3 + - httptools>=0.0.9 + - ujson>=1.35 + - aiofiles>=0.3.0 + - websockets>=3.2 + - sphinxcontrib-asyncio>=0.2.0 + - https://github.com/channelcat/docutils-fork/zipball/master diff --git a/examples/add_task_sanic.py b/examples/add_task_sanic.py new file mode 100644 index 00000000..52b4e6bb --- /dev/null +++ b/examples/add_task_sanic.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- + +import asyncio + +from sanic import Sanic + +app = Sanic() + + +async def notify_server_started_after_five_seconds(): + await asyncio.sleep(5) + print('Server successfully started!') + +app.add_task(notify_server_started_after_five_seconds()) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000) diff --git a/examples/authorized_sanic.py b/examples/authorized_sanic.py new file mode 100644 index 00000000..f6b17426 --- /dev/null +++ b/examples/authorized_sanic.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +from sanic import Sanic +from functools import wraps +from sanic.response import json + +app = Sanic() + + +def check_request_for_authorization_status(request): + # Note: Define your check, for instance cookie, session. + flag = True + return flag + + +def authorized(): + def decorator(f): + @wraps(f) + async def decorated_function(request, *args, **kwargs): + # run some method that checks the request + # for the client's authorization status + is_authorized = check_request_for_authorization_status(request) + + if is_authorized: + # the user is authorized. + # run the handler method and return the response + response = await f(request, *args, **kwargs) + return response + else: + # the user is not authorized. + return json({'status': 'not_authorized'}, 403) + return decorated_function + return decorator + + +@app.route("/") +@authorized() +async def test(request): + return json({'status': 'authorized'}) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000) diff --git a/examples/blueprints.py b/examples/blueprints.py index f612185c..29144c4e 100644 --- a/examples/blueprints.py +++ b/examples/blueprints.py @@ -1,11 +1,10 @@ -from sanic import Sanic -from sanic import Blueprint -from sanic.response import json, text - +from sanic import Blueprint, Sanic +from sanic.response import file, json app = Sanic(__name__) blueprint = Blueprint('name', url_prefix='/my_blueprint') -blueprint2 = Blueprint('name', url_prefix='/my_blueprint2') +blueprint2 = Blueprint('name2', url_prefix='/my_blueprint2') +blueprint3 = Blueprint('name3', url_prefix='/my_blueprint3') @blueprint.route('/foo') @@ -18,7 +17,22 @@ async def foo2(request): return json({'msg': 'hi from blueprint2'}) -app.register_blueprint(blueprint) -app.register_blueprint(blueprint2) +@blueprint3.route('/foo') +async def index(request): + return await file('websocket.html') + + +@app.websocket('/feed') +async def foo3(request, ws): + while True: + data = 'hello!' + print('Sending: ' + data) + await ws.send(data) + data = await ws.recv() + print('Received: ' + data) + +app.blueprint(blueprint) +app.blueprint(blueprint2) +app.blueprint(blueprint3) app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/examples/exception_monitoring.py b/examples/exception_monitoring.py new file mode 100644 index 00000000..02a13e7d --- /dev/null +++ b/examples/exception_monitoring.py @@ -0,0 +1,54 @@ +""" +Example intercepting uncaught exceptions using Sanic's error handler framework. +This may be useful for developers wishing to use Sentry, Airbrake, etc. +or a custom system to log and monitor unexpected errors in production. +First we create our own class inheriting from Handler in sanic.exceptions, +and pass in an instance of it when we create our Sanic instance. Inside this +class' default handler, we can do anything including sending exceptions to +an external service. +""" +from sanic.handlers import ErrorHandler +from sanic.exceptions import SanicException +""" +Imports and code relevant for our CustomHandler class +(Ordinarily this would be in a separate file) +""" + + +class CustomHandler(ErrorHandler): + + def default(self, request, exception): + # Here, we have access to the exception object + # and can do anything with it (log, send to external service, etc) + + # Some exceptions are trivial and built into Sanic (404s, etc) + if not isinstance(exception, SanicException): + print(exception) + + # Then, we must finish handling the exception by returning + # our response to the client + # For this we can just call the super class' default handler + return super().default(request, exception) + + +""" +This is an ordinary Sanic server, with the exception that we set the +server's error_handler to an instance of our CustomHandler +""" + +from sanic import Sanic + +app = Sanic(__name__) + +handler = CustomHandler() +app.error_handler = handler + + +@app.route("/") +async def test(request): + # Here, something occurs which causes an unexpected exception + # This exception will flow to our custom handler. + raise SanicException('You Broke It!') + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/examples/limit_concurrency.py b/examples/limit_concurrency.py new file mode 100644 index 00000000..f6b4b01a --- /dev/null +++ b/examples/limit_concurrency.py @@ -0,0 +1,38 @@ +from sanic import Sanic +from sanic.response import json + +import asyncio +import aiohttp + +app = Sanic(__name__) + +sem = None + + +@app.listener('before_server_start') +def init(sanic, loop): + global sem + concurrency_per_worker = 4 + sem = asyncio.Semaphore(concurrency_per_worker, loop=loop) + +async def bounded_fetch(session, url): + """ + Use session object to perform 'get' request on url + """ + async with sem, session.get(url) as response: + return await response.json() + + +@app.route("/") +async def test(request): + """ + Download and serve example JSON + """ + url = "https://api.github.com/repos/channelcat/sanic" + + async with aiohttp.ClientSession() as session: + response = await bounded_fetch(session, url) + return json(response) + + +app.run(host="0.0.0.0", port=8000, workers=2) diff --git a/examples/log_request_id.py b/examples/log_request_id.py new file mode 100644 index 00000000..a6dba418 --- /dev/null +++ b/examples/log_request_id.py @@ -0,0 +1,86 @@ +''' +Based on example from https://github.com/Skyscanner/aiotask-context +and `examples/{override_logging,run_async}.py`. + +Needs https://github.com/Skyscanner/aiotask-context/tree/52efbc21e2e1def2d52abb9a8e951f3ce5e6f690 or newer + +$ pip install git+https://github.com/Skyscanner/aiotask-context.git +''' + +import asyncio +import uuid +import logging +from signal import signal, SIGINT + +from sanic import Sanic +from sanic import response + +import uvloop +import aiotask_context as context + +log = logging.getLogger(__name__) + + +class RequestIdFilter(logging.Filter): + def filter(self, record): + record.request_id = context.get('X-Request-ID') + return True + + +LOG_SETTINGS = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'level': 'DEBUG', + 'formatter': 'default', + 'filters': ['requestid'], + }, + }, + 'filters': { + 'requestid': { + '()': RequestIdFilter, + }, + }, + 'formatters': { + 'default': { + 'format': '%(asctime)s %(levelname)s %(name)s:%(lineno)d %(request_id)s | %(message)s', + }, + }, + 'loggers': { + '': { + 'level': 'DEBUG', + 'handlers': ['console'], + 'propagate': True + }, + } +} + + +app = Sanic(__name__, log_config=LOG_SETTINGS) + + +@app.middleware('request') +async def set_request_id(request): + request_id = request.headers.get('X-Request-ID') or str(uuid.uuid4()) + context.set("X-Request-ID", request_id) + + +@app.route("/") +async def test(request): + log.debug('X-Request-ID: %s', context.get('X-Request-ID')) + log.info('Hello from test!') + return response.json({"test": True}) + + +if __name__ == '__main__': + asyncio.set_event_loop(uvloop.new_event_loop()) + server = app.create_server(host="0.0.0.0", port=8000) + loop = asyncio.get_event_loop() + loop.set_task_factory(context.task_factory) + task = asyncio.ensure_future(server) + try: + loop.run_forever() + except: + loop.stop() diff --git a/examples/modify_header_example.py b/examples/modify_header_example.py new file mode 100644 index 00000000..f13e5f00 --- /dev/null +++ b/examples/modify_header_example.py @@ -0,0 +1,28 @@ +""" +Modify header or status in response +""" + +from sanic import Sanic +from sanic import response + +app = Sanic(__name__) + + +@app.route('/') +def handle_request(request): + return response.json( + {'message': 'Hello world!'}, + headers={'X-Served-By': 'sanic'}, + status=200 + ) + + +@app.route('/unauthorized') +def handle_request(request): + return response.json( + {'message': 'You are not authorized'}, + headers={'X-Served-By': 'sanic'}, + status=404 + ) + +app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/examples/override_logging.py b/examples/override_logging.py new file mode 100644 index 00000000..99d26997 --- /dev/null +++ b/examples/override_logging.py @@ -0,0 +1,24 @@ +from sanic import Sanic +from sanic import response +import logging + +logging_format = "[%(asctime)s] %(process)d-%(levelname)s " +logging_format += "%(module)s::%(funcName)s():l%(lineno)d: " +logging_format += "%(message)s" + +logging.basicConfig( + format=logging_format, + level=logging.DEBUG +) +log = logging.getLogger() + +# Set logger to override default basicConfig +sanic = Sanic() + + +@sanic.route("/") +def test(request): + log.info("received request; responding with 'hey'") + return response.text("hey") + +sanic.run(host="0.0.0.0", port=8000) diff --git a/examples/pytest_xdist.py b/examples/pytest_xdist.py new file mode 100644 index 00000000..06730016 --- /dev/null +++ b/examples/pytest_xdist.py @@ -0,0 +1,49 @@ +"""pytest-xdist example for sanic server + +Install testing tools: + + $ pip install pytest pytest-xdist + +Run with xdist params: + + $ pytest examples/pytest_xdist.py -n 8 # 8 workers +""" +import re +from sanic import Sanic +from sanic.response import text +from sanic.testing import PORT as PORT_BASE, SanicTestClient +import pytest + + +@pytest.fixture(scope="session") +def test_port(worker_id): + m = re.search(r'[0-9]+', worker_id) + if m: + num_id = m.group(0) + else: + num_id = 0 + port = PORT_BASE + int(num_id) + return port + + +@pytest.fixture(scope="session") +def app(): + app = Sanic() + + @app.route('/') + async def index(request): + return text('OK') + + return app + + +@pytest.fixture(scope="session") +def client(app, test_port): + return SanicTestClient(app, test_port) + + +@pytest.mark.parametrize('run_id', range(100)) +def test_index(client, run_id): + request, response = client._sanic_endpoint_test('get', '/') + assert response.status == 200 + assert response.text == 'OK' diff --git a/examples/redirect_example.py b/examples/redirect_example.py new file mode 100644 index 00000000..f73ad178 --- /dev/null +++ b/examples/redirect_example.py @@ -0,0 +1,18 @@ +from sanic import Sanic +from sanic import response + +app = Sanic(__name__) + + +@app.route('/') +def handle_request(request): + return response.redirect('/redirect') + + +@app.route('/redirect') +async def test(request): + return response.json({"Redirected": True}) + + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/examples/request_stream/client.py b/examples/request_stream/client.py new file mode 100644 index 00000000..a59c4c23 --- /dev/null +++ b/examples/request_stream/client.py @@ -0,0 +1,10 @@ +import requests + +# Warning: This is a heavy process. + +data = "" +for i in range(1, 250000): + data += str(i) + +r = requests.post('http://0.0.0.0:8000/stream', data=data) +print(r.text) diff --git a/examples/request_stream/server.py b/examples/request_stream/server.py new file mode 100644 index 00000000..e53a224c --- /dev/null +++ b/examples/request_stream/server.py @@ -0,0 +1,65 @@ +from sanic import Sanic +from sanic.views import CompositionView +from sanic.views import HTTPMethodView +from sanic.views import stream as stream_decorator +from sanic.blueprints import Blueprint +from sanic.response import stream, text + +bp = Blueprint('blueprint_request_stream') +app = Sanic('request_stream') + + +class SimpleView(HTTPMethodView): + + @stream_decorator + async def post(self, request): + result = '' + while True: + body = await request.stream.get() + if body is None: + break + result += body.decode('utf-8') + return text(result) + + +@app.post('/stream', stream=True) +async def handler(request): + async def streaming(response): + while True: + body = await request.stream.get() + if body is None: + break + body = body.decode('utf-8').replace('1', 'A') + response.write(body) + return stream(streaming) + + +@bp.put('/bp_stream', stream=True) +async def bp_handler(request): + result = '' + while True: + body = await request.stream.get() + if body is None: + break + result += body.decode('utf-8').replace('1', 'A') + return text(result) + + +async def post_handler(request): + result = '' + while True: + body = await request.stream.get() + if body is None: + break + result += body.decode('utf-8') + return text(result) + +app.blueprint(bp) +app.add_route(SimpleView.as_view(), '/method_view') +view = CompositionView() +view.add(['POST'], post_handler, stream=True) +app.add_route(view, '/composition_view') + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8000) diff --git a/examples/request_timeout.py b/examples/request_timeout.py new file mode 100644 index 00000000..0c2c489c --- /dev/null +++ b/examples/request_timeout.py @@ -0,0 +1,21 @@ +import asyncio +from sanic import Sanic +from sanic import response +from sanic.config import Config +from sanic.exceptions import RequestTimeout + +Config.REQUEST_TIMEOUT = 1 +app = Sanic(__name__) + + +@app.route('/') +async def test(request): + await asyncio.sleep(3) + return response.text('Hello, world!') + + +@app.exception(RequestTimeout) +def timeout(request, exception): + return response.text('RequestTimeout from error_handler.', 408) + +app.run(host='0.0.0.0', port=8000) diff --git a/examples/run_async.py b/examples/run_async.py new file mode 100644 index 00000000..3d8ab55a --- /dev/null +++ b/examples/run_async.py @@ -0,0 +1,22 @@ +from sanic import Sanic +from sanic import response +from signal import signal, SIGINT +import asyncio +import uvloop + +app = Sanic(__name__) + + +@app.route("/") +async def test(request): + return response.json({"answer": "42"}) + +asyncio.set_event_loop(uvloop.new_event_loop()) +server = app.create_server(host="0.0.0.0", port=8000) +loop = asyncio.get_event_loop() +task = asyncio.ensure_future(server) +signal(SIGINT, lambda s, f: loop.stop()) +try: + loop.run_forever() +except: + loop.stop() diff --git a/examples/simple_async_view.py b/examples/simple_async_view.py new file mode 100644 index 00000000..990aa21a --- /dev/null +++ b/examples/simple_async_view.py @@ -0,0 +1,42 @@ +from sanic import Sanic +from sanic.views import HTTPMethodView +from sanic.response import text + +app = Sanic('some_name') + + +class SimpleView(HTTPMethodView): + + def get(self, request): + return text('I am get method') + + def post(self, request): + return text('I am post method') + + def put(self, request): + return text('I am put method') + + def patch(self, request): + return text('I am patch method') + + def delete(self, request): + return text('I am delete method') + + +class SimpleAsyncView(HTTPMethodView): + + async def get(self, request): + return text('I am async get method') + + async def post(self, request): + return text('I am async post method') + + async def put(self, request): + return text('I am async put method') + + +app.add_route(SimpleView.as_view(), '/') +app.add_route(SimpleAsyncView.as_view(), '/async') + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/examples/simple_server.py b/examples/simple_server.py index 24e3570f..948090c4 100644 --- a/examples/simple_server.py +++ b/examples/simple_server.py @@ -1,12 +1,13 @@ from sanic import Sanic -from sanic.response import json +from sanic import response app = Sanic(__name__) @app.route("/") async def test(request): - return json({"test": True}) + return response.json({"test": True}) -app.run(host="0.0.0.0", port=8000) +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000) diff --git a/examples/teapot.py b/examples/teapot.py new file mode 100644 index 00000000..897f7836 --- /dev/null +++ b/examples/teapot.py @@ -0,0 +1,13 @@ +from sanic import Sanic +from sanic import response as res + +app = Sanic(__name__) + + +@app.route("/") +async def test(req): + return res.text("I\'m a teapot", status=418) + + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000) diff --git a/examples/try_everything.py b/examples/try_everything.py index 80358ddb..a775704d 100644 --- a/examples/try_everything.py +++ b/examples/try_everything.py @@ -1,6 +1,8 @@ +import os + from sanic import Sanic -from sanic.log import log -from sanic.response import json, text +from sanic.log import logger as log +from sanic import response from sanic.exceptions import ServerError app = Sanic(__name__) @@ -8,37 +10,50 @@ app = Sanic(__name__) @app.route("/") async def test_async(request): - return json({"test": True}) + return response.json({"test": True}) @app.route("/sync", methods=['GET', 'POST']) def test_sync(request): - return json({"test": True}) + return response.json({"test": True}) -@app.route("/dynamic/
+ {exc_name}: {exc_value}
+ while handling path {path}
+
{exc_value}
Traceback (most recent call last):
+ {frame_html} +
+ File {0.filename}, line {0.lineno},
+ in {0.name}
+
{0.line}
+ The server encountered an internal error and cannot complete + your request. +
+''' + + +_sanic_exceptions = {} + + +def add_status_code(code): + """ + Decorator used for adding exceptions to _sanic_exceptions. + """ + def class_decorator(cls): + cls.status_code = code + _sanic_exceptions[code] = cls + return cls + return class_decorator class SanicException(Exception): + def __init__(self, message, status_code=None): super().__init__(message) + if status_code is not None: self.status_code = status_code +@add_status_code(404) class NotFound(SanicException): - status_code = 404 + pass +@add_status_code(400) class InvalidUsage(SanicException): - status_code = 400 + pass +@add_status_code(405) +class MethodNotSupported(SanicException): + def __init__(self, message, method, allowed_methods): + super().__init__(message) + self.headers = dict() + self.headers["Allow"] = ", ".join(allowed_methods) + if method in ['HEAD', 'PATCH', 'PUT', 'DELETE']: + self.headers['Content-Length'] = 0 + + +@add_status_code(500) class ServerError(SanicException): - status_code = 500 + pass -class Handler: - handlers = None +@add_status_code(503) +class ServiceUnavailable(SanicException): + """The server is currently unavailable (because it is overloaded or + down for maintenance). Generally, this is a temporary state.""" + pass - def __init__(self, sanic): - self.handlers = {} - self.sanic = sanic - def add(self, exception, handler): - self.handlers[exception] = handler +class URLBuildError(ServerError): + pass - def response(self, request, exception): - """ - Fetches and executes an exception handler and returns a response object - :param request: Request - :param exception: Exception to handle - :return: Response object - """ - handler = self.handlers.get(type(exception), self.default) - response = handler(request=request, exception=exception) - return response - def default(self, request, exception): - if issubclass(type(exception), SanicException): - return text( - "Error: {}".format(exception), - status=getattr(exception, 'status_code', 500)) - elif self.sanic.debug: - return text( - "Error: {}\nException: {}".format( - exception, format_exc()), status=500) - else: - return text( - "An error occurred while generating the request", status=500) +class FileNotFound(NotFound): + def __init__(self, message, path, relative_url): + super().__init__(message) + self.path = path + self.relative_url = relative_url + + +@add_status_code(408) +class RequestTimeout(SanicException): + """The Web server (running the Web site) thinks that there has been too + long an interval of time between 1) the establishment of an IP + connection (socket) between the client and the server and + 2) the receipt of any data on that socket, so the server has dropped + the connection. The socket connection has actually been lost - the Web + server has 'timed out' on that particular socket connection. + """ + pass + + +@add_status_code(413) +class PayloadTooLarge(SanicException): + pass + + +class HeaderNotFound(InvalidUsage): + pass + + +@add_status_code(416) +class ContentRangeError(SanicException): + def __init__(self, message, content_range): + super().__init__(message) + self.headers = { + 'Content-Type': 'text/plain', + "Content-Range": "bytes */%s" % (content_range.total,) + } + + +@add_status_code(403) +class Forbidden(SanicException): + pass + + +class InvalidRangeType(ContentRangeError): + pass + + +@add_status_code(401) +class Unauthorized(SanicException): + """ + Unauthorized exception (401 HTTP status code). + + :param message: Message describing the exception. + :param status_code: HTTP Status code. + :param scheme: Name of the authentication scheme to be used. + + When present, kwargs is used to complete the WWW-Authentication header. + + Examples:: + + # With a Basic auth-scheme, realm MUST be present: + raise Unauthorized("Auth required.", + scheme="Basic", + realm="Restricted Area") + + # With a Digest auth-scheme, things are a bit more complicated: + raise Unauthorized("Auth required.", + scheme="Digest", + realm="Restricted Area", + qop="auth, auth-int", + algorithm="MD5", + nonce="abcdef", + opaque="zyxwvu") + + # With a Bearer auth-scheme, realm is optional so you can write: + raise Unauthorized("Auth required.", scheme="Bearer") + + # or, if you want to specify the realm: + raise Unauthorized("Auth required.", + scheme="Bearer", + realm="Restricted Area") + """ + def __init__(self, message, status_code=None, scheme=None, **kwargs): + super().__init__(message, status_code) + + # if auth-scheme is specified, set "WWW-Authenticate" header + if scheme is not None: + values = ['{!s}="{!s}"'.format(k, v) for k, v in kwargs.items()] + challenge = ', '.join(values) + + self.headers = { + "WWW-Authenticate": "{} {}".format(scheme, challenge).rstrip() + } + + +def abort(status_code, message=None): + """ + Raise an exception based on SanicException. Returns the HTTP response + message appropriate for the given status code, unless provided. + + :param status_code: The HTTP status code to return. + :param message: The HTTP response body. Defaults to the messages + in response.py for the given status code. + """ + if message is None: + message = STATUS_CODES.get(status_code) + # These are stored as bytes in the STATUS_CODES dict + message = message.decode('utf8') + sanic_exception = _sanic_exceptions.get(status_code, SanicException) + raise sanic_exception(message=message, status_code=status_code) diff --git a/sanic/handlers.py b/sanic/handlers.py new file mode 100644 index 00000000..81dd38d7 --- /dev/null +++ b/sanic/handlers.py @@ -0,0 +1,171 @@ +import sys +from traceback import format_exc, extract_tb + +from sanic.exceptions import ( + ContentRangeError, + HeaderNotFound, + INTERNAL_SERVER_ERROR_HTML, + InvalidRangeType, + SanicException, + TRACEBACK_LINE_HTML, + TRACEBACK_STYLE, + TRACEBACK_WRAPPER_HTML, + TRACEBACK_WRAPPER_INNER_HTML, + TRACEBACK_BORDER) +from sanic.log import logger +from sanic.response import text, html + + +class ErrorHandler: + handlers = None + cached_handlers = None + _missing = object() + + def __init__(self): + self.handlers = [] + self.cached_handlers = {} + self.debug = False + + def _render_exception(self, exception): + frames = extract_tb(exception.__traceback__) + + frame_html = [] + for frame in frames: + frame_html.append(TRACEBACK_LINE_HTML.format(frame)) + + return TRACEBACK_WRAPPER_INNER_HTML.format( + exc_name=exception.__class__.__name__, + exc_value=exception, + frame_html=''.join(frame_html)) + + def _render_traceback_html(self, exception, request): + exc_type, exc_value, tb = sys.exc_info() + exceptions = [] + + while exc_value: + exceptions.append(self._render_exception(exc_value)) + exc_value = exc_value.__cause__ + + return TRACEBACK_WRAPPER_HTML.format( + style=TRACEBACK_STYLE, + exc_name=exception.__class__.__name__, + exc_value=exception, + inner_html=TRACEBACK_BORDER.join(reversed(exceptions)), + path=request.path) + + def add(self, exception, handler): + self.handlers.append((exception, handler)) + + def lookup(self, exception): + handler = self.cached_handlers.get(exception, self._missing) + if handler is self._missing: + for exception_class, handler in self.handlers: + if isinstance(exception, exception_class): + self.cached_handlers[type(exception)] = handler + return handler + self.cached_handlers[type(exception)] = None + handler = None + return handler + + def response(self, request, exception): + """Fetches and executes an exception handler and returns a response + object + + :param request: Request + :param exception: Exception to handle + :return: Response object + """ + handler = self.lookup(exception) + response = None + try: + if handler: + response = handler(request, exception) + if response is None: + response = self.default(request, exception) + except Exception: + self.log(format_exc()) + if self.debug: + url = getattr(request, 'url', 'unknown') + response_message = ('Exception raised in exception handler ' + '"%s" for uri: "%s"\n%s') + logger.error(response_message, + handler.__name__, url, format_exc()) + + return text(response_message % ( + handler.__name__, url, format_exc()), 500) + else: + return text('An error occurred while handling an error', 500) + return response + + def log(self, message, level='error'): + """ + Override this method in an ErrorHandler subclass to prevent + logging exceptions. + """ + getattr(logger, level)(message) + + def default(self, request, exception): + self.log(format_exc()) + if issubclass(type(exception), SanicException): + return text( + 'Error: {}'.format(exception), + status=getattr(exception, 'status_code', 500), + headers=getattr(exception, 'headers', dict()) + ) + elif self.debug: + html_output = self._render_traceback_html(exception, request) + + response_message = ('Exception occurred while handling uri: ' + '"%s"\n%s') + logger.error(response_message, request.url, format_exc()) + return html(html_output, status=500) + else: + return html(INTERNAL_SERVER_ERROR_HTML, status=500) + + +class ContentRangeHandler: + """Class responsible for parsing request header""" + __slots__ = ('start', 'end', 'size', 'total', 'headers') + + def __init__(self, request, stats): + self.total = stats.st_size + _range = request.headers.get('Range') + if _range is None: + raise HeaderNotFound('Range Header Not Found') + unit, _, value = tuple(map(str.strip, _range.partition('='))) + if unit != 'bytes': + raise InvalidRangeType( + '%s is not a valid Range Type' % (unit,), self) + start_b, _, end_b = tuple(map(str.strip, value.partition('-'))) + try: + self.start = int(start_b) if start_b else None + except ValueError: + raise ContentRangeError( + '\'%s\' is invalid for Content Range' % (start_b,), self) + try: + self.end = int(end_b) if end_b else None + except ValueError: + raise ContentRangeError( + '\'%s\' is invalid for Content Range' % (end_b,), self) + if self.end is None: + if self.start is None: + raise ContentRangeError( + 'Invalid for Content Range parameters', self) + else: + # this case represents `Content-Range: bytes 5-` + self.end = self.total + else: + if self.start is None: + # this case represents `Content-Range: bytes -5` + self.start = self.total - self.end + self.end = self.total + if self.start >= self.end: + raise ContentRangeError( + 'Invalid for Content Range parameters', self) + self.size = self.end - self.start + self.headers = { + 'Content-Range': "bytes %s-%s/%s" % ( + self.start, self.end, self.total)} + + def __bool__(self): + return self.size > 0 diff --git a/sanic/http.py b/sanic/http.py new file mode 100644 index 00000000..482253b3 --- /dev/null +++ b/sanic/http.py @@ -0,0 +1,128 @@ +"""Defines basics of HTTP standard.""" + +STATUS_CODES = { + 100: b'Continue', + 101: b'Switching Protocols', + 102: b'Processing', + 200: b'OK', + 201: b'Created', + 202: b'Accepted', + 203: b'Non-Authoritative Information', + 204: b'No Content', + 205: b'Reset Content', + 206: b'Partial Content', + 207: b'Multi-Status', + 208: b'Already Reported', + 226: b'IM Used', + 300: b'Multiple Choices', + 301: b'Moved Permanently', + 302: b'Found', + 303: b'See Other', + 304: b'Not Modified', + 305: b'Use Proxy', + 307: b'Temporary Redirect', + 308: b'Permanent Redirect', + 400: b'Bad Request', + 401: b'Unauthorized', + 402: b'Payment Required', + 403: b'Forbidden', + 404: b'Not Found', + 405: b'Method Not Allowed', + 406: b'Not Acceptable', + 407: b'Proxy Authentication Required', + 408: b'Request Timeout', + 409: b'Conflict', + 410: b'Gone', + 411: b'Length Required', + 412: b'Precondition Failed', + 413: b'Request Entity Too Large', + 414: b'Request-URI Too Long', + 415: b'Unsupported Media Type', + 416: b'Requested Range Not Satisfiable', + 417: b'Expectation Failed', + 418: b'I\'m a teapot', + 422: b'Unprocessable Entity', + 423: b'Locked', + 424: b'Failed Dependency', + 426: b'Upgrade Required', + 428: b'Precondition Required', + 429: b'Too Many Requests', + 431: b'Request Header Fields Too Large', + 451: b'Unavailable For Legal Reasons', + 500: b'Internal Server Error', + 501: b'Not Implemented', + 502: b'Bad Gateway', + 503: b'Service Unavailable', + 504: b'Gateway Timeout', + 505: b'HTTP Version Not Supported', + 506: b'Variant Also Negotiates', + 507: b'Insufficient Storage', + 508: b'Loop Detected', + 510: b'Not Extended', + 511: b'Network Authentication Required' +} + +# According to https://tools.ietf.org/html/rfc2616#section-7.1 +_ENTITY_HEADERS = frozenset([ + 'allow', + 'content-encoding', + 'content-language', + 'content-length', + 'content-location', + 'content-md5', + 'content-range', + 'content-type', + 'expires', + 'last-modified', + 'extension-header' +]) + +# According to https://tools.ietf.org/html/rfc2616#section-13.5.1 +_HOP_BY_HOP_HEADERS = frozenset([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailers', + 'transfer-encoding', + 'upgrade' +]) + + +def has_message_body(status): + """ + According to the following RFC message body and length SHOULD NOT + be included in responses status 1XX, 204 and 304. + https://tools.ietf.org/html/rfc2616#section-4.4 + https://tools.ietf.org/html/rfc2616#section-4.3 + """ + return status not in (204, 304) and not (100 <= status < 200) + + +def is_entity_header(header): + """Checks if the given header is an Entity Header""" + return header.lower() in _ENTITY_HEADERS + + +def is_hop_by_hop_header(header): + """Checks if the given header is a Hop By Hop header""" + return header.lower() in _HOP_BY_HOP_HEADERS + + +def remove_entity_headers(headers, + allowed=('content-location', 'expires')): + """ + Removes all the entity headers present in the headers given. + According to RFC 2616 Section 10.3.5, + Content-Location and Expires are allowed as for the + "strong cache validator". + https://tools.ietf.org/html/rfc2616#section-10.3.5 + + returns the headers without the entity headers + """ + allowed = set([h.lower() for h in allowed]) + headers = {header: value for header, value in headers.items() + if not is_entity_header(header) + and header.lower() not in allowed} + return headers diff --git a/sanic/log.py b/sanic/log.py index bd2e499e..9c6d868d 100644 --- a/sanic/log.py +++ b/sanic/log.py @@ -1,5 +1,63 @@ import logging +import sys -logging.basicConfig( - level=logging.INFO, format="%(asctime)s: %(levelname)s: %(message)s") -log = logging.getLogger(__name__) + +LOGGING_CONFIG_DEFAULTS = dict( + version=1, + disable_existing_loggers=False, + + loggers={ + "root": { + "level": "INFO", + "handlers": ["console"] + }, + "sanic.error": { + "level": "INFO", + "handlers": ["error_console"], + "propagate": True, + "qualname": "sanic.error" + }, + + "sanic.access": { + "level": "INFO", + "handlers": ["access_console"], + "propagate": True, + "qualname": "sanic.access" + } + }, + handlers={ + "console": { + "class": "logging.StreamHandler", + "formatter": "generic", + "stream": sys.stdout + }, + "error_console": { + "class": "logging.StreamHandler", + "formatter": "generic", + "stream": sys.stderr + }, + "access_console": { + "class": "logging.StreamHandler", + "formatter": "access", + "stream": sys.stdout + }, + }, + formatters={ + "generic": { + "format": "%(asctime)s [%(process)d] [%(levelname)s] %(message)s", + "datefmt": "[%Y-%m-%d %H:%M:%S %z]", + "class": "logging.Formatter" + }, + "access": { + "format": "%(asctime)s - (%(name)s)[%(levelname)s][%(host)s]: " + + "%(request)s %(message)s %(status)d %(byte)d", + "datefmt": "[%Y-%m-%d %H:%M:%S %z]", + "class": "logging.Formatter" + }, + } +) + + +logger = logging.getLogger('root') +error_logger = logging.getLogger('sanic.error') +access_logger = logging.getLogger('sanic.access') diff --git a/sanic/reloader_helpers.py b/sanic/reloader_helpers.py new file mode 100644 index 00000000..73759124 --- /dev/null +++ b/sanic/reloader_helpers.py @@ -0,0 +1,144 @@ +import os +import sys +import signal +import subprocess +from time import sleep +from multiprocessing import Process + + +def _iter_module_files(): + """This iterates over all relevant Python files. + + It goes through all + loaded files from modules, all files in folders of already loaded modules + as well as all files reachable through a package. + """ + # The list call is necessary on Python 3 in case the module + # dictionary modifies during iteration. + for module in list(sys.modules.values()): + if module is None: + continue + filename = getattr(module, '__file__', None) + if filename: + old = None + while not os.path.isfile(filename): + old = filename + filename = os.path.dirname(filename) + if filename == old: + break + else: + if filename[-4:] in ('.pyc', '.pyo'): + filename = filename[:-1] + yield filename + + +def _get_args_for_reloading(): + """Returns the executable.""" + rv = [sys.executable] + rv.extend(sys.argv) + return rv + + +def restart_with_reloader(): + """Create a new process and a subprocess in it with the same arguments as + this one. + """ + args = _get_args_for_reloading() + new_environ = os.environ.copy() + new_environ['SANIC_SERVER_RUNNING'] = 'true' + cmd = ' '.join(args) + worker_process = Process( + target=subprocess.call, args=(cmd,), + kwargs=dict(shell=True, env=new_environ)) + worker_process.start() + return worker_process + + +def kill_process_children_unix(pid): + """Find and kill child processes of a process (maximum two level). + + :param pid: PID of parent process (process ID) + :return: Nothing + """ + root_process_path = "/proc/{pid}/task/{pid}/children".format(pid=pid) + if not os.path.isfile(root_process_path): + return + with open(root_process_path) as children_list_file: + children_list_pid = children_list_file.read().split() + + for child_pid in children_list_pid: + children_proc_path = "/proc/%s/task/%s/children" % \ + (child_pid, child_pid) + if not os.path.isfile(children_proc_path): + continue + with open(children_proc_path) as children_list_file_2: + children_list_pid_2 = children_list_file_2.read().split() + for _pid in children_list_pid_2: + os.kill(int(_pid), signal.SIGTERM) + + +def kill_process_children_osx(pid): + """Find and kill child processes of a process. + + :param pid: PID of parent process (process ID) + :return: Nothing + """ + subprocess.run(['pkill', '-P', str(pid)]) + + +def kill_process_children(pid): + """Find and kill child processes of a process. + + :param pid: PID of parent process (process ID) + :return: Nothing + """ + if sys.platform == 'darwin': + kill_process_children_osx(pid) + elif sys.platform == 'posix': + kill_process_children_unix(pid) + else: + pass # should signal error here + + +def kill_program_completly(proc): + """Kill worker and it's child processes and exit. + + :param proc: worker process (process ID) + :return: Nothing + """ + kill_process_children(proc.pid) + proc.terminate() + os._exit(0) + + +def watchdog(sleep_interval): + """Watch project files, restart worker process if a change happened. + + :param sleep_interval: interval in second. + :return: Nothing + """ + mtimes = {} + worker_process = restart_with_reloader() + signal.signal( + signal.SIGTERM, lambda *args: kill_program_completly(worker_process)) + signal.signal( + signal.SIGINT, lambda *args: kill_program_completly(worker_process)) + while True: + for filename in _iter_module_files(): + try: + mtime = os.stat(filename).st_mtime + except OSError: + continue + + old_time = mtimes.get(filename) + if old_time is None: + mtimes[filename] = mtime + continue + elif mtime > old_time: + kill_process_children(worker_process.pid) + worker_process = restart_with_reloader() + + mtimes[filename] = mtime + break + + sleep(sleep_interval) diff --git a/sanic/request.py b/sanic/request.py index 31b73ed8..c8b470d4 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -1,79 +1,132 @@ +import sys +import json +import socket from cgi import parse_header from collections import namedtuple +from http.cookies import SimpleCookie from httptools import parse_url -from urllib.parse import parse_qs -from ujson import loads as json_loads +from urllib.parse import parse_qs, urlunparse -from .log import log +try: + from ujson import loads as json_loads +except ImportError: + if sys.version_info[:2] == (3, 5): + def json_loads(data): + # on Python 3.5 json.loads only supports str not bytes + return json.loads(data.decode()) + else: + json_loads = json.loads + +from sanic.exceptions import InvalidUsage +from sanic.log import error_logger, logger + +DEFAULT_HTTP_CONTENT_TYPE = "application/octet-stream" + + +# HTTP/1.1: https://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html#sec7.2.1 +# > If the media type remains unknown, the recipient SHOULD treat it +# > as type "application/octet-stream" class RequestParameters(dict): - """ - Hosts a dict with lists as values where get returns the first + """Hosts a dict with lists as values where get returns the first value of the list and getlist returns the whole shebang """ - def __init__(self, *args, **kwargs): - self.super = super() - self.super.__init__(*args, **kwargs) - def get(self, name, default=None): - values = self.super.get(name) - return values[0] if values else default + """Return the first value, either the default or actual""" + return super().get(name, [default])[0] def getlist(self, name, default=None): - return self.super.get(name, default) + """Return the entire list""" + return super().get(name, default) -class Request: - """ - Properties of an HTTP request such as URL, headers, etc. - """ +class Request(dict): + """Properties of an HTTP request such as URL, headers, etc.""" __slots__ = ( - 'url', 'headers', 'version', 'method', - 'query_string', 'body', - 'parsed_json', 'parsed_args', 'parsed_form', 'parsed_files', + 'app', 'headers', 'version', 'method', '_cookies', 'transport', + 'body', 'parsed_json', 'parsed_args', 'parsed_form', 'parsed_files', + '_ip', '_parsed_url', 'uri_template', 'stream', '_remote_addr', + '_socket', '_port', '__weakref__', 'raw_url' ) - def __init__(self, url_bytes, headers, version, method): + def __init__(self, url_bytes, headers, version, method, transport): + self.raw_url = url_bytes # TODO: Content-Encoding detection - url_parsed = parse_url(url_bytes) - self.url = url_parsed.path.decode('utf-8') + self._parsed_url = parse_url(url_bytes) + self.app = None + self.headers = headers self.version = version self.method = method - self.query_string = None - if url_parsed.query: - self.query_string = url_parsed.query.decode('utf-8') + self.transport = transport # Init but do not inhale - self.body = None + self.body = [] self.parsed_json = None self.parsed_form = None self.parsed_files = None self.parsed_args = None + self.uri_template = None + self._cookies = None + self.stream = None + + def __repr__(self): + if self.method is None or not self.path: + return '<{0}>'.format(self.__class__.__name__) + return '<{0}: {1} {2}>'.format(self.__class__.__name__, + self.method, + self.path) + + def __bool__(self): + if self.transport: + return True + return False @property def json(self): - if not self.parsed_json: - try: - self.parsed_json = json_loads(self.body) - except Exception: - pass + if self.parsed_json is None: + self.load_json() + + return self.parsed_json + + def load_json(self, loads=json_loads): + try: + self.parsed_json = loads(self.body) + except Exception: + if not self.body: + return None + raise InvalidUsage("Failed when parsing body as json") return self.parsed_json + @property + def token(self): + """Attempt to return the auth header token. + + :return: token related to request + """ + 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].strip() + + return auth_header + @property def form(self): if self.parsed_form is None: - self.parsed_form = {} - self.parsed_files = {} - content_type, parameters = parse_header( - self.headers.get('Content-Type')) + self.parsed_form = RequestParameters() + self.parsed_files = RequestParameters() + content_type = self.headers.get( + 'Content-Type', DEFAULT_HTTP_CONTENT_TYPE) + content_type, parameters = parse_header(content_type) try: - is_url_encoded = ( - content_type == 'application/x-www-form-urlencoded') - if content_type is None or is_url_encoded: + if content_type == 'application/x-www-form-urlencoded': self.parsed_form = RequestParameters( parse_qs(self.body.decode('utf-8'))) elif content_type == 'multipart/form-data': @@ -81,9 +134,8 @@ class Request: boundary = parameters['boundary'].encode('utf-8') self.parsed_form, self.parsed_files = ( parse_multipart_form(self.body, boundary)) - except Exception as e: - log.exception(e) - pass + except Exception: + error_logger.exception("Failed when parsing form") return self.parsed_form @@ -101,28 +153,145 @@ class Request: self.parsed_args = RequestParameters( parse_qs(self.query_string)) else: - self.parsed_args = {} - + self.parsed_args = RequestParameters() return self.parsed_args + @property + def raw_args(self): + return {k: v[0] for k, v in self.args.items()} + + @property + def cookies(self): + if self._cookies is None: + cookie = self.headers.get('Cookie') + if cookie is not None: + cookies = SimpleCookie() + cookies.load(cookie) + self._cookies = {name: cookie.value + for name, cookie in cookies.items()} + else: + self._cookies = {} + return self._cookies + + @property + def ip(self): + if not hasattr(self, '_socket'): + self._get_address() + return self._ip + + @property + def port(self): + if not hasattr(self, '_socket'): + self._get_address() + return self._port + + @property + def socket(self): + if not hasattr(self, '_socket'): + self._get_address() + return self._socket + + def _get_address(self): + sock = self.transport.get_extra_info('socket') + + if sock.family == socket.AF_INET: + self._socket = (self.transport.get_extra_info('peername') or + (None, None)) + self._ip, self._port = self._socket + elif sock.family == socket.AF_INET6: + self._socket = (self.transport.get_extra_info('peername') or + (None, None, None, None)) + self._ip, self._port, *_ = self._socket + else: + self._ip, self._port = (None, None) + + @property + def remote_addr(self): + """Attempt to return the original client ip based on X-Forwarded-For. + + :return: original client ip. + """ + if not hasattr(self, '_remote_addr'): + forwarded_for = self.headers.get('X-Forwarded-For', '').split(',') + remote_addrs = [ + addr for addr in [ + addr.strip() for addr in forwarded_for + ] if addr + ] + if len(remote_addrs) > 0: + self._remote_addr = remote_addrs[0] + else: + self._remote_addr = '' + return self._remote_addr + + @property + def scheme(self): + if self.app.websocket_enabled \ + and self.headers.get('upgrade') == 'websocket': + scheme = 'ws' + else: + scheme = 'http' + + if self.transport.get_extra_info('sslcontext'): + scheme += 's' + + return scheme + + @property + def host(self): + # it appears that httptools doesn't return the host + # so pull it from the headers + return self.headers.get('Host', '') + + @property + def content_type(self): + return self.headers.get('Content-Type', DEFAULT_HTTP_CONTENT_TYPE) + + @property + def match_info(self): + """return matched info after resolving route""" + return self.app.router.get(self)[2] + + @property + def path(self): + return self._parsed_url.path.decode('utf-8') + + @property + def query_string(self): + if self._parsed_url.query: + return self._parsed_url.query.decode('utf-8') + else: + return '' + + @property + def url(self): + return urlunparse(( + self.scheme, + self.host, + self.path, + None, + self.query_string, + None)) + File = namedtuple('File', ['type', 'body', 'name']) def parse_multipart_form(body, boundary): + """Parse a request body and returns fields and files + + :param body: bytes request body + :param boundary: bytes multipart boundary + :return: fields (RequestParameters), files (RequestParameters) """ - Parses a request body and returns fields and files - :param body: Bytes request body - :param boundary: Bytes multipart boundary - :return: fields (dict), files (dict) - """ - files = {} - fields = {} + files = RequestParameters() + fields = RequestParameters() form_parts = body.split(boundary) for form_part in form_parts[1:-1]: file_name = None - file_type = None + content_type = 'text/plain' + content_charset = 'utf-8' field_name = None line_index = 2 line_end_index = 0 @@ -135,22 +304,35 @@ def parse_multipart_form(body, boundary): break colon_index = form_line.index(':') - form_header_field = form_line[0:colon_index] + form_header_field = form_line[0:colon_index].lower() form_header_value, form_parameters = parse_header( form_line[colon_index + 2:]) - if form_header_field == 'Content-Disposition': - if 'filename' in form_parameters: - file_name = form_parameters['filename'] + if form_header_field == 'content-disposition': + file_name = form_parameters.get('filename') field_name = form_parameters.get('name') - elif form_header_field == 'Content-Type': - file_type = form_header_value + elif form_header_field == 'content-type': + content_type = form_header_value + content_charset = form_parameters.get('charset', 'utf-8') - post_data = form_part[line_index:-4] - if file_name or file_type: - files[field_name] = File( - type=file_type, name=file_name, body=post_data) + if field_name: + post_data = form_part[line_index:-4] + if file_name: + form_file = File(type=content_type, + name=file_name, + body=post_data) + if field_name in files: + files[field_name].append(form_file) + else: + files[field_name] = [form_file] + else: + value = post_data.decode(content_charset) + if field_name in fields: + fields[field_name].append(value) + else: + fields[field_name] = [value] else: - fields[field_name] = post_data.decode('utf-8') + logger.debug('Form-data field does not have a \'name\' parameter \ + in the Content-Disposition header') return fields, files diff --git a/sanic/response.py b/sanic/response.py index 2bf9b167..2d2f5b96 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -1,77 +1,372 @@ -import ujson +from mimetypes import guess_type +from os import path +from urllib.parse import quote_plus -STATUS_CODES = { - 200: b'OK', - 400: b'Bad Request', - 401: b'Unauthorized', - 402: b'Payment Required', - 403: b'Forbidden', - 404: b'Not Found', - 405: b'Method Not Allowed', - 500: b'Internal Server Error', - 501: b'Not Implemented', - 502: b'Bad Gateway', - 503: b'Service Unavailable', - 504: b'Gateway Timeout', -} +try: + from ujson import dumps as json_dumps +except BaseException: + from json import dumps as json_dumps + +from aiofiles import open as open_async +from multidict import CIMultiDict + +from sanic import http +from sanic.cookies import CookieJar -class HTTPResponse: - __slots__ = ('body', 'status', 'content_type', 'headers') +class BaseHTTPResponse: + def _encode_body(self, data): + try: + # Try to encode it regularly + return data.encode() + except AttributeError: + # Convert it to a str if you can't + return str(data).encode() + + def _parse_headers(self): + headers = b'' + for name, value in self.headers.items(): + try: + headers += ( + b'%b: %b\r\n' % ( + name.encode(), value.encode('utf-8'))) + except AttributeError: + headers += ( + b'%b: %b\r\n' % ( + str(name).encode(), str(value).encode('utf-8'))) + + return headers + + @property + def cookies(self): + if self._cookies is None: + self._cookies = CookieJar(self.headers) + return self._cookies + + +class StreamingHTTPResponse(BaseHTTPResponse): + __slots__ = ( + 'transport', 'streaming_fn', 'status', + 'content_type', 'headers', '_cookies' + ) + + def __init__(self, streaming_fn, status=200, headers=None, + content_type='text/plain'): + self.content_type = content_type + self.streaming_fn = streaming_fn + self.status = status + self.headers = CIMultiDict(headers or {}) + self._cookies = None + + def write(self, data): + """Writes a chunk of data to the streaming response. + + :param data: bytes-ish data to be written. + """ + if type(data) != bytes: + data = self._encode_body(data) + + self.transport.write( + b"%x\r\n%b\r\n" % (len(data), data)) + + async def stream( + self, version="1.1", keep_alive=False, keep_alive_timeout=None): + """Streams headers, runs the `streaming_fn` callback that writes + content to the response body, then finalizes the response body. + """ + headers = self.get_headers( + version, keep_alive=keep_alive, + keep_alive_timeout=keep_alive_timeout) + self.transport.write(headers) + + await self.streaming_fn(self) + self.transport.write(b'0\r\n\r\n') + + def get_headers( + self, version="1.1", keep_alive=False, keep_alive_timeout=None): + # This is all returned in a kind-of funky way + # We tried to make this as fast as possible in pure python + timeout_header = b'' + if keep_alive and keep_alive_timeout is not None: + timeout_header = b'Keep-Alive: %d\r\n' % keep_alive_timeout + + self.headers['Transfer-Encoding'] = 'chunked' + self.headers.pop('Content-Length', None) + self.headers['Content-Type'] = self.headers.get( + 'Content-Type', self.content_type) + + headers = self._parse_headers() + + if self.status is 200: + status = b'OK' + else: + status = http.STATUS_CODES.get(self.status) + + return (b'HTTP/%b %d %b\r\n' + b'%b' + b'%b\r\n') % ( + version.encode(), + self.status, + status, + timeout_header, + headers + ) + + +class HTTPResponse(BaseHTTPResponse): + __slots__ = ('body', 'status', 'content_type', 'headers', '_cookies') def __init__(self, body=None, status=200, headers=None, content_type='text/plain', body_bytes=b''): self.content_type = content_type if body is not None: - self.body = body.encode('utf-8') + self.body = self._encode_body(body) else: self.body = body_bytes self.status = status - self.headers = headers or {} + self.headers = CIMultiDict(headers or {}) + self._cookies = None - def output(self, version="1.1", keep_alive=False, keep_alive_timeout=None): + def output( + self, version="1.1", keep_alive=False, keep_alive_timeout=None): # This is all returned in a kind-of funky way # We tried to make this as fast as possible in pure python timeout_header = b'' - if keep_alive and keep_alive_timeout: - timeout_header = b'Keep-Alive: timeout=%d\r\n' % keep_alive_timeout + if keep_alive and keep_alive_timeout is not None: + timeout_header = b'Keep-Alive: %d\r\n' % keep_alive_timeout + + body = b'' + if http.has_message_body(self.status): + body = self.body + self.headers['Content-Length'] = self.headers.get( + 'Content-Length', len(self.body)) + + self.headers['Content-Type'] = self.headers.get( + 'Content-Type', self.content_type) + + if self.status in (304, 412): + self.headers = http.remove_entity_headers(self.headers) + + headers = self._parse_headers() + + if self.status is 200: + status = b'OK' + else: + status = http.STATUS_CODES.get(self.status, b'UNKNOWN RESPONSE') - headers = b'' - if self.headers: - headers = b''.join( - b'%b: %b\r\n' % (name.encode(), value.encode('utf-8')) - for name, value in self.headers.items() - ) return (b'HTTP/%b %d %b\r\n' - b'Content-Type: %b\r\n' - b'Content-Length: %d\r\n' b'Connection: %b\r\n' - b'%b%b\r\n' + b'%b' + b'%b\r\n' b'%b') % ( - version.encode(), - self.status, - STATUS_CODES.get(self.status, b'FAIL'), - self.content_type.encode(), - len(self.body), - b'keep-alive' if keep_alive else b'close', - timeout_header, - headers, - self.body - ) + version.encode(), + self.status, + status, + b'keep-alive' if keep_alive else b'close', + timeout_header, + headers, + body + ) + + @property + def cookies(self): + if self._cookies is None: + self._cookies = CookieJar(self.headers) + return self._cookies -def json(body, status=200, headers=None): - return HTTPResponse(ujson.dumps(body), headers=headers, status=status, - content_type="application/json; charset=utf-8") +def json(body, status=200, headers=None, + content_type="application/json", dumps=json_dumps, + **kwargs): + """ + Returns response object with body in json format. + + :param body: Response data to be serialized. + :param status: Response code. + :param headers: Custom Headers. + :param kwargs: Remaining arguments that are passed to the json encoder. + """ + return HTTPResponse(dumps(body, **kwargs), headers=headers, + status=status, content_type=content_type) -def text(body, status=200, headers=None): - return HTTPResponse(body, status=status, headers=headers, - content_type="text/plain; charset=utf-8") +def text(body, status=200, headers=None, + content_type="text/plain; charset=utf-8"): + """ + Returns response object with body in text format. + + :param body: Response data to be encoded. + :param status: Response code. + :param headers: Custom Headers. + :param content_type: the content type (string) of the response + """ + return HTTPResponse( + body, status=status, headers=headers, + content_type=content_type) + + +def raw(body, status=200, headers=None, + content_type="application/octet-stream"): + """ + Returns response object without encoding the body. + + :param body: Response data. + :param status: Response code. + :param headers: Custom Headers. + :param content_type: the content type (string) of the response. + """ + return HTTPResponse(body_bytes=body, status=status, headers=headers, + content_type=content_type) def html(body, status=200, headers=None): + """ + Returns response object with body in html format. + + :param body: Response data to be encoded. + :param status: Response code. + :param headers: Custom Headers. + """ return HTTPResponse(body, status=status, headers=headers, content_type="text/html; charset=utf-8") + + +async def file(location, status=200, mime_type=None, headers=None, + filename=None, _range=None): + """Return a response object with file data. + + :param location: Location of file on system. + :param mime_type: Specific mime_type. + :param headers: Custom Headers. + :param filename: Override filename. + :param _range: + """ + headers = headers or {} + if filename: + headers.setdefault( + 'Content-Disposition', + 'attachment; filename="{}"'.format(filename)) + filename = filename or path.split(location)[-1] + + async with open_async(location, mode='rb') as _file: + if _range: + await _file.seek(_range.start) + out_stream = await _file.read(_range.size) + headers['Content-Range'] = 'bytes %s-%s/%s' % ( + _range.start, _range.end, _range.total) + else: + out_stream = await _file.read() + + mime_type = mime_type or guess_type(filename)[0] or 'text/plain' + return HTTPResponse(status=status, + headers=headers, + content_type=mime_type, + body_bytes=out_stream) + + +async def file_stream(location, status=200, chunk_size=4096, mime_type=None, + headers=None, filename=None, _range=None): + """Return a streaming response object with file data. + + :param location: Location of file on system. + :param chunk_size: The size of each chunk in the stream (in bytes) + :param mime_type: Specific mime_type. + :param headers: Custom Headers. + :param filename: Override filename. + :param _range: + """ + headers = headers or {} + if filename: + headers.setdefault( + 'Content-Disposition', + 'attachment; filename="{}"'.format(filename)) + filename = filename or path.split(location)[-1] + + _file = await open_async(location, mode='rb') + + async def _streaming_fn(response): + nonlocal _file, chunk_size + try: + if _range: + chunk_size = min((_range.size, chunk_size)) + await _file.seek(_range.start) + to_send = _range.size + while to_send > 0: + content = await _file.read(chunk_size) + if len(content) < 1: + break + to_send -= len(content) + response.write(content) + else: + while True: + content = await _file.read(chunk_size) + if len(content) < 1: + break + response.write(content) + finally: + await _file.close() + return # Returning from this fn closes the stream + + mime_type = mime_type or guess_type(filename)[0] or 'text/plain' + if _range: + headers['Content-Range'] = 'bytes %s-%s/%s' % ( + _range.start, _range.end, _range.total) + return StreamingHTTPResponse(streaming_fn=_streaming_fn, + status=status, + headers=headers, + content_type=mime_type) + + +def stream( + streaming_fn, status=200, headers=None, + content_type="text/plain; charset=utf-8"): + """Accepts an coroutine `streaming_fn` which can be used to + write chunks to a streaming response. Returns a `StreamingHTTPResponse`. + + Example usage:: + + @app.route("/") + async def index(request): + async def streaming_fn(response): + await response.write('foo') + await response.write('bar') + + return stream(streaming_fn, content_type='text/plain') + + :param streaming_fn: A coroutine accepts a response and + writes content to that response. + :param mime_type: Specific mime_type. + :param headers: Custom Headers. + """ + return StreamingHTTPResponse( + streaming_fn, + headers=headers, + content_type=content_type, + status=status + ) + + +def redirect(to, headers=None, status=302, + content_type="text/html; charset=utf-8"): + """Abort execution and cause a 302 redirect (by default). + + :param to: path or fully qualified URL to redirect to + :param headers: optional dict of headers to include in the new request + :param status: status code (int) of the new request, defaults to 302 + :param content_type: the content type (string) of the response + :returns: the redirecting Response + """ + headers = headers or {} + + # URL Quote the URL before redirecting + safe_to = quote_plus(to, safe=":/#?&=@[]!$&'()*+,;") + + # According to RFC 7231, a relative URI is now permitted. + headers['Location'] = safe_to + + return HTTPResponse( + status=status, + headers=headers, + content_type=content_type) diff --git a/sanic/router.py b/sanic/router.py index e6c580d7..2864bf84 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -1,146 +1,441 @@ import re -from collections import namedtuple -from .exceptions import NotFound, InvalidUsage +import uuid +from collections import defaultdict, namedtuple +from collections.abc import Iterable +from functools import lru_cache +from urllib.parse import unquote -Route = namedtuple("Route", ['handler', 'methods', 'pattern', 'parameters']) -Parameter = namedtuple("Parameter", ['name', 'cast']) +from sanic.exceptions import NotFound, MethodNotSupported +from sanic.views import CompositionView + +Route = namedtuple( + 'Route', + ['handler', 'methods', 'pattern', 'parameters', 'name', 'uri']) +Parameter = namedtuple('Parameter', ['name', 'cast']) + +REGEX_TYPES = { + 'string': (str, r'[^/]+'), + 'int': (int, r'\d+'), + 'number': (float, r'[0-9\\.]+'), + 'alpha': (str, r'[A-Za-z]+'), + 'path': (str, r'[^/].*?'), + 'uuid': (uuid.UUID, r'[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-' + r'[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}') +} + +ROUTER_CACHE_SIZE = 1024 + + +def url_hash(url): + return url.count('/') + + +class RouteExists(Exception): + pass + + +class RouteDoesNotExist(Exception): + pass class Router: - """ - Router supports basic routing with parameters and method checks + """Router supports basic routing with parameters and method checks + Usage: - @sanic.route('/my/url/+ ▄▄▄▄▄ + ▀▀▀██████▄▄▄ _______________ + ▄▄▄▄▄ █████████▄ / \ + ▀▀▀▀█████▌ ▀▐▄ ▀▐█ | Gotta go fast! | + ▀▀█████▄▄ ▀██████▄██ | _________________/ + ▀▄▄▄▄▄ ▀▀█▄▀█════█▀ |/ + ▀▀▀▄ ▀▀███ ▀ ▄▄ + ▄███▀▀██▄████████▄ ▄▀▀▀▀▀▀█▌ + ██▀▄▄▄██▀▄███▀ ▀▀████ ▄██ +▄▀▀▀▄██▄▀▀▌████▒▒▒▒▒▒███ ▌▄▄▀ +▌ ▐▀████▐███▒▒▒▒▒▐██▌ +▀▄▄▄▄▀ ▀▀████▒▒▒▒▄██▀ + ▀▀█████████▀ + ▄▄██▀██████▀█ + ▄██▀ ▀▀▀ █ + ▄█ ▐▌ + ▄▄▄▄█▌ ▀█▄▄▄▄▀▀▄ +▌ ▐ ▀▀▄▄▄▀ + ▀▀▄▄▀ + ++ + diff --git a/tests/test_bad_request.py b/tests/test_bad_request.py new file mode 100644 index 00000000..bf595085 --- /dev/null +++ b/tests/test_bad_request.py @@ -0,0 +1,21 @@ +import asyncio +from sanic import Sanic + + +def test_bad_request_response(): + app = Sanic('test_bad_request_response') + lines = [] + @app.listener('after_server_start') + async def _request(sanic, loop): + connect = asyncio.open_connection('127.0.0.1', 42101) + reader, writer = await connect + writer.write(b'not http') + while True: + line = await reader.readline() + if not line: + break + lines.append(line) + app.stop() + app.run(host='127.0.0.1', port=42101, debug=False) + assert lines[0] == b'HTTP/1.1 400 Bad Request\r\n' + assert lines[-1] == b'Error: Bad Request' diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 8068160f..37756085 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -1,14 +1,51 @@ +import asyncio +import inspect +import os +import pytest + from sanic import Sanic from sanic.blueprints import Blueprint from sanic.response import json, text -from sanic.utils import sanic_endpoint_test from sanic.exceptions import NotFound, ServerError, InvalidUsage +from sanic.constants import HTTP_METHODS # ------------------------------------------------------------ # # GET # ------------------------------------------------------------ # +def get_file_path(static_file_directory, file_name): + return os.path.join(static_file_directory, file_name) + +def get_file_content(static_file_directory, file_name): + """The content of the static file to check""" + with open(get_file_path(static_file_directory, file_name), 'rb') as file: + return file.read() + +@pytest.mark.parametrize('method', HTTP_METHODS) +def test_versioned_routes_get(method): + app = Sanic('test_shorhand_routes_get') + bp = Blueprint('test_text') + + method = method.lower() + + func = getattr(bp, method) + if callable(func): + @func('/{}'.format(method), version=1) + def handler(request): + return text('OK') + else: + print(func) + raise + + app.blueprint(bp) + + client_method = getattr(app.test_client, method) + + request, response = client_method('/v1/{}'.format(method)) + assert response.status == 200 + + def test_bp(): app = Sanic('test_text') bp = Blueprint('test_text') @@ -17,11 +54,99 @@ def test_bp(): def handler(request): return text('Hello') - app.register_blueprint(bp) - request, response = sanic_endpoint_test(app) + app.blueprint(bp) + request, response = app.test_client.get('/') + assert app.is_request_stream is False assert response.text == 'Hello' +def test_bp_strict_slash(): + app = Sanic('test_route_strict_slash') + bp = Blueprint('test_text') + + @bp.get('/get', strict_slashes=True) + def handler(request): + return text('OK') + + @bp.post('/post/', strict_slashes=True) + def handler(request): + return text('OK') + + app.blueprint(bp) + + request, response = app.test_client.get('/get') + assert response.text == 'OK' + assert response.json == None + + request, response = app.test_client.get('/get/') + assert response.status == 404 + + request, response = app.test_client.post('/post/') + assert response.text == 'OK' + + request, response = app.test_client.post('/post') + assert response.status == 404 + +def test_bp_strict_slash_default_value(): + app = Sanic('test_route_strict_slash') + bp = Blueprint('test_text', strict_slashes=True) + + @bp.get('/get') + def handler(request): + return text('OK') + + @bp.post('/post/') + def handler(request): + return text('OK') + + app.blueprint(bp) + + request, response = app.test_client.get('/get/') + assert response.status == 404 + + request, response = app.test_client.post('/post') + assert response.status == 404 + +def test_bp_strict_slash_without_passing_default_value(): + app = Sanic('test_route_strict_slash') + bp = Blueprint('test_text') + + @bp.get('/get') + def handler(request): + return text('OK') + + @bp.post('/post/') + def handler(request): + return text('OK') + + app.blueprint(bp) + + request, response = app.test_client.get('/get/') + assert response.text == 'OK' + + request, response = app.test_client.post('/post') + assert response.text == 'OK' + +def test_bp_strict_slash_default_value_can_be_overwritten(): + app = Sanic('test_route_strict_slash') + bp = Blueprint('test_text', strict_slashes=True) + + @bp.get('/get', strict_slashes=False) + def handler(request): + return text('OK') + + @bp.post('/post/', strict_slashes=False) + def handler(request): + return text('OK') + + app.blueprint(bp) + + request, response = app.test_client.get('/get/') + assert response.text == 'OK' + + request, response = app.test_client.post('/post') + assert response.text == 'OK' + def test_bp_with_url_prefix(): app = Sanic('test_text') bp = Blueprint('test_text', url_prefix='/test1') @@ -30,8 +155,8 @@ def test_bp_with_url_prefix(): def handler(request): return text('Hello') - app.register_blueprint(bp) - request, response = sanic_endpoint_test(app, uri='/test1/') + app.blueprint(bp) + request, response = app.test_client.get('/test1/') assert response.text == 'Hello' @@ -49,14 +174,84 @@ def test_several_bp_with_url_prefix(): def handler2(request): return text('Hello2') - app.register_blueprint(bp) - app.register_blueprint(bp2) - request, response = sanic_endpoint_test(app, uri='/test1/') + app.blueprint(bp) + app.blueprint(bp2) + request, response = app.test_client.get('/test1/') assert response.text == 'Hello' - request, response = sanic_endpoint_test(app, uri='/test2/') + request, response = app.test_client.get('/test2/') assert response.text == 'Hello2' +def test_bp_with_host(): + app = Sanic('test_bp_host') + bp = Blueprint('test_bp_host', url_prefix='/test1', host="example.com") + + @bp.route('/') + def handler(request): + return text('Hello') + + @bp.route('/', host="sub.example.com") + def handler(request): + return text('Hello subdomain!') + + app.blueprint(bp) + headers = {"Host": "example.com"} + request, response = app.test_client.get( + '/test1/', + headers=headers) + assert response.text == 'Hello' + + headers = {"Host": "sub.example.com"} + request, response = app.test_client.get( + '/test1/', + headers=headers) + + assert response.text == 'Hello subdomain!' + + +def test_several_bp_with_host(): + app = Sanic('test_text') + bp = Blueprint('test_text', + url_prefix='/test', + host="example.com") + bp2 = Blueprint('test_text2', + url_prefix='/test', + host="sub.example.com") + + @bp.route('/') + def handler(request): + return text('Hello') + + @bp2.route('/') + def handler2(request): + return text('Hello2') + + @bp2.route('/other/') + def handler2(request): + return text('Hello3') + + + app.blueprint(bp) + app.blueprint(bp2) + + assert bp.host == "example.com" + headers = {"Host": "example.com"} + request, response = app.test_client.get( + '/test/', + headers=headers) + assert response.text == 'Hello' + + assert bp2.host == "sub.example.com" + headers = {"Host": "sub.example.com"} + request, response = app.test_client.get( + '/test/', + headers=headers) + + assert response.text == 'Hello2' + request, response = app.test_client.get( + '/test/other/', + headers=headers) + assert response.text == 'Hello3' def test_bp_middleware(): app = Sanic('test_middleware') @@ -70,9 +265,9 @@ def test_bp_middleware(): async def handler(request): return text('FAIL') - app.register_blueprint(blueprint) + app.blueprint(blueprint) - request, response = sanic_endpoint_test(app) + request, response = app.test_client.get('/') assert response.status == 200 assert response.text == 'OK' @@ -97,15 +292,229 @@ def test_bp_exception_handler(): def handler_exception(request, exception): return text("OK") - app.register_blueprint(blueprint) + app.blueprint(blueprint) - request, response = sanic_endpoint_test(app, uri='/1') + request, response = app.test_client.get('/1') assert response.status == 400 - request, response = sanic_endpoint_test(app, uri='/2') + request, response = app.test_client.get('/2') assert response.status == 200 assert response.text == 'OK' - request, response = sanic_endpoint_test(app, uri='/3') - assert response.status == 200 \ No newline at end of file + request, response = app.test_client.get('/3') + assert response.status == 200 + +def test_bp_listeners(): + app = Sanic('test_middleware') + blueprint = Blueprint('test_middleware') + + order = [] + + @blueprint.listener('before_server_start') + def handler_1(sanic, loop): + order.append(1) + + @blueprint.listener('after_server_start') + def handler_2(sanic, loop): + order.append(2) + + @blueprint.listener('after_server_start') + def handler_3(sanic, loop): + order.append(3) + + @blueprint.listener('before_server_stop') + def handler_4(sanic, loop): + order.append(5) + + @blueprint.listener('before_server_stop') + def handler_5(sanic, loop): + order.append(4) + + @blueprint.listener('after_server_stop') + def handler_6(sanic, loop): + order.append(6) + + app.blueprint(blueprint) + + request, response = app.test_client.get('/') + + assert order == [1,2,3,4,5,6] + +def test_bp_static(): + current_file = inspect.getfile(inspect.currentframe()) + with open(current_file, 'rb') as file: + current_file_contents = file.read() + + app = Sanic('test_static') + blueprint = Blueprint('test_static') + + blueprint.static('/testing.file', current_file) + + app.blueprint(blueprint) + + request, response = app.test_client.get('/testing.file') + assert response.status == 200 + assert response.body == current_file_contents + +@pytest.mark.parametrize('file_name', ['test.html']) +def test_bp_static_content_type(file_name): + # This is done here, since no other test loads a file here + current_file = inspect.getfile(inspect.currentframe()) + current_directory = os.path.dirname(os.path.abspath(current_file)) + static_directory = os.path.join(current_directory, 'static') + + app = Sanic('test_static') + blueprint = Blueprint('test_static') + blueprint.static( + '/testing.file', + get_file_path(static_directory, file_name), + content_type='text/html; charset=utf-8' + ) + + app.blueprint(blueprint) + + request, response = app.test_client.get('/testing.file') + assert response.status == 200 + assert response.body == get_file_content(static_directory, file_name) + assert response.headers['Content-Type'] == 'text/html; charset=utf-8' + +def test_bp_shorthand(): + app = Sanic('test_shorhand_routes') + blueprint = Blueprint('test_shorhand_routes') + ev = asyncio.Event() + + @blueprint.get('/get') + def handler(request): + assert request.stream is None + return text('OK') + + @blueprint.put('/put') + def handler(request): + assert request.stream is None + return text('OK') + + @blueprint.post('/post') + def handler(request): + assert request.stream is None + return text('OK') + + @blueprint.head('/head') + def handler(request): + assert request.stream is None + return text('OK') + + @blueprint.options('/options') + def handler(request): + assert request.stream is None + return text('OK') + + @blueprint.patch('/patch') + def handler(request): + assert request.stream is None + return text('OK') + + @blueprint.delete('/delete') + def handler(request): + assert request.stream is None + return text('OK') + + @blueprint.websocket('/ws') + async def handler(request, ws): + assert request.stream is None + ev.set() + + app.blueprint(blueprint) + + assert app.is_request_stream is False + + request, response = app.test_client.get('/get') + assert response.text == 'OK' + + request, response = app.test_client.post('/get') + assert response.status == 405 + + request, response = app.test_client.put('/put') + assert response.text == 'OK' + + request, response = app.test_client.get('/post') + assert response.status == 405 + + request, response = app.test_client.post('/post') + assert response.text == 'OK' + + request, response = app.test_client.get('/post') + assert response.status == 405 + + request, response = app.test_client.head('/head') + assert response.status == 200 + + request, response = app.test_client.get('/head') + assert response.status == 405 + + request, response = app.test_client.options('/options') + assert response.text == 'OK' + + request, response = app.test_client.get('/options') + assert response.status == 405 + + request, response = app.test_client.patch('/patch') + assert response.text == 'OK' + + request, response = app.test_client.get('/patch') + assert response.status == 405 + + request, response = app.test_client.delete('/delete') + assert response.text == 'OK' + + request, response = app.test_client.get('/delete') + assert response.status == 405 + + request, response = app.test_client.get('/ws', headers={ + 'Upgrade': 'websocket', + 'Connection': 'upgrade', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': '13'}) + assert response.status == 101 + assert ev.is_set() + +def test_bp_group(): + app = Sanic('test_nested_bp_groups') + + deep_0 = Blueprint('deep_0', url_prefix='/deep') + deep_1 = Blueprint('deep_1', url_prefix = '/deep1') + + @deep_0.route('/') + def handler(request): + return text('D0_OK') + + @deep_1.route('/bottom') + def handler(request): + return text('D1B_OK') + + mid_0 = Blueprint.group(deep_0, deep_1, url_prefix='/mid') + mid_1 = Blueprint('mid_tier', url_prefix='/mid1') + + @mid_1.route('/') + def handler(request): + return text('M1_OK') + + top = Blueprint.group(mid_0, mid_1) + + app.blueprint(top) + + @app.route('/') + def handler(request): + return text('TOP_OK') + + request, response = app.test_client.get('/') + assert response.text == 'TOP_OK' + + request, response = app.test_client.get('/mid1') + assert response.text == 'M1_OK' + + request, response = app.test_client.get('/mid/deep') + assert response.text == 'D0_OK' + + request, response = app.test_client.get('/mid/deep1/bottom') + assert response.text == 'D1B_OK' diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..e393d02b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,93 @@ +from os import environ +import pytest +from tempfile import NamedTemporaryFile + +from sanic import Sanic + + +def test_load_from_object(): + app = Sanic('test_load_from_object') + class Config: + not_for_config = 'should not be used' + CONFIG_VALUE = 'should be used' + + app.config.from_object(Config) + assert 'CONFIG_VALUE' in app.config + assert app.config.CONFIG_VALUE == 'should be used' + assert 'not_for_config' not in app.config + +def test_auto_load_env(): + environ["SANIC_TEST_ANSWER"] = "42" + app = Sanic() + assert app.config.TEST_ANSWER == 42 + del environ["SANIC_TEST_ANSWER"] + +def test_dont_load_env(): + environ["SANIC_TEST_ANSWER"] = "42" + app = Sanic(load_env=False) + assert getattr(app.config, 'TEST_ANSWER', None) == None + del environ["SANIC_TEST_ANSWER"] + +def test_load_env_prefix(): + environ["MYAPP_TEST_ANSWER"] = "42" + app = Sanic(load_env='MYAPP_') + assert app.config.TEST_ANSWER == 42 + del environ["MYAPP_TEST_ANSWER"] + +def test_load_from_file(): + app = Sanic('test_load_from_file') + config = b""" +VALUE = 'some value' +condition = 1 == 1 +if condition: + CONDITIONAL = 'should be set' + """ + with NamedTemporaryFile() as config_file: + config_file.write(config) + config_file.seek(0) + app.config.from_pyfile(config_file.name) + assert 'VALUE' in app.config + assert app.config.VALUE == 'some value' + assert 'CONDITIONAL' in app.config + assert app.config.CONDITIONAL == 'should be set' + assert 'condition' not in app.config + + +def test_load_from_missing_file(): + app = Sanic('test_load_from_missing_file') + with pytest.raises(IOError): + app.config.from_pyfile('non-existent file') + + +def test_load_from_envvar(): + app = Sanic('test_load_from_envvar') + config = b"VALUE = 'some value'" + with NamedTemporaryFile() as config_file: + config_file.write(config) + config_file.seek(0) + environ['APP_CONFIG'] = config_file.name + app.config.from_envvar('APP_CONFIG') + assert 'VALUE' in app.config + assert app.config.VALUE == 'some value' + + +def test_load_from_missing_envvar(): + app = Sanic('test_load_from_missing_envvar') + with pytest.raises(RuntimeError): + app.config.from_envvar('non-existent variable') + + +def test_overwrite_exisiting_config(): + app = Sanic('test_overwrite_exisiting_config') + app.config.DEFAULT = 1 + class Config: + DEFAULT = 2 + + app.config.from_object(Config) + assert app.config.DEFAULT == 2 + + +def test_missing_config(): + app = Sanic('test_missing_config') + with pytest.raises(AttributeError): + app.config.NON_EXISTENT diff --git a/tests/test_cookies.py b/tests/test_cookies.py new file mode 100644 index 00000000..61f50735 --- /dev/null +++ b/tests/test_cookies.py @@ -0,0 +1,116 @@ +from datetime import datetime, timedelta +from http.cookies import SimpleCookie +from sanic import Sanic +from sanic.response import json, text +import pytest + + +# ------------------------------------------------------------ # +# GET +# ------------------------------------------------------------ # + +def test_cookies(): + app = Sanic('test_text') + + @app.route('/') + def handler(request): + response = text('Cookies are: {}'.format(request.cookies['test'])) + response.cookies['right_back'] = 'at you' + return response + + request, response = app.test_client.get('/', cookies={"test": "working!"}) + response_cookies = SimpleCookie() + response_cookies.load(response.headers.get('Set-Cookie', {})) + + assert response.text == 'Cookies are: working!' + assert response_cookies['right_back'].value == 'at you' + + +@pytest.mark.parametrize("httponly,expected", [ + (False, False), + (True, True), +]) +def test_false_cookies_encoded(httponly, expected): + app = Sanic('test_text') + + @app.route('/') + def handler(request): + response = text('hello cookies') + response.cookies['hello'] = 'world' + response.cookies['hello']['httponly'] = httponly + return text(response.cookies['hello'].encode('utf8')) + + request, response = app.test_client.get('/') + + assert ('HttpOnly' in response.text) == expected + + +@pytest.mark.parametrize("httponly,expected", [ + (False, False), + (True, True), +]) +def test_false_cookies(httponly, expected): + app = Sanic('test_text') + + @app.route('/') + def handler(request): + response = text('hello cookies') + response.cookies['right_back'] = 'at you' + response.cookies['right_back']['httponly'] = httponly + return response + + request, response = app.test_client.get('/') + response_cookies = SimpleCookie() + response_cookies.load(response.headers.get('Set-Cookie', {})) + + assert ('HttpOnly' in response_cookies['right_back'].output()) == expected + +def test_http2_cookies(): + app = Sanic('test_http2_cookies') + + @app.route('/') + async def handler(request): + response = text('Cookies are: {}'.format(request.cookies['test'])) + return response + + headers = {'cookie': 'test=working!'} + request, response = app.test_client.get('/', headers=headers) + + assert response.text == 'Cookies are: working!' + +def test_cookie_options(): + app = Sanic('test_text') + + @app.route('/') + def handler(request): + response = text("OK") + response.cookies['test'] = 'at you' + response.cookies['test']['httponly'] = True + response.cookies['test']['expires'] = datetime.now() + timedelta(seconds=10) + return response + + request, response = app.test_client.get('/') + response_cookies = SimpleCookie() + response_cookies.load(response.headers.get('Set-Cookie', {})) + + assert response_cookies['test'].value == 'at you' + assert response_cookies['test']['httponly'] == True + +def test_cookie_deletion(): + app = Sanic('test_text') + + @app.route('/') + def handler(request): + response = text("OK") + del response.cookies['i_want_to_die'] + response.cookies['i_never_existed'] = 'testing' + del response.cookies['i_never_existed'] + return response + + request, response = app.test_client.get('/') + response_cookies = SimpleCookie() + response_cookies.load(response.headers.get('Set-Cookie', {})) + + assert int(response_cookies['i_want_to_die']['max-age']) == 0 + with pytest.raises(KeyError): + hold_my_beer = response.cookies['i_never_existed'] diff --git a/tests/test_create_task.py b/tests/test_create_task.py new file mode 100644 index 00000000..1517ca8c --- /dev/null +++ b/tests/test_create_task.py @@ -0,0 +1,47 @@ +from sanic import Sanic +from sanic.response import text +from threading import Event +import asyncio +from queue import Queue + + +def test_create_task(): + e = Event() + + async def coro(): + await asyncio.sleep(0.05) + e.set() + + app = Sanic('test_create_task') + app.add_task(coro) + + @app.route('/early') + def not_set(request): + return text(e.is_set()) + + @app.route('/late') + async def set(request): + await asyncio.sleep(0.1) + return text(e.is_set()) + + request, response = app.test_client.get('/early') + assert response.body == b'False' + + request, response = app.test_client.get('/late') + assert response.body == b'True' + +def test_create_task_with_app_arg(): + app = Sanic('test_add_task') + q = Queue() + + @app.route('/') + def not_set(request): + return "hello" + + async def coro(app): + q.put(app.name) + + app.add_task(coro) + + request, response = app.test_client.get('/') + assert q.get() == 'test_add_task' diff --git a/tests/test_custom_protocol.py b/tests/test_custom_protocol.py new file mode 100644 index 00000000..74564012 --- /dev/null +++ b/tests/test_custom_protocol.py @@ -0,0 +1,31 @@ +from sanic import Sanic +from sanic.server import HttpProtocol +from sanic.response import text + +app = Sanic('test_custom_porotocol') + + +class CustomHttpProtocol(HttpProtocol): + + def write_response(self, response): + if isinstance(response, str): + response = text(response) + self.transport.write( + response.output(self.request.version) + ) + self.transport.close() + + +@app.route('/1') +async def handler_1(request): + return 'OK' + + +def test_use_custom_protocol(): + server_kwargs = { + 'protocol': CustomHttpProtocol + } + request, response = app.test_client.get( + '/1', server_kwargs=server_kwargs) + assert response.status == 200 + assert response.text == 'OK' diff --git a/tests/test_dynamic_routes.py b/tests/test_dynamic_routes.py new file mode 100644 index 00000000..950584a8 --- /dev/null +++ b/tests/test_dynamic_routes.py @@ -0,0 +1,44 @@ +from sanic import Sanic +from sanic.response import text +from sanic.router import RouteExists +import pytest + + +@pytest.mark.parametrize("method,attr, expected", [ + ("get", "text", "OK1 test"), + ("post", "text", "OK2 test"), + ("put", "text", "OK2 test"), + ("delete", "status", 405), +]) +def test_overload_dynamic_routes(method, attr, expected): + app = Sanic('test_dynamic_route') + + @app.route('/overload/', methods=['GET']) + async def handler1(request, param): + return text('OK1 ' + param) + + @app.route('/overload/', methods=['POST', 'PUT']) + async def handler2(request, param): + return text('OK2 ' + param) + + request, response = getattr(app.test_client, method)('/overload/test') + assert getattr(response, attr) == expected + + +def test_overload_dynamic_routes_exist(): + app = Sanic('test_dynamic_route') + + @app.route('/overload/', methods=['GET']) + async def handler1(request, param): + return text('OK1 ' + param) + + @app.route('/overload/', methods=['POST', 'PUT']) + async def handler2(request, param): + return text('OK2 ' + param) + + # if this doesn't raise an error, than at least the below should happen: + # assert response.text == 'Duplicated' + with pytest.raises(RouteExists): + @app.route('/overload/', methods=['PUT', 'DELETE']) + async def handler3(request): + return text('Duplicated') diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 28e766cd..b4e2c6ea 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,51 +1,204 @@ +import pytest +from bs4 import BeautifulSoup + from sanic import Sanic from sanic.response import text -from sanic.exceptions import InvalidUsage, ServerError, NotFound -from sanic.utils import sanic_endpoint_test - -# ------------------------------------------------------------ # -# GET -# ------------------------------------------------------------ # - -exception_app = Sanic('test_exceptions') +from sanic.exceptions import InvalidUsage, ServerError, NotFound, Unauthorized +from sanic.exceptions import Forbidden, abort -@exception_app.route('/') -def handler(request): - return text('OK') +class SanicExceptionTestException(Exception): + pass -@exception_app.route('/error') -def handler_error(request): - raise ServerError("OK") +@pytest.fixture(scope='module') +def exception_app(): + app = Sanic('test_exceptions') + + @app.route('/') + def handler(request): + return text('OK') + + @app.route('/error') + def handler_error(request): + raise ServerError("OK") + + @app.route('/404') + def handler_404(request): + raise NotFound("OK") + + @app.route('/403') + def handler_403(request): + raise Forbidden("Forbidden") + + @app.route('/401') + def handler_401(request): + raise Unauthorized("Unauthorized") + + @app.route('/401/basic') + def handler_401_basic(request): + raise Unauthorized("Unauthorized", scheme="Basic", realm="Sanic") + + @app.route('/401/digest') + def handler_401_digest(request): + raise Unauthorized("Unauthorized", + scheme="Digest", + realm="Sanic", + qop="auth, auth-int", + algorithm="MD5", + nonce="abcdef", + opaque="zyxwvu") + + @app.route('/401/bearer') + def handler_401_bearer(request): + raise Unauthorized("Unauthorized", scheme="Bearer") + + @app.route('/invalid') + def handler_invalid(request): + raise InvalidUsage("OK") + + @app.route('/abort/401') + def handler_401_error(request): + abort(401) + + @app.route('/abort') + def handler_500_error(request): + abort(500) + return text("OK") + + @app.route('/divide_by_zero') + def handle_unhandled_exception(request): + 1 / 0 + + @app.route('/error_in_error_handler_handler') + def custom_error_handler(request): + raise SanicExceptionTestException('Dummy message!') + + @app.exception(SanicExceptionTestException) + def error_in_error_handler_handler(request, exception): + 1 / 0 + + return app -@exception_app.route('/404') -def handler_404(request): - raise NotFound("OK") +def test_catch_exception_list(): + app = Sanic('exception_list') + + @app.exception([SanicExceptionTestException, NotFound]) + def exception_list(request, exception): + return text("ok") + + @app.route('/') + def exception(request): + raise SanicExceptionTestException("You won't see me") + + request, response = app.test_client.get('/random') + assert response.text == 'ok' + + request, response = app.test_client.get('/') + assert response.text == 'ok' -@exception_app.route('/invalid') -def handler_invalid(request): - raise InvalidUsage("OK") - - -def test_no_exception(): - request, response = sanic_endpoint_test(exception_app) +def test_no_exception(exception_app): + """Test that a route works without an exception""" + request, response = exception_app.test_client.get('/') assert response.status == 200 assert response.text == 'OK' -def test_server_error_exception(): - request, response = sanic_endpoint_test(exception_app, uri='/error') +def test_server_error_exception(exception_app): + """Test the built-in ServerError exception works""" + request, response = exception_app.test_client.get('/error') assert response.status == 500 -def test_invalid_usage_exception(): - request, response = sanic_endpoint_test(exception_app, uri='/invalid') +def test_invalid_usage_exception(exception_app): + """Test the built-in InvalidUsage exception works""" + request, response = exception_app.test_client.get('/invalid') assert response.status == 400 -def test_not_found_exception(): - request, response = sanic_endpoint_test(exception_app, uri='/404') +def test_not_found_exception(exception_app): + """Test the built-in NotFound exception works""" + request, response = exception_app.test_client.get('/404') 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') + assert response.status == 401 + + 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 + + request, response = exception_app.test_client.get('/401/bearer') + assert response.status == 401 + assert response.headers.get('WWW-Authenticate') == "Bearer" + + +def test_handled_unhandled_exception(exception_app): + """Test that an exception not built into sanic is handled""" + request, response = exception_app.test_client.get('/divide_by_zero') + assert response.status == 500 + soup = BeautifulSoup(response.body, 'html.parser') + assert soup.h1.text == 'Internal Server Error' + + message = " ".join(soup.p.text.split()) + assert message == ( + "The server encountered an internal error and " + "cannot complete your request.") + + +def test_exception_in_exception_handler(exception_app): + """Test that an exception thrown in an error handler is handled""" + request, response = exception_app.test_client.get( + '/error_in_error_handler_handler') + assert response.status == 500 + assert response.body == b'An error occurred while handling an error' + + +def test_exception_in_exception_handler_debug_off(exception_app): + """Test that an exception thrown in an error handler is handled""" + request, response = exception_app.test_client.get( + '/error_in_error_handler_handler', + debug=False) + assert response.status == 500 + assert response.body == b'An error occurred while handling an error' + + +def test_exception_in_exception_handler_debug_on(exception_app): + """Test that an exception thrown in an error handler is handled""" + request, response = exception_app.test_client.get( + '/error_in_error_handler_handler', + debug=True) + assert response.status == 500 + assert response.body.startswith(b'Exception raised in exception ') + + +def test_abort(exception_app): + """Test the abort function""" + request, response = exception_app.test_client.get('/abort/401') + assert response.status == 401 + + request, response = exception_app.test_client.get('/abort') + assert response.status == 500 diff --git a/tests/test_exceptions_handler.py b/tests/test_exceptions_handler.py index 2e8bc359..6a959382 100644 --- a/tests/test_exceptions_handler.py +++ b/tests/test_exceptions_handler.py @@ -1,7 +1,8 @@ from sanic import Sanic from sanic.response import text from sanic.exceptions import InvalidUsage, ServerError, NotFound -from sanic.utils import sanic_endpoint_test +from sanic.handlers import ErrorHandler +from bs4 import BeautifulSoup exception_handler_app = Sanic('test_exception_handler') @@ -21,29 +22,133 @@ def handler_3(request): raise NotFound("OK") +@exception_handler_app.route('/4') +def handler_4(request): + foo = bar # noqa -- F821 undefined name 'bar' is done to throw exception + return text(foo) + + +@exception_handler_app.route('/5') +def handler_5(request): + class CustomServerError(ServerError): + pass + raise CustomServerError('Custom server error') + + +@exception_handler_app.route('/6/