├── .github └── dependabot.yml ├── .gitignore ├── .travis.yml ├── MANIFEST.in ├── Makefile ├── README.md ├── benchmarks ├── README.md ├── aiohttp │ ├── app.py │ ├── gunicorn_conf.py │ └── run.sh ├── bench.sh ├── fabfile.py ├── falcon │ ├── app.py │ ├── gunicorn_conf.py │ └── run.sh ├── native │ ├── app.py │ └── run.sh ├── requirements.txt ├── roll │ ├── app.py │ ├── gunicorn_conf.py │ └── run.sh ├── sanic │ ├── app.py │ └── run.sh ├── starlette │ ├── app.py │ ├── gunicorn_conf.py │ └── run.sh ├── trinket │ ├── app.py │ └── run.sh └── uvicorn │ ├── app.py │ └── run.sh ├── conftest.py ├── docs ├── changelog.md ├── discussions.md ├── how-to │ ├── advanced.md │ ├── basic.md │ └── developing.md ├── index.md ├── reference │ ├── core.md │ ├── events.md │ └── extensions.md └── tutorials.md ├── examples ├── basic │ ├── __init__.py │ ├── __main__.py │ └── tests.py ├── fullasync │ ├── __init__.py │ ├── __main__.py │ └── tests.py ├── html │ ├── __init__.py │ ├── __main__.py │ ├── templates │ │ ├── base.html │ │ └── home.html │ └── tests.py ├── streamresponse │ ├── __init__.py │ ├── __main__.py │ ├── crowd-cheering.mp3 │ └── tests.py └── websocket │ ├── __init__.py │ ├── __main__.py │ └── html │ └── websocket.html ├── mkdocs.yml ├── requirements-dev.txt ├── requirements.txt ├── roll ├── __init__.py ├── extensions.py ├── http.py ├── io.py ├── testing.py ├── websocket.py └── worker.py ├── setup.py └── tests ├── static ├── index.html ├── style.css └── sub │ └── index.html ├── test_class_views.py ├── test_errors.py ├── test_extensions.py ├── test_hooks.py ├── test_named_url.py ├── test_request.py ├── test_response.py ├── test_views.py ├── test_websockets.py └── test_websockets_failure.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "11:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: aiohttp 11 | - dependency-name: falcon 12 | - dependency-name: gunicorn 13 | - dependency-name: httpie 14 | - dependency-name: sanic 15 | - dependency-name: starlette 16 | - dependency-name: trinket 17 | - dependency-name: uvicorn 18 | - dependency-name: uvloop 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.egg-info 3 | .cache 4 | benchmarks/*/*.log 5 | build/ 6 | dist/ 7 | *.c 8 | *.so 9 | .pytest_cache/ 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.6 4 | - 3.7 5 | - 3.8 6 | - 3.9 7 | install: 8 | - python setup.py develop 9 | - pip install -r requirements-dev.txt 10 | script: py.test -vv 11 | branches: 12 | only: 13 | - master 14 | notifications: 15 | email: 16 | on_failure: always 17 | on_success: never 18 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include requirements.txt 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | develop: 2 | pip install -e . 3 | dist: 4 | python setup.py sdist bdist_wheel 5 | upload: 6 | twine upload dist/* 7 | clean: 8 | rm -rf *.egg-info/ dist/ build/ 9 | test: 10 | pytest -vx 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Let’s roll. 2 | 3 | ## Philosophy 4 | 5 | Async, simple, fast: pick three! Roll is a pico framework with 6 | performances and aesthetic in mind. 7 | 8 | 9 | ## Documentation 10 | 11 | Check out [the latest documentation](http://roll.readthedocs.io/en/latest/) 12 | for an extended overview of what can and cannot be achieved. 13 | -------------------------------------------------------------------------------- /benchmarks/README.md: -------------------------------------------------------------------------------- 1 | ## Running locally 2 | 3 | Create a venv, install requirements.txt dependencies, and then run: 4 | 5 | ./bench.sh 6 | ./bench.sh --frameworks "roll sanic" # To run only roll and sanic. 7 | ./bench.sh --frameworks "roll sanic" --endpoint full # To run only roll and 8 | # sanic with "full" endpoint. 9 | ./bench.sh --frameworks "roll sanic" --workers 2 # To run only roll and sanic 10 | # with 2 processes. 11 | 12 | 13 | ## Running remotely 14 | 15 | You need Fabric v2 branch: 16 | 17 | pip install git+git://github.com/fabric/fabric@v2 18 | 19 | then bootstrap the server: 20 | 21 | fab -eH user@ip.ip.ip.ip bootstrap 22 | 23 | then run the benchmarks: 24 | 25 | fab -eH user@ip.ip.ip.ip bench 26 | fab -eH user@ip.ip.ip.ip bench --tools ab --names "roll sanic" 27 | -------------------------------------------------------------------------------- /benchmarks/aiohttp/app.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | 3 | 4 | async def minimal(request): 5 | return web.json_response({'message': 'Hello, World!'}) 6 | 7 | 8 | async def parameter(request): 9 | parameter = request.match_info.get('parameter', '') 10 | return web.json_response({'message': parameter}) 11 | 12 | 13 | app = web.Application() 14 | app.router.add_get('/hello/minimal', minimal) 15 | app.router.add_get('/hello/with/{parameter}', parameter) 16 | -------------------------------------------------------------------------------- /benchmarks/aiohttp/gunicorn_conf.py: -------------------------------------------------------------------------------- 1 | worker_class = 'aiohttp.GunicornWebWorker' 2 | loglevel = 'critical' 3 | -------------------------------------------------------------------------------- /benchmarks/aiohttp/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | exec gunicorn app:app --workers $WORKERS --threads $WORKERS --config gunicorn_conf.py 4 | -------------------------------------------------------------------------------- /benchmarks/bench.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | while [[ "$#" > 1 ]]; do case $1 in 4 | --frameworks) FRAMEWORKS="$2";; 5 | --endpoint) ENDPOINT="$2";; 6 | --workers) WORKERS="$2";; 7 | *) break;; 8 | esac; shift; shift 9 | done 10 | 11 | WORKERS=${WORKERS:-1} 12 | ENDPOINT=${ENDPOINT:-minimal} 13 | FRAMEWORKS=${FRAMEWORKS:-sanic roll} 14 | 15 | 16 | function run_minimal() { 17 | URL="http://127.0.0.1:8000/hello/minimal" 18 | http $URL 19 | time wrk -t20 -c100 -d10s $URL | tee $NAME/wrk.log 20 | } 21 | 22 | function run_parameter() { 23 | URL="http://127.0.0.1:8000/hello/with/foobar" 24 | http $URL 25 | time wrk -t20 -c100 -d10s $URL | tee $NAME/wrk.log 26 | } 27 | 28 | function run_cookie() { 29 | URL="http://127.0.0.1:8000/hello/cookie" 30 | http $URL Cookie:"test=bench" 31 | time wrk -t20 -c100 -d10s -H "Cookie: test=bench" $URL | tee $NAME/wrk.log 32 | } 33 | 34 | function run_query() { 35 | URL="http://127.0.0.1:8000/hello/query?query=foobar" 36 | http $URL 37 | time wrk -t20 -c100 -d10s $URL | tee $NAME/wrk.log 38 | } 39 | 40 | function run_full() { 41 | URL="http://127.0.0.1:8000/hello/full/with/foo/and/bar?query=foobar" 42 | http $URL Cookie:"test=bench" 43 | time wrk -t20 -c100 -d10s -H "Cookie: test=bench" $URL | tee $NAME/wrk.log 44 | } 45 | 46 | 47 | function run () { 48 | echo "Running bench for $NAME on $ENDPOINT endpoint with $WORKERS worker(s)" 49 | cd $NAME && . ./run.sh & 50 | sleep 1 51 | PID=$! 52 | run_$ENDPOINT 53 | kill $PID 54 | wait $PID 55 | } 56 | 57 | LEN=${#NAMES[@]} 58 | COUNTER=0 59 | for NAME in $FRAMEWORKS 60 | do 61 | echo ">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>" 62 | run 63 | let COUNTER++ 64 | if (($COUNTER < $LEN)) 65 | then sleep 20 66 | fi 67 | echo "<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<" 68 | done 69 | -------------------------------------------------------------------------------- /benchmarks/fabfile.py: -------------------------------------------------------------------------------- 1 | from hashlib import md5 2 | from pathlib import Path 3 | 4 | from invoke import task 5 | 6 | 7 | def as_user(ctx, user, cmd, *args, **kwargs): 8 | ctx.run('sudo --set-home --preserve-env --user {} --login ' 9 | '{}'.format(user, cmd), *args, **kwargs) 10 | 11 | 12 | def as_bench(ctx, cmd, *args, **kwargs): 13 | as_user(ctx, 'bench', cmd) 14 | 15 | 16 | def sudo_put(ctx, local, remote, chown=None): 17 | tmp = str(Path('/tmp') / md5(remote.encode()).hexdigest()) 18 | ctx.put(local, tmp) 19 | ctx.run('sudo mv {} {}'.format(tmp, remote)) 20 | if chown: 21 | ctx.run('sudo chown {} {}'.format(chown, remote)) 22 | 23 | 24 | def put_dir(ctx, local, remote): 25 | exclude = ['/.', '__pycache__', '.egg-info', '/tests'] 26 | local = Path(local) 27 | remote = Path(remote) 28 | for path in local.rglob('*'): 29 | relative_path = path.relative_to(local) 30 | if any(pattern in str(path) for pattern in exclude): 31 | continue 32 | if path.is_dir(): 33 | as_bench(ctx, 'mkdir -p {}'.format(remote / relative_path)) 34 | else: 35 | sudo_put(ctx, path, str(remote / relative_path), 36 | chown='bench:users') 37 | 38 | 39 | @task 40 | def bench(ctx, endpoint='minimal', frameworks='roll', workers='1'): 41 | as_bench(ctx, '/bin/bash -c ". /srv/bench/venv/bin/activate && ' 42 | 'cd /srv/bench/src/benchmarks && ./bench.sh ' 43 | f'--endpoint \"{endpoint}\" --frameworks \"{frameworks}\" ' 44 | f'--workers \"{workers}\""') 45 | 46 | 47 | @task 48 | def system(ctx): 49 | ctx.run('sudo apt update') 50 | ctx.run('sudo apt install python3.6 python3.6-dev wrk ' 51 | 'python-virtualenv build-essential httpie --yes') 52 | ctx.run('sudo useradd -N bench -m -d /srv/bench/ || exit 0') 53 | ctx.run('sudo chsh -s /bin/bash bench') 54 | 55 | 56 | @task 57 | def venv(ctx): 58 | as_bench(ctx, 'virtualenv /srv/bench/venv --python=python3.6') 59 | as_bench(ctx, '/srv/bench/venv/bin/pip install pip -U') 60 | 61 | 62 | @task 63 | def bootstrap(ctx): 64 | system(ctx) 65 | venv(ctx) 66 | deploy(ctx) 67 | 68 | 69 | @task 70 | def deploy(ctx): 71 | as_bench(ctx, 'rm -rf /srv/bench/src') 72 | # Push local code so we can benchmark local changes easily. 73 | put_dir(ctx, Path(__file__).parent.parent, '/srv/bench/src') 74 | as_bench(ctx, '/srv/bench/venv/bin/pip install -r ' 75 | '/srv/bench/src/benchmarks/requirements.txt') 76 | as_bench(ctx, '/bin/bash -c "cd /srv/bench/src/; ' 77 | '/srv/bench/venv/bin/python setup.py develop"') 78 | -------------------------------------------------------------------------------- /benchmarks/falcon/app.py: -------------------------------------------------------------------------------- 1 | import falcon 2 | 3 | try: 4 | import ujson as json 5 | except ImportError: 6 | import json as json 7 | 8 | 9 | class MinimalResource: 10 | def on_get(self, req, resp): 11 | resp.status = falcon.HTTP_200 12 | resp.body = json.dumps({'message': 'Hello, World!'}) 13 | 14 | 15 | class ParameterResource: 16 | def on_get(self, req, resp, parameter): 17 | resp.status = falcon.HTTP_200 18 | resp.body = json.dumps({'parameter': parameter}) 19 | 20 | 21 | app = falcon.API() 22 | app.add_route('/hello/minimal', MinimalResource()) 23 | app.add_route('/hello/with/{parameter}', ParameterResource()) 24 | -------------------------------------------------------------------------------- /benchmarks/falcon/gunicorn_conf.py: -------------------------------------------------------------------------------- 1 | loglevel = 'critical' 2 | -------------------------------------------------------------------------------- /benchmarks/falcon/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | exec gunicorn app:app --workers $WORKERS --threads $WORKERS --config gunicorn_conf.py 4 | -------------------------------------------------------------------------------- /benchmarks/native/app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import signal 4 | import sys 5 | 6 | import uvloop 7 | 8 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 9 | logging.getLogger('asyncio').setLevel(logging.CRITICAL) 10 | 11 | 12 | class Protocol(asyncio.Protocol): 13 | 14 | def connection_made(self, transport): 15 | self.writer = transport 16 | 17 | def data_received(self, data: bytes): 18 | self.writer.write(b'HTTP/1.1 200 OK\r\n') 19 | self.writer.write(b'Content-Length: 27\r\n') 20 | self.writer.write(b'Content-Type: application/json\r\n') 21 | self.writer.write(b'\r\n') 22 | self.writer.write(b'{"message":"Hello, World!"}') 23 | 24 | 25 | if __name__ == '__main__': 26 | loop = asyncio.get_event_loop() 27 | server = loop.create_server(Protocol, '127.0.0.1', 8000) 28 | loop.create_task(server) 29 | print('Serving on http://127.0.0.1:8000') 30 | 31 | def shutdown(*args): 32 | server.close() 33 | print('\nServer stopped.') 34 | sys.exit(0) 35 | 36 | signal.signal(signal.SIGTERM, shutdown) 37 | signal.signal(signal.SIGINT, shutdown) 38 | try: 39 | loop.run_forever() 40 | except KeyboardInterrupt: 41 | pass 42 | finally: 43 | server.close() 44 | loop.close() 45 | -------------------------------------------------------------------------------- /benchmarks/native/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | exec python app.py 4 | -------------------------------------------------------------------------------- /benchmarks/requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.7.4 2 | falcon==2.0.0 3 | gunicorn==20.0.4 4 | httpie==2.2.0 5 | sanic==20.3.0 6 | starlette==0.13.4 7 | trinket==0.1.5 8 | ujson==2.0.3 9 | uvicorn==0.11.7 10 | uvloop==0.14.0 11 | -------------------------------------------------------------------------------- /benchmarks/roll/app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import uvloop 5 | 6 | from roll import Roll 7 | 8 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 9 | logging.getLogger('asyncio').setLevel(logging.CRITICAL) 10 | 11 | app = Roll() 12 | 13 | 14 | @app.route('/hello/minimal') 15 | async def minimal(request, response): 16 | response.json = {'message': 'Hello, World!'} 17 | 18 | 19 | @app.route('/hello/with/{parameter}') 20 | async def parameter(request, response, parameter): 21 | response.json = {'parameter': parameter} 22 | 23 | 24 | @app.route('/hello/cookie') 25 | async def cookie(request, response): 26 | response.json = {'cookie': request.cookies['test']} 27 | response.cookies.set(name='bench', value='value') 28 | 29 | 30 | @app.route('/hello/query') 31 | async def query(request, response): 32 | response.json = {'query': request.query.get('query')} 33 | 34 | 35 | @app.route('/hello/full/with/{one}/and/{two}') 36 | async def full(request, response, one, two): 37 | response.json = { 38 | 'parameters': f'{one} and {two}', 39 | 'query': request.query.get('query'), 40 | 'cookie': request.cookies['test'], 41 | } 42 | response.cookies.set(name='bench', value='value') 43 | -------------------------------------------------------------------------------- /benchmarks/roll/gunicorn_conf.py: -------------------------------------------------------------------------------- 1 | worker_class = 'roll.worker.Worker' 2 | loglevel = 'critical' 3 | -------------------------------------------------------------------------------- /benchmarks/roll/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | exec gunicorn app:app --workers $WORKERS --threads $WORKERS --config gunicorn_conf.py 4 | -------------------------------------------------------------------------------- /benchmarks/sanic/app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | 5 | import uvloop 6 | from sanic import Sanic 7 | from sanic.response import json 8 | 9 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 10 | logging.getLogger('asyncio').setLevel(logging.CRITICAL) 11 | 12 | app = Sanic() 13 | 14 | 15 | @app.route('/hello/minimal') 16 | async def minimal(request): 17 | return json({'message': 'Hello, World!'}) 18 | 19 | 20 | @app.route('/hello/with/') 21 | async def parameter(request, parameter): 22 | return json({'parameter': parameter}) 23 | 24 | 25 | @app.route('/hello/cookie') 26 | async def cookie(request): 27 | response = json({'cookie': request.cookies.get('test')}) 28 | response.cookies['bench'] = 'value' 29 | return response 30 | 31 | 32 | @app.route('/hello/query') 33 | async def query(request): 34 | return json({'query': request.args.get('query')}) 35 | 36 | 37 | @app.route('/hello/full/with//and/') 38 | async def full(request, one, two): 39 | response = json({ 40 | 'parameters': f'{one} and {two}', 41 | 'query': request.args.get('query'), 42 | 'cookie': request.cookies['test'], 43 | }) 44 | response.cookies['bench'] = 'value' 45 | return response 46 | 47 | 48 | if __name__ == '__main__': 49 | app.run(host='127.0.0.1', port=8000, access_log=False, 50 | workers=int(os.environ.get('WORKERS'))) 51 | -------------------------------------------------------------------------------- /benchmarks/sanic/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | export WORKERS=$WORKERS 4 | exec python app.py 5 | -------------------------------------------------------------------------------- /benchmarks/starlette/app.py: -------------------------------------------------------------------------------- 1 | from starlette.applications import Starlette 2 | from starlette.responses import UJSONResponse 3 | from starlette.routing import Route 4 | 5 | 6 | async def minimal(request): 7 | return UJSONResponse({'message': 'Hello, World!'}) 8 | 9 | 10 | app = Starlette(debug=False, routes=[ 11 | Route('/hello/minimal', minimal), 12 | ]) 13 | -------------------------------------------------------------------------------- /benchmarks/starlette/gunicorn_conf.py: -------------------------------------------------------------------------------- 1 | loglevel = 'critical' 2 | worker_class = 'uvicorn.workers.UvicornWorker' 3 | -------------------------------------------------------------------------------- /benchmarks/starlette/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | exec gunicorn app:app --workers $WORKERS --threads $WORKERS --config gunicorn_conf.py 4 | -------------------------------------------------------------------------------- /benchmarks/trinket/app.py: -------------------------------------------------------------------------------- 1 | from trinket import Trinket, Response 2 | 3 | 4 | app = Trinket() 5 | 6 | 7 | @app.route('/hello/minimal') 8 | async def minimal(request): 9 | response = Response.json({'message': 'Hello, World!'}) 10 | return response 11 | 12 | 13 | @app.route('/hello/with/{parameter}') 14 | async def parameter(request, parameter): 15 | response.json = Response.json({'parameter': parameter}) 16 | 17 | 18 | @app.route('/hello/cookie') 19 | async def cookie(request): 20 | response = Response.json({'cookie': request.cookies['test']}) 21 | response.cookies.set(name='bench', value='value') 22 | return response 23 | 24 | 25 | @app.route('/hello/query') 26 | async def query(request): 27 | response = Response.json({'query': request.query.get('query')}) 28 | return response 29 | 30 | 31 | @app.route('/hello/full/with/{one}/and/{two}') 32 | async def full(request, one, two): 33 | response = Response.json({ 34 | 'parameters': f'{one} and {two}', 35 | 'query': request.query.get('query'), 36 | 'cookie': request.cookies['test'], 37 | }) 38 | response.cookies.set(name='bench', value='value') 39 | return response 40 | 41 | 42 | if __name__ == '__main__': 43 | app.start(host='127.0.0.1', port=8000, debug=False) 44 | -------------------------------------------------------------------------------- /benchmarks/trinket/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | exec python app.py 4 | -------------------------------------------------------------------------------- /benchmarks/uvicorn/app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import ujson as json 5 | import uvloop 6 | 7 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 8 | logging.getLogger('asyncio').setLevel(logging.CRITICAL) 9 | 10 | 11 | async def app(scope, receive, send): 12 | assert scope['type'] == 'http' 13 | await send({ 14 | 'type': 'http.response.start', 15 | 'status': 200, 16 | 'headers': [ 17 | [b'content-type', b'application/json'], 18 | ] 19 | }) 20 | await send({ 21 | 'type': 'http.response.body', 22 | 'body': json.dumps({'message': 'Hello, World!'}).encode(), 23 | }) 24 | -------------------------------------------------------------------------------- /benchmarks/uvicorn/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | exec uvicorn app:app --workers $WORKERS --no-access-log 4 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from roll import Roll 3 | from roll.extensions import traceback 4 | 5 | 6 | @pytest.fixture(scope='function') 7 | def app(): 8 | app_ = Roll() 9 | traceback(app_) 10 | return app_ 11 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Roll changelog 2 | 3 | A changelog: 4 | 5 | * is breaking-change-oriented 6 | * links to related issues 7 | * is concise 8 | 9 | *Analogy: git blame :p* 10 | 11 | ## 0.13.0 - 2021-05-18 12 | 13 | - **Breaking change**: 14 | * `request` event is now sent *after* the URL matching and the HTTP verb check, 15 | please use the new `headers` event for these use cases. 16 | - Added new `headers` event, to hook after request headers have been read, but 17 | before the request body has been consumed. ([#227](https://github.com/pyrates/roll/pull/227)) 18 | - Added method `Request.read()` to load and consume request body in one call 19 | 20 | ## 0.12.4 - 2020-12-03 21 | 22 | - Python 3.9 support 23 | 24 | ## 0.12.3 - 2020-07-21 25 | 26 | - Added `credentials` parameter to `cors` extension, to deal with `Access-Control-Allow-Credentials` header 27 | ([#155](https://github.com/pyrates/roll/pull/155)) 28 | 29 | ## 0.12.2 - 2020-06-22 30 | 31 | - Fixed URL quoting in testing utility ([#149](https://github.com/pyrates/roll/pull/149), [#150](https://github.com/pyrates/roll/pull/150)) 32 | 33 | ## 0.12.1 - 2020-06-22 34 | 35 | - Fixed request not properly drained in case of error ([#138](https://github.com/pyrates/roll/pull/138)) 36 | - Moved `app.url_for` helper to a dedicated extension: `named_url` ([#143](https://github.com/pyrates/roll/pull/143)) 37 | - Added `request.referrer` and `request.origin` shortcuts ([#147](https://github.com/pyrates/roll/pull/147)) 38 | 39 | ## 0.12.0 - 2020-06-14 40 | 41 | - Added `app.url_for` helper ([#136](https://github.com/pyrates/roll/pull/136)) 42 | - Added `response.redirect` shortcut ([#134](https://github.com/pyrates/roll/pull/134)) 43 | - Fixed URL not quoted in testing utility 44 | - Added `default_index` to `static` extension ([#95](https://github.com/pyrates/roll/pull/95)) 45 | - Added support for streamed responses ([#89](https://github.com/pyrates/roll/pull/89)) 46 | - Added support for async reading of request body ([#89](https://github.com/pyrates/roll/pull/89)) 47 | - `Request.json` is now cached ([#90](https://github.com/pyrates/roll/pull/90)) 48 | 49 | ## 0.11.0 - 2019-05-07 50 | 51 | - **Breaking change**: 52 | * Removed python 3.5 support ([#81](https://github.com/pyrates/roll/pull/81)) 53 | - Added support for class-based views ([#80](https://github.com/pyrates/roll/pull/80)) 54 | - Added support for chunked response ([#81](https://github.com/pyrates/roll/pull/81)) 55 | 56 | ## 0.10.0 - 2018-11-08 57 | 58 | - python 3.7 compatibility (bump websockets and biscuits packages) 59 | ([#69](https://github.com/pyrates/roll/pull/69)) 60 | - uvloop is not a dependency anymore (but you should still use it!): need was 61 | to allow installing Roll without uvloop is some envs, and to let the users 62 | define the uvloop version they want to use 63 | ([#68](https://github.com/pyrates/roll/pull/68)) 64 | - `Request.method` is `None` by default ([#67](https://github.com/pyrates/roll/pull/67)) 65 | - allow to use `methods=*` in `cors` extension 66 | ([#65](https://github.com/pyrates/roll/pull/65)) 67 | 68 | ## 0.9.1 - 2018-06-11 69 | 70 | * Do not try to write on a closed transport 71 | ([#58](https://github.com/pyrates/roll/pull/58)) 72 | 73 | ## 0.9.0 - 2018-06-04 74 | 75 | * **Breaking changes**: 76 | * `Request.route` is now always set, but `Request.route.payload` is `None` 77 | when path is not found. This allows to catch a not found request in the 78 | `request` hook. Note: if the `request` hook does not return a response, 79 | a 404 is still automatically raised. 80 | ([#45](https://github.com/pyrates/roll/pull/45)) 81 | * Added `request.host` shortcut ([#43](https://github.com/pyrates/roll/pull/43)) 82 | * Introduced protocol upgrade and protocol configuration for routes. Two 83 | protocols are shipped by default : HTTP and Websocket 84 | ([#54](https://github.com/pyrates/roll/pull/54)). 85 | * The route is now resolved as soon as the URL has been parsed. In addition, the 86 | route lookup method has been split up from the application `__call__`method, 87 | to allow easy override 88 | ([#54](https://github.com/pyrates/roll/pull/54)). 89 | * Testing: now build a proper request instead of calling callbacks by hand 90 | ([#54](https://github.com/pyrates/roll/pull/54)). 91 | 92 | 93 | ## 0.8.0 - 2017-12-11 94 | 95 | * **Breaking changes**: 96 | * `Request` and `Response` classes now take `app` as init parameter. It 97 | allows lazy parsing of the query while keeping the `Query` class reference 98 | on `Roll` application. 99 | ([#35](https://github.com/pyrates/roll/pull/35)) 100 | * Added support for request body parsing through multifruits 101 | ([#38](https://github.com/pyrates/roll/pull/38)) 102 | 103 | 104 | ## 0.7.0 - 2017-11-27 105 | 106 | * **Breaking changes**: 107 | * `Query`, `Request` and `Response` are not anymore attached to the 108 | `Protocol` class. They are now declared at the `Roll` class level. 109 | It allows easier subclassing and customization of these parts. 110 | ([#30](https://github.com/pyrates/roll/pull/30)) 111 | * Removed Request.kwargs in favor of inheriting from dict, to store user 112 | data in a separate space 113 | ([#33](https://github.com/pyrates/roll/pull/33)) 114 | * Request headers are now normalized in upper case, to work around 115 | inconsistent casing in clients 116 | ([#24](https://github.com/pyrates/roll/pull/24)) 117 | * Only set the body and Content-Length header when necessary 118 | ([#31](https://github.com/pyrates/roll/pull/31)) 119 | * Added `cookies` support ([#28](https://github.com/pyrates/roll/pull/28)) 120 | 121 | 122 | ## 0.6.0 — 2017-11-22 123 | 124 | * **Breaking changes**: 125 | * `options` extension is no more applied by default 126 | ([#16](https://github.com/pyrates/roll/pull/16)) 127 | * deprecated `req` pytest fixture is now removed 128 | ([#9](https://github.com/pyrates/roll/pull/9)) 129 | * Changed `Roll.hook` signature to also accept kwargs 130 | ([#5](https://github.com/pyrates/roll/pull/5)) 131 | * `json` shorcut sets `utf-8` charset in `Content-Type` header 132 | ([#13](https://github.com/pyrates/roll/pull/13)) 133 | * Added `static` extension to serve static files for development 134 | ([#16](https://github.com/pyrates/roll/pull/16)) 135 | * `cors` accepts `headers` parameter to control `Access-Control-Allow-Headers` 136 | ([#12](https://github.com/pyrates/roll/pull/12)) 137 | * Added `content_negociation` extension to reject unacceptable client requests 138 | based on the `Accept` header 139 | ([#21](https://github.com/pyrates/roll/pull/21)) 140 | * Allow to set multiple `Set-Cookie` headers 141 | ([#23](https://github.com/pyrates/roll/pull/23)) 142 | 143 | ## 0.5.0 — 2017-09-21 144 | 145 | * **Breaking change**: 146 | order of parameters in events is always `request`, `response` and 147 | optionnaly `error` if any, in that particular order. 148 | * Add documentation. 149 | * Move project to Github. 150 | 151 | ## 0.4.0 — 2017-09-21 152 | 153 | * **Breaking change**: 154 | routes placeholder syntax changed from `:parameter` to `{parameter}` 155 | * Switch routes from kua to autoroutes for performances. 156 | 157 | ## 0.3.0 — 2017-09-21 158 | 159 | * **Breaking change**: 160 | `cors` extension parameter is no longer `value` but `origin` 161 | * Improve benchmarks and overall performances. 162 | 163 | ## 0.2.0 — 2017-08-25 164 | 165 | * Resolve HTTP status only at response write time. 166 | 167 | ## 0.1.1 — 2017-08-25 168 | 169 | * Fix `setup.py`. 170 | 171 | ## 0.1.0 — 2017-08-25 172 | 173 | * First release 174 | -------------------------------------------------------------------------------- /docs/discussions.md: -------------------------------------------------------------------------------- 1 | # Discussions 2 | 3 | A discussion: 4 | 5 | * is understanding-oriented 6 | * explains 7 | * provides background and context 8 | 9 | *Analogy: an article on culinary social history* 10 | 11 | 12 | ## Why Roll? 13 | 14 | It all started with big waves (hence the name ;-)) in a little retreat 15 | with a new project that required good performances. 16 | 17 | We tried Sanic but were not satisfied with synchronuous tests and did 18 | not understand why such a complexity so we assembled a few classes and 19 | Roll was born! 20 | 21 | Lately we realized that it has pretty good preformances *for our usage* 22 | and decided to add some benchmarks and then iterate to better understand 23 | bottlenecks and strategies to tackle these. It was fun and not 24 | ridiculous at the end so we gave some love to the documentation and 25 | here we are. 26 | 27 | [A kind of presentation of Roll](https://larlet.fr/david/blog/2017/async-python-frameworks/) 28 | has been done in November, 2017 at PyConCA. 29 | -------------------------------------------------------------------------------- /docs/how-to/advanced.md: -------------------------------------------------------------------------------- 1 | # How-to guides: advanced 2 | 3 | 4 | ## How to create an extension 5 | 6 | You can use extensions to achieve a lot of enhancements of the base 7 | framework. 8 | 9 | Basically, an extension is a function listening to 10 | [events](../reference/events.md), for instance: 11 | 12 | ```python3 13 | def cors(app, value='*'): 14 | 15 | @app.listen('response') 16 | async def add_cors_headers(response, request): 17 | response.headers['Access-Control-Allow-Origin'] = value 18 | ``` 19 | 20 | Here the `cors` extension can be applied to the Roll `app` object. 21 | It listens to the `response` event and for each of those add a custom 22 | header. The name of the inner function is not relevant but explicit is 23 | always a bonus. The `response` object is modified in place. 24 | 25 | *Note: more [extensions](../reference/events.md) are available by default. 26 | Make sure to check these out!* 27 | 28 | 29 | ## How to deal with content negociation 30 | 31 | The [`content_negociation` extension](../reference/extensions.md#content_negociation) 32 | is made for this purpose, you can use it that way: 33 | 34 | ```python3 35 | extensions.content_negociation(app) 36 | 37 | @app.route('/test', accepts=['text/html', 'application/json']) 38 | async def get(req, resp): 39 | if req.headers['ACCEPT'] == 'text/html': 40 | resp.headers['Content-Type'] = 'text/html' 41 | resp.body = '

accepted

' 42 | elif req.headers['ACCEPT'] == 'application/json': 43 | resp.json = {'status': 'accepted'} 44 | ``` 45 | 46 | Requests with `Accept` header not matching `text/html` or 47 | `application/json` will be honored with a `406 Not Acceptable` response. 48 | 49 | 50 | ## How to subclass Roll itself 51 | 52 | Let’s say you want your own [Query](../reference/core.md#query) parser 53 | to deal with GET parameters that should be converted as `datetime.date` 54 | objects. 55 | 56 | What you can do is subclass the [Roll](../reference/core.md#roll) class 57 | to set your custom Query class: 58 | 59 | ```python3 60 | from datetime import date 61 | 62 | from roll import Roll, Query 63 | from roll.extensions import simple_server 64 | 65 | 66 | class MyQuery(Query): 67 | 68 | @property 69 | def date(self): 70 | return date(int(self.get('year')), 71 | int(self.get('month')), 72 | int(self.get('day'))) 73 | 74 | 75 | class MyRoll(Roll): 76 | Query = MyQuery 77 | 78 | 79 | app = MyRoll() 80 | 81 | 82 | @app.route('/hello/') 83 | async def hello(request, response): 84 | response.body = request.query.date.isoformat() 85 | 86 | 87 | if __name__ == '__main__': 88 | simple_server(app) 89 | ``` 90 | 91 | And now when you pass appropriated parameters (for the sake of brievety, 92 | no error handling is performed but hopefully you get the point!): 93 | 94 | ``` 95 | $ http :3579/hello/ year==2017 month==9 day==20 96 | HTTP/1.1 200 OK 97 | Content-Length: 10 98 | 99 | 2017-09-20 100 | ``` 101 | 102 | 103 | ## How to deploy Roll into production 104 | 105 | The recommended way to deploy Roll is using 106 | [Gunicorn](http://docs.gunicorn.org/). 107 | 108 | First install gunicorn in your virtualenv: 109 | 110 | pip install gunicorn 111 | 112 | To run it, you need to pass it the pythonpath to your roll project 113 | application. For example, if you have created a module `core.py` 114 | in your package `mypackage`, where you create your application 115 | with `app = Roll()`, then you need to issue this command line: 116 | 117 | gunicorn mypackage.core:app --worker-class roll.worker.Worker 118 | 119 | See [gunicorn documentation](http://docs.gunicorn.org/en/stable/settings.html) 120 | for more details about the available arguments. 121 | 122 | Note: it's also recommended to install [uvloop](https://github.com/MagicStack/uvloop) 123 | as a faster `asyncio` event loop replacement: 124 | 125 | pip install uvloop 126 | 127 | ## How to send custom events 128 | 129 | Roll has a very small API for listening and sending events. It's possible to use 130 | it in your project for your own events. 131 | 132 | Events are useful when you want other users to extend your own code, whether 133 | it's a Roll extension, or a full project built with Roll. 134 | They differ from configuration in that they are more adapted for dynamic 135 | modularity. 136 | 137 | For example, say we develop a DB pooling extension for Roll. We 138 | would use a simple configuration parameter to let users change the connection 139 | credentials (host, username, password…). But if we want users to run some 140 | code each time a new connection is created, we may use a custom event. 141 | 142 | Our extension usage would look like this: 143 | 144 | ```python3 145 | app = Roll() 146 | db_pool_extension(app, dbname='mydb', username='foo', password='bar') 147 | 148 | @app.listen('new_connection') 149 | def listener(connection): 150 | # dosomething with the connection, 151 | # for example register some PostgreSQL custom types. 152 | ``` 153 | 154 | Then, in our extension, when creating a new connection, we'd do something like 155 | that: 156 | 157 | ```python3 158 | app.hook('new_connection', connection=connection) 159 | ``` 160 | 161 | 162 | ## How to protect a view with a decorator 163 | 164 | Here is a small example of a `WWW-Authenticate` protection using a decorator. Of 165 | course, the decorator pattern can be used to any kind of more advanced 166 | authentication process. 167 | 168 | 169 | ```python3 170 | from base64 import b64decode 171 | 172 | from roll import Roll 173 | 174 | 175 | def auth_required(func): 176 | 177 | async def wrapper(request, response, *args, **kwargs): 178 | auth = request.headers.get('AUTHORIZATION', '') 179 | # This is really naive, never do that at home! 180 | if b64decode(auth[6:]) != b'user:pwd': 181 | response.status = HTTPStatus.UNAUTHORIZED 182 | response.headers['WWW-Authenticate'] = 'Basic' 183 | else: 184 | await func(request, response, *args, **kwargs) 185 | 186 | return wrapper 187 | 188 | 189 | app = Roll() 190 | 191 | 192 | @app.route('/hello/') 193 | @auth_required 194 | async def hello(request, response): 195 | pass 196 | ``` 197 | 198 | 199 | ## How to work with Websockets pings and pongs 200 | 201 | While most clients will keep the connection alive and won't expect 202 | heartbeats (read ping), some can be more pedantic and ask for a regular 203 | keep-alive ping. 204 | 205 | ```python 206 | import asyncio 207 | 208 | async def keep_me_alive(request, ws, **params): 209 | while True: 210 | try: 211 | msg = await asyncio.wait_for(ws.recv(), timeout=20) 212 | except asyncio.TimeoutError: 213 | # No data in 20 seconds, check the connection. 214 | try: 215 | pong_waiter = await ws.ping() 216 | await asyncio.wait_for(pong_waiter, timeout=10) 217 | except asyncio.TimeoutError: 218 | # No response to ping in 10 seconds, disconnect. 219 | break 220 | else: 221 | # do something with msg 222 | ... 223 | ``` 224 | 225 | 226 | ## How to consume a request body the asynchronous way 227 | 228 | Let’s say you are waiting for big file uploads. You might want to consume the request iteratively to keep your memory consumption low. Here is how to achieve that: 229 | 230 | ```python3 231 | # lazy_body parameter will ask Roll not to load the body automatically 232 | @app.route('/path', lazy_body=True) 233 | async def my_handler(request, response): 234 | # Prior to accept the upload you can check for headers: 235 | if headers.get("Authorization") != "Dummy OK": 236 | # raise, redirect, 401, whatever 237 | 238 | # In case image is a file object you can write onto. 239 | async for chunk in request: 240 | image.write(chunk) 241 | ``` 242 | 243 | 244 | ## How to serve a chunked response 245 | 246 | In some situations, it's useful to send a chunked response, for example for an 247 | unknown sized response body, maybe a file generated on the fly, or to prevent 248 | loading a big file in memory. 249 | 250 | This is a good occasion to take advantage of using an async library: Roll will 251 | automatically serve a chunked response if `Response.body` is an 252 | [async generator](https://www.python.org/dev/peps/pep-0525/), or more specifically 253 | if it defines the `__aiter__` method. 254 | 255 | Here is a theoretical example: 256 | 257 | ```python3 258 | @app.route('/path') 259 | async def my_handler(request, response): 260 | response.body = my_async_generator 261 | ``` 262 | 263 | Now a more concrete example: 264 | 265 | ```python3 266 | from aiofile import AIOFile, Reader 267 | 268 | async def serve_file(path): 269 | async with AIOFile(path, 'rb') as afp: 270 | reader = Reader(afp, chunk_size=4096) 271 | async for data in reader: 272 | yield data 273 | 274 | 275 | @app.route('/path') 276 | async def my_handler(request, response): 277 | response.body = serve_file(path_to_file) 278 | response.headers['Content-Disposition'] = "attachment; filename=filename.mp3" 279 | ``` 280 | 281 | Note: the header `Transfert-Encoding` will be set to `chunked`, and each chunk 282 | length will be calculated and added to the chunk body by Roll. 283 | -------------------------------------------------------------------------------- /docs/how-to/basic.md: -------------------------------------------------------------------------------- 1 | # How-to guides 2 | 3 | A how-to guide: 4 | 5 | * is goal-oriented 6 | * shows how to solve a specific problem 7 | * is a series of steps 8 | 9 | *Analogy: a recipe in a cookery book* 10 | 11 | 12 | ## How to install Roll 13 | 14 | Roll requires Python 3.6+ to be installed. 15 | 16 | It is recommended to install it within 17 | [a pipenv or virtualenv](http://docs.python-guide.org/en/latest/dev/virtualenvs/). 18 | 19 | You can install Roll through [pip](https://pip.pypa.io/en/stable/): 20 | 21 | pip install roll 22 | 23 | 24 | ## How to return an HTTP error 25 | 26 | There are many reasons to return an HTTP error, with Roll you have to 27 | raise an HttpError instance. Remember our 28 | [base example from tutorial](/tutorials.md#your-first-roll-application)? 29 | What if we want to return an error to the user: 30 | 31 | ```python3 32 | from http import HTTPStatus 33 | 34 | from roll import Roll, HttpError 35 | from roll.extensions import simple_server 36 | 37 | app = Roll() 38 | 39 | 40 | @app.route('/hello/{parameter}') 41 | async def hello(request, response, parameter): 42 | if parameter == 'foo': 43 | raise HttpError(HTTPStatus.BAD_REQUEST, 'Run, you foo(l)!') 44 | response.body = f'Hello {parameter}' 45 | 46 | 47 | if __name__ == '__main__': 48 | simple_server(app) 49 | ``` 50 | 51 | Now when we try to reach the view with the `foo` parameter: 52 | 53 | ``` 54 | $ http :3579/hello/foo 55 | HTTP/1.1 400 Bad Request 56 | Content-Length: 16 57 | 58 | Run, you foo(l)! 59 | ``` 60 | 61 | One advantage of using the exception mechanism is that you can raise an 62 | HttpError from anywhere and let Roll handle it! 63 | 64 | 65 | ## How to return JSON content 66 | 67 | There is a shortcut to return JSON content from a view. Remember our 68 | [base example from tutorial](/tutorials.md#your-first-roll-application)? 69 | 70 | ```python3 71 | from roll import Roll 72 | from roll.extensions import simple_server 73 | 74 | app = Roll() 75 | 76 | 77 | @app.route('/hello/{parameter}') 78 | async def hello(request, response, parameter): 79 | response.json = {'hello': parameter} 80 | 81 | 82 | if __name__ == '__main__': 83 | simple_server(app) 84 | ``` 85 | 86 | Setting a `dict` to `response.json` will automagically dump it to 87 | regular JSON and set the appropriated content type: 88 | 89 | ``` 90 | $ http :3579/hello/world 91 | HTTP/1.1 200 OK 92 | Content-Length: 17 93 | Content-Type: application/json; charset=utf-8 94 | 95 | { 96 | "hello": "world" 97 | } 98 | ``` 99 | 100 | Especially useful for APIs. 101 | 102 | 103 | ## How to serve HTML templates 104 | 105 | There is an example in the `examples/html` folder using 106 | [Jinja2](http://jinja.pocoo.org/) to render and return HTML views. 107 | 108 | To run it, go to the `examples` folder and run `python -m html`. 109 | Now reach `http://127.0.0.1:3579/hello/world` with your browser. 110 | 111 | To run associated tests: `py.test html/tests.py`. 112 | 113 | 114 | ## How to store custom data in the request 115 | 116 | You can use `Request` as a `dict` like object for your own use, `Roll` itself 117 | never touches it. 118 | 119 | ```python3 120 | request['user'] = get_current_user() 121 | ``` 122 | 123 | 124 | ## How to deal with cookies 125 | 126 | ### Request cookies 127 | 128 | If the request has any `Cookie` header, you can retrieve it with the 129 | `request.cookies` attribute, using the cookie `name` as key: 130 | 131 | ```python3 132 | value = request.cookies['name'] 133 | ``` 134 | 135 | 136 | ### Response cookies 137 | 138 | You can add cookies to response using the `response.cookies` attribute: 139 | 140 | ```python3 141 | response.cookies.set(name='name', value='value', path='/foo') 142 | ``` 143 | 144 | See the [reference](/reference/core/#cookies) for all the available `set` kwargs. 145 | 146 | 147 | ## How to consume query parameters 148 | 149 | The query parameters (a.k.a. URL parameters) are made accessible via the `request.query` 150 | property. 151 | 152 | The very basic usage is: 153 | 154 | ```python3 155 | # URL looks like http://localhost/path?myparam=blah 156 | myparam = request.query.get('myparam', 'default-value') 157 | assert myparam == 'blah' 158 | other = request.query.get('other', 'default-value') 159 | assert other == 'default-value' 160 | ``` 161 | 162 | You can also request the full list of values: 163 | 164 | ```python3 165 | # URL looks like http://localhost/path?myparam=bar&myparam=foo 166 | myparam = request.query.list('myparam', 'default-value') 167 | assert myparam == ['bar', 'foo'] 168 | ``` 169 | 170 | If you don't pass a default value, Roll will assume that you are getting a required 171 | parameter, and so if this parameter is not present in the query, 172 | a `400` [HttpError](/reference/core/#httperror) will be raised. 173 | 174 | The [Query](/reference/core/#query) class has three getters to cast the value for 175 | you: `bool`, `int` and `float`. 176 | 177 | ```python3 178 | # URL looks like http://localhost/path?myparam=true 179 | myparam = request.query.bool('myparam', False) 180 | assert myparam is True 181 | ``` 182 | 183 | If the parameter value cannot be casted, a `400` [HttpError](/reference/core/#httperror) 184 | will be raised. 185 | 186 | See also "[how to subclass roll itself](/how-to/advanced.md#how-to-subclass-roll-itself)" 187 | to see how to make your own Query getters. 188 | 189 | 190 | ## How to use class-based views 191 | 192 | In many situations, a `function` is sufficient to handle a request, but in some 193 | cases, using classes helps reducing code boilerplate and keeping things DRY. 194 | 195 | Using class-based views with Roll is straightforward: 196 | 197 | 198 | ```python3 199 | @app.route('/my/path/{myvar}') 200 | class MyView: 201 | 202 | def on_get(self, request, response, myvar): 203 | do_something_on_get 204 | 205 | def on_post(self, request, response, myvar): 206 | do_something_on_post 207 | ``` 208 | 209 | As you may guess, you need to provide an `on_xxx` method for each HTTP method 210 | your view needs to support. 211 | 212 | Of course, class-based views can inherit and have inheritance: 213 | 214 | ```python3 215 | class View: 216 | CUSTOM = None 217 | 218 | async def on_get(self, request, response): 219 | response.body = self.CUSTOM 220 | 221 | @app.route("/tomatoes") 222 | class Tomato(View): 223 | CUSTOM = "tomato" 224 | 225 | @app.route("/cucumbers") 226 | class Cucumber(View): 227 | CUSTOM = "cucumber" 228 | 229 | @app.route("/gherkins") 230 | class Gherkin(Cucumber): 231 | CUSTOM = "gherkin" 232 | ``` 233 | 234 | Warning: Roll will instanciate the class once per thread (to avoid overhead at each 235 | request), so their state will be shared between requests, thus make sure not to 236 | set instance properties on them. 237 | 238 | 239 | See also the [advanced guides](/how-to/advanced.md). 240 | -------------------------------------------------------------------------------- /docs/how-to/developing.md: -------------------------------------------------------------------------------- 1 | # How-to guides: developing 2 | 3 | 4 | ## How to use a livereload development server 5 | 6 | First, install [hupper](https://pypi.python.org/pypi/hupper). 7 | 8 | Then turn your Roll service into an importable module. Basically, a folder with 9 | `__init__.py` and `__main__.py` files and put your `simple_server` call within 10 | the `__main__.py` file (see the `examples/basic` folder for… an example!). 11 | 12 | Once you did that, you can run the server using `hupper -m examples.basic`. 13 | Each and every time you modify a python file, the server will reload and 14 | take into account your modifications accordingly. 15 | 16 | One of the pros of using hupper is that you can even set an `ipdb` call within 17 | your code and it will work seamlessly (as opposed to using solutions like 18 | [entr](http://www.entrproject.org/)). 19 | 20 | 21 | ## How to test forms 22 | 23 | In case of the login form from the 24 | [dedicated tutorial](../tutorials#your-first-roll-form): 25 | 26 | ```python3 27 | @app.route('/login', methods=['POST']) 28 | async def login(request, response): 29 | username = request.form.get('username') 30 | password = request.form.get('password') 31 | response.body = f'Username: `{username}` password: `{password}`.' 32 | ``` 33 | 34 | Start to set the appropriated content type and then pass your data: 35 | 36 | ```python3 37 | async def test_login_form(client): 38 | client.content_type = 'application/x-www-form-urlencoded' 39 | data = {'username': 'David', 'password': '123456'} 40 | resp = await client.post('/test', data=data) 41 | assert resp.status == HTTPStatus.OK 42 | assert resp.body == b'Username: `David` password: `123456`.' 43 | ``` 44 | 45 | Note that you can send files too, for instance with an upload avatar view: 46 | 47 | ```python3 48 | @app.route('/upload/avatar', methods=['POST']) 49 | async def upload_avatar(req, resp): 50 | filename = req.files.get('avatar').filename 51 | content = req.files.get('avatar').read() 52 | resp.body = f'{filename} {content}' 53 | ``` 54 | 55 | Start to set the appropriated content type and then pass your files content: 56 | 57 | ```python3 58 | async def test_avatar_form(client, app): 59 | client.content_type = 'multipart/form-data' 60 | files = {'avatar': (b'foo', 'me.png')} 61 | resp = await client.post('/test', files=files) 62 | assert resp.status == HTTPStatus.OK 63 | assert resp.body == b'me.png foo' 64 | ``` 65 | 66 | ## How to run Roll’s tests 67 | 68 | Roll exposes a pytest fixture (`client`), and for this needs to be 69 | properly installed so pytest sees it. Once in the roll root (and with 70 | your virtualenv active), run: 71 | 72 | python setup.py develop 73 | 74 | Then you can run the tests: 75 | 76 | py.test 77 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Let’s Roll. 2 | 3 | Async, simple, fast: pick three! Roll is a pico framework with 4 | performances and aesthetic in mind. 5 | 6 | 7 | ## Contains 8 | 9 | * async everywhere 10 | * routing through [autoroutes](https://github.com/pyrates/autoroutes) 11 | * cookies handling through [biscuits](https://github.com/pyrates/biscuits) 12 | * multipart parsing through [multifruits](https://github.com/pyrates/multifruits) 13 | * extensible system through events 14 | * decent HTTP errors 15 | * websockets 16 | * easy to test 17 | 18 | 19 | ## Will NOT contain 20 | 21 | * a templating system 22 | * a lot of extensions (share yours!) 23 | 24 | 25 | Please [provide us any feedback](https://github.com/pyrates/roll/issues/new) 26 | as an early reader! If you are curious about the code, it is hosted 27 | [on Github](https://github.com/pyrates/roll/blob/master/roll/__init__.py) 28 | and you should be able to read it within the next half-hour. 29 | 30 | 31 | ## Tutorials 32 | 33 | * [Your first Roll application](tutorials.md#your-first-roll-application) 34 | **☜ TL;DR: this is where you find basic code API!** 35 | * [Your first Roll test](tutorials.md#your-first-roll-test) 36 | * [Your first Roll form](tutorials.md#your-first-roll-form) 37 | * [Using extensions](tutorials.md#using-extensions) 38 | * [Using events](tutorials.md#using-events) 39 | 40 | 41 | ## Discussions 42 | 43 | * [Why Roll?](discussions.md#why-roll) 44 | 45 | 46 | ## How-to guides 47 | 48 | * [basic](how-to/basic.md): all the basic stuff you'll certainly use 49 | * [developing](how-to/developing.md): snippets to make developing with Roll more fun 50 | * [advanced](how-to/advanced.md): more advanced guides 51 | 52 | 53 | ## Reference 54 | 55 | * [Core objects](reference/core.md) 56 | * [Extensions](reference/extensions.md) 57 | * [Events](reference/events.md) 58 | 59 | 60 | ## Changelog 61 | 62 | * [Changelog](changelog.md) 63 | 64 | *The documentation structure is based on that 65 | [excellent article from Divio](https://www.divio.com/en/blog/documentation/).* 66 | -------------------------------------------------------------------------------- /docs/reference/core.md: -------------------------------------------------------------------------------- 1 | # Core objects 2 | 3 | ## Roll 4 | 5 | Roll provides an asyncio protocol. 6 | 7 | You can subclass it to set your own [Protocol](#protocol), [Route](#route), 8 | [Query](#query), [Form](#form), [Files](#files), [Request](#request), 9 | [Response](#response) and/or [Cookies](#cookies) class(es). 10 | 11 | See [How to subclass Roll itself](./../how-to/advanced.md#how-to-subclass-roll-itself) 12 | guide. 13 | 14 | 15 | ### Methods 16 | 17 | - **route(path: str, methods: list, protocol: str='http', lazy_body: bool='False', \**extras: dict)**: 18 | register a route handler. Usually used as a decorator: 19 | 20 | @app.route('/path/with/{myvar}') 21 | async def my_handler(request, response, myvar): 22 | do_something 23 | 24 | By default, Roll routing is powered by [autoroutes](https://github.com/pyrates/autoroutes). 25 | Check out its documentation to get more details on which placeholders you 26 | can use on route paths. 27 | 28 | `methods` lists the HTTP methods accepted by this handler. If not defined, 29 | the handler will accept only `GET`. When the handler is a *class*, `methods` must 30 | not be used, as Roll will extrapolate them from the defined methods on the 31 | *class* itself. See [How to use class-based views](./../how-to/basic.md#how-to-use-class-based-views) 32 | for an example of class-based view. 33 | 34 | The `lazy_body` boolean parameter allows you to consume manually the body of the `Request`. It can be handy if you need to check for instance headers prior to load the whole body into RAM (think [images upload for instance](../how-to/advanced.md#how-to-consume-a-request-body-the-asynchronous-way)) or if you plan to accept a streaming incoming request. By default, the body of the request will be fully loaded. 35 | 36 | Any `extra` passed will be stored on the route payload, and accessible through 37 | `request.route.payload`. 38 | 39 | Raise `ValueError` if two URLs with the same name are registred. 40 | 41 | - **listen(name: str)**: listen the event `name`. 42 | 43 | @app.listen('request') 44 | def on_request(request, response): 45 | do_something 46 | 47 | See [Events](#events) for a list of available events in Roll core. 48 | 49 | 50 | 51 | ## HttpError 52 | 53 | The object to raise when an error must be returned. 54 | Accepts a `status` and a `message`. 55 | The `status` can be either a `http.HTTPStatus` instance or an integer. 56 | 57 | 58 | ## Request 59 | 60 | A container for the result of the parsing on each request. 61 | The default parsing is made by `httptools.HttpRequestParser`. 62 | 63 | You can use the empty `kwargs` dict to attach whatever you want, 64 | especially useful for extensions. 65 | 66 | 67 | ### Properties 68 | 69 | - **url** (`bytes`): raw URL as received by Roll 70 | - **path** (`str`): path element of the URL 71 | - **query_string** (`str`): extracted query string 72 | - **query** (`Query`): Query instance with parsed query string 73 | - **method** (`str`): HTTP verb 74 | - **body** (`bytes`): raw body as received by Roll by default. In case you activated the `lazy_body` option in the route, you will have to call the `load_body()` method *before* you access it 75 | - **form** (`Form`): a [Form instance](#form) with multipart or url-encoded 76 | key/values parsed 77 | - **files** (`Files`): a [Files instance](#files) with multipart files parsed 78 | - **json** (`dict` or `list`): body parsed as JSON 79 | - **content_type** (`str`): shortcut to the `Content-Type` header 80 | - **host** (`str`): shortcut to the `Host` header 81 | - **referrer** (`str`): shortcut to the `Referer` header 82 | - **origin** (`str`): shortcut to the `Origin` header 83 | - **headers** (`dict`): HTTP headers normalized in upper case 84 | - **cookies** (`Cookies`): a [Cookies instance](#cookies) with request cookies 85 | - **route** (`Route`): a [Route instance](#Route) storing results from URL matching 86 | 87 | In case of errors during the parsing of `form`, `files` or `json`, 88 | an [HttpError](#httperror) is raised with a `400` (Bad request) status code. 89 | 90 | ### Methods 91 | 92 | - **load_body**: consume request body and load it in memory 93 | - **read** -> `bytes`: call `load_body` and return the loaded body 94 | 95 | ### Custom properties 96 | 97 | While `Request` cannot accept arbitrary attributes, it's a `dict` like object, 98 | which keys are never used by Roll itself, they are dedicated to external use, 99 | for example for session data. 100 | 101 | See 102 | [How to store custom data in the request](../how-to/basic.md#how-to-store-custom-data-in-the-request) 103 | for an example of use. 104 | 105 | ### Iterating over Request’s data 106 | 107 | If you set the `lazy_body` parameter to `True` in your route, you will be able to iterate over the `Request` object itself to access the data (this is what is done under the hood when you `load_body()` by the way). Note that it is only relevant to iterate once across the data. 108 | 109 | 110 | ## Response 111 | 112 | A container for `status`, `headers` and `body`. 113 | 114 | ### Properties 115 | 116 | - **status** (`http.HTTPStatus`): the response status 117 | 118 | # you can set the `status` with the HTTP code directly 119 | response.status = 204 120 | # same as 121 | response.status = http.HTTPStatus.OK 122 | 123 | - **headers** (`dict`): case sensitive HTTP headers 124 | 125 | - **cookies** (`Cookies`): a [Cookies instance](#cookies) 126 | 127 | response.cookies.set(name='cookie', value='value', path='/some/path') 128 | 129 | - **body** (`bytes`): raw Response body; by default, Roll expects `body` to be 130 | `bytes`. If it's not, there are two cases: 131 | - if `body` is an [async generator](https://www.python.org/dev/peps/pep-0525/), 132 | Roll will serve a chunked response (see 133 | [How to serve a chunked response](../how-to/advanced.md#how-to-serve-a-chunked-response)) 134 | - if it's anything else, Roll will convert it to `str` (by calling `str()`), 135 | and then to `bytes` (by calling its `encode()` method) 136 | 137 | 138 | ### Shortcuts 139 | 140 | - **json**: takes any python object castable to `json` and set the body and the 141 | `Content-Type` header 142 | 143 | response.json = {'some': 'dict'} 144 | # Works also with a `list`: 145 | response.json = [{'some': 'dict'}, {'another': 'one'}] 146 | 147 | - **redirect**: takes a `location, status` tuple, and set the Location header and 148 | the status accordingly. 149 | 150 | response.redirect = "https://example.org", 302 151 | 152 | ## Multipart 153 | 154 | Responsible of the parsing of multipart encoded `request.body`. 155 | 156 | ### Methods 157 | 158 | - **initialize(content_type: str)**: returns a tuple 159 | ([Form](#form) instance, [Files](#files) instance) filled with data 160 | from subsequent calls to `feed_data` 161 | - **feed_data(data: bytes)**: incrementally fills [Form](#form) and 162 | [Files](#files) objects with bytes from the body 163 | 164 | 165 | ## Multidict 166 | 167 | Data structure to deal with several values for the same key. 168 | Useful for query string parameters or form-like POSTed ones. 169 | 170 | ### Methods 171 | 172 | - **get(key: str, default=...)**: returns a single value for the given `key`, 173 | raises an `HttpError(BAD_REQUEST)` if the `key` is missing and no `default` is 174 | given 175 | - **list(key: str, default=...)**: returns the values for the given `key` as `list`, 176 | raises an `HttpError(BAD_REQUEST)` if the `key` is missing and no `default` is 177 | given 178 | 179 | 180 | ## Query 181 | 182 | Handy parsing of GET HTTP parameters. 183 | Inherits from [Multidict](#multidict) with all the `get`/`list` goodies. 184 | 185 | ### Methods 186 | 187 | - **bool(key: str, default=...)**: same as `get` but try to cast the value as 188 | `boolean`; raises an `HttpError(BAD_REQUEST)` if the value is not castable 189 | - **int(key: str, default=...)**: same as `get` but try to cast the value as 190 | `int`; raises an `HttpError(BAD_REQUEST)` if the value is not castable 191 | - **float(key: str, default=...)**: same as `get` but try to cast the value as 192 | `float`; raises an `HttpError(BAD_REQUEST)` if the value is not castable 193 | 194 | 195 | ## Form 196 | 197 | Allow to access casted POST parameters from `request.body`. 198 | Inherits from [Query](#query) with all the `get`/`list` + casting goodies. 199 | 200 | 201 | ## Files 202 | 203 | Allow to access POSTed files from `request.body`. 204 | Inherits from [Multidict](#multidict) with all the `get`/`list` goodies. 205 | 206 | 207 | ## Cookies 208 | 209 | A Cookies management class, built on top of 210 | [biscuits](https://github.com/pyrates/biscuits). 211 | 212 | ### Methods 213 | 214 | - **set(name, value, max_age=None, expires=None, secure=False, httponly=False, 215 | path=None, domain=None)**: set a new cookie 216 | 217 | See [How to deal with cookies](../how-to/basic.md#how-to-deal-with-cookies) for 218 | examples. 219 | 220 | 221 | ## Protocol 222 | 223 | Responsible of parsing the request and writing the response. 224 | 225 | 226 | ## Routes 227 | 228 | Responsible for URL-pattern matching. Allows to switch to your own 229 | parser. Default routes use [autoroutes](https://github.com/pyrates/autoroutes), 230 | please refers to that documentation for available patterns. 231 | 232 | 233 | ## Route 234 | 235 | A namedtuple to collect matched route data with attributes: 236 | 237 | * **payload** (`dict`): the data received by the `@app.route` decorator, 238 | contains all handlers plus optionnal custom data. Value is `None` when request 239 | path is not found. 240 | * **vars** (`dict`): URL placeholders resolved for the current route. 241 | 242 | 243 | ## Websocket 244 | 245 | Communication protocol using a socket between a client (usually the browser) 246 | and the server (a route endpoint). 247 | 248 | See [The Websocket Protocol RFC](https://tools.ietf.org/html/rfc6455) 249 | 250 | - **recv()**: receive the next message (async). 251 | - **send(data)**: send data to the client. Can handle `str` or `bytes` 252 | arg (async). 253 | - **close(code: int, reason: str)**: close the websocket (async). 254 | - **ping(data)**: send a ping/heartbeat packet (async). 255 | This method returns an `asyncio.Future` object. 256 | - **pong()**: send a pong packet in response to a ping (async). 257 | 258 | The websocket object can be used as an asynchronous iterator. Using it that 259 | way will yield a message at each iteration while keeping the websocket 260 | connection alive. 261 | 262 | ```python 263 | async def myendpoint(request, ws, **params): 264 | async for message in ws: 265 | print(message) 266 | ``` 267 | -------------------------------------------------------------------------------- /docs/reference/events.md: -------------------------------------------------------------------------------- 1 | # Events 2 | 3 | Please read [Using events](../tutorials.md#using-events) for usage. 4 | 5 | ## startup 6 | 7 | Fired once when launching the server. 8 | 9 | 10 | ## shutdown 11 | 12 | Fired once when shutting down the server. 13 | 14 | 15 | ## headers 16 | 17 | Fired at each request after request headers have been read, but before consuming 18 | the body. 19 | 20 | Receives `request` and `response` parameters. 21 | 22 | Returning `True` allows to shortcut everything and return the current 23 | response object directly, see the [options extension](#extensions) for 24 | an example. 25 | 26 | 27 | ## request 28 | 29 | Fired at each request after route matching, HTTP verb check and after body has 30 | been eventually consumed. 31 | 32 | Receives `request` and `response` parameters. 33 | 34 | Returning `True` allows to shortcut everything and return the current 35 | response object directly. 36 | 37 | 38 | ## response 39 | 40 | Fired at each request after all processing. 41 | 42 | Receives `request` and `response` parameters. 43 | 44 | 45 | ## error 46 | 47 | Fired in case of error, can be at each request. 48 | Use it to customize HTTP error formatting for instance. 49 | 50 | Receives `request`, `response` and `error` parameters. 51 | 52 | If an unexpected error is raised during code execution, Roll will catch it and 53 | return a 500 response. In this case, `error.__context__` is set to the original 54 | error, so one can adapt the behaviour in the error chain management, including 55 | the `error` event. 56 | -------------------------------------------------------------------------------- /docs/reference/extensions.md: -------------------------------------------------------------------------------- 1 | # Extensions 2 | 3 | Please read 4 | [How to create an extension](../how-to/advanced.md#how-to-create-an-extension) 5 | for usage. 6 | 7 | All built-in extensions are imported from `roll.extensions`: 8 | 9 | from roll.extensions import cors, logger, … 10 | 11 | ## cors 12 | 13 | Add [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS)-related headers. 14 | Especially useful for APIs. You generally want to also use the `options` 15 | extension in the same time. 16 | 17 | ### Parameters 18 | 19 | - **app**: Roll app to register the extension against 20 | - **origin** (`str`; default: `*`): value of the `Access-Control-Allow-Origin` header 21 | - **methods** (`list` of `str`; default: `None`): value of the 22 | `Access-Control-Allow-Methods` header; if `None` the header will not be set 23 | - **headers** (`list` of `str`; default: `None`): value of the 24 | `Access-Control-Allow-Headers` header; if `None` the header will not be set 25 | - **credentials** (default: `False`): if `True`, `Access-Control-Allow-Credentials` 26 | header will be set to `true` 27 | 28 | 29 | ## logger 30 | 31 | Log each and every request by default. 32 | 33 | ### Parameters 34 | 35 | - **app**: Roll app to register the extension against 36 | - **level** (default: `logging.DEBUG`): `logging` level 37 | - **handler** (default: `logging.StreamHandler`): `logging` handler 38 | 39 | 40 | ## options 41 | 42 | Performant return in case of `OPTIONS` HTTP request. 43 | Combine it with the `cors` extension to handle the preflight request. 44 | 45 | ### Parameters 46 | 47 | - **app**: Roll app to register the extension against 48 | 49 | 50 | ## content_negociation 51 | 52 | Deal with content negociation declared during routes definition. 53 | Will return a `406 Not Acceptable` response in case of mismatch between 54 | the `Accept` header from the client and the `accepts` parameter set in 55 | routes. Useful to reject requests which are not expecting the available 56 | response. 57 | 58 | ### Parameters 59 | 60 | - **app**: Roll app to register the extension against 61 | 62 | 63 | ### Requirements 64 | 65 | - mimetype-match>=1.0.4 66 | 67 | 68 | ## traceback 69 | 70 | Print the traceback on the server side if any. Handy for debugging. 71 | 72 | ### Parameters 73 | 74 | - **app**: Roll app to register the extension against 75 | 76 | 77 | ## igniter 78 | 79 | Display a BIG message when running the server. 80 | Quite useless, hence so essential! 81 | 82 | ### Parameters 83 | 84 | - **app**: Roll app to register the extension against 85 | 86 | 87 | ## static 88 | 89 | Serve static files. Should not be used in production. 90 | 91 | ### Parameters 92 | 93 | - **app**: Roll app to register the extension against 94 | - **prefix** (`str`, default=`/static/`): URL prefix to serve the statics 95 | - **root** (`str` or `pathlib.Path`, default=current executable path): 96 | filesystem path to look for static files 97 | - **default_index** (`str`, default=empty string): filename, for instance `index.html`, useful to serve a static HTML website 98 | - **name** (`str`, default=`static`): optional name to be used when calling `url_for` helper 99 | 100 | 101 | ## simple_server 102 | 103 | Special extension that does not rely on the events’ mechanism. 104 | 105 | Launch a local server on `127.0.0.1:3579` by default. 106 | 107 | ### Parameters 108 | 109 | - **app**: Roll app to register the extension against 110 | - **port** (`int`; default=`3579`): the port to listen 111 | - **host** (`str`; default=`127.0.0.1`): where to bind the server 112 | - **quiet** (`bool`; default=`False`): prevent the server to output startup 113 | debug infos 114 | 115 | ## named_url 116 | 117 | Allow two things: 118 | 119 | - name the routes 120 | - build routes URL from these names and their optional parameters. 121 | 122 | When using this extension, you can then optionaly pass a `name` parameter to the 123 | `route` decorator. Otherwise, the name will be computed from the route handler 124 | name. 125 | 126 | 127 | ### Parameters 128 | 129 | - **app**: Roll app to register the extension against 130 | 131 | 132 | ### Usage 133 | 134 | ```python 135 | from roll import Roll 136 | from roll.extensions import named_url 137 | 138 | app = Roll() 139 | 140 | # Registering the extension will return the `url_for` helper. 141 | url_for = named_url(app) 142 | 143 | 144 | # Define a route 145 | @app.route("/mypath/{myvar}") 146 | async def myroute(request, response, myvar): 147 | pass 148 | 149 | # Now we can build the url 150 | url_for("myroute", myvar="value") 151 | # /mypath/value 152 | 153 | 154 | # To control the route name, we can pass it as a route kwarg: 155 | @app.route("/mypath/{myvar}", name="custom_name") 156 | async def otherroute(request, response, myvar): 157 | pass 158 | 159 | # And then we can use it 160 | url_for("custom_name", myvar="value") 161 | ``` 162 | 163 | Hint: the helper can be attached to the `app`, to have it available everywhere: 164 | 165 | app.url_for = named_url(app) 166 | -------------------------------------------------------------------------------- /docs/tutorials.md: -------------------------------------------------------------------------------- 1 | # Tutorials 2 | 3 | A tutorial: 4 | 5 | * is learning-oriented 6 | * allows the newcomer to get started 7 | * is a lesson 8 | 9 | *Analogy: teaching a small child how to cook* 10 | 11 | 12 | ## Your first Roll application 13 | 14 | Make sure you [installed Roll first](how-to/basic.md#how-to-install-roll). 15 | 16 | The tinyest application you can make is this one: 17 | 18 | ```python3 19 | from roll import Roll 20 | from roll.extensions import simple_server 21 | 22 | app = Roll() 23 | 24 | 25 | @app.route('/hello/{parameter}') 26 | async def hello(request, response, parameter): 27 | response.body = f'Hello {parameter}' 28 | 29 | 30 | if __name__ == '__main__': 31 | simple_server(app) 32 | ``` 33 | 34 | Roll provides an asyncio protocol dealing with routes, requests and 35 | responses. Everything else is done via extensions. Default routing is 36 | done by [autoroutes](https://github.com/pyrates/autoroutes). 37 | 38 | *Note: if you are not familiar with that `f''` thing, it is Python 3.6 39 | shortcut for `.format()`.* 40 | 41 | To launch that application, run it with `python yourfile.py`. You should 42 | be able to perform HTTP requests against it: 43 | 44 | ``` 45 | $ curl localhost:3579/hello/world 46 | Hello world 47 | ``` 48 | 49 | *Note: [HTTPie](https://httpie.org/) is definitely a nicer replacement 50 | for curl so we will use it from now on. You can `pip install` it too.* 51 | 52 | ``` 53 | $ http :3579/hello/world 54 | HTTP/1.1 200 OK 55 | Content-Length: 11 56 | 57 | Hello world 58 | ``` 59 | 60 | That’s it! Celebrate that first step and… wait! 61 | We need to test that view before :-). 62 | 63 | 64 | ## Your first Roll test 65 | 66 | First install `pytest` and `pytest-asyncio`. 67 | 68 | Then create a `tests.py` file and copy-paste: 69 | 70 | ```python3 71 | from http import HTTPStatus 72 | 73 | import pytest 74 | 75 | from yourfile import app as app_ 76 | 77 | pytestmark = pytest.mark.asyncio 78 | 79 | 80 | @pytest.fixture(scope='function') 81 | def app(): 82 | return app_ 83 | 84 | 85 | async def test_hello_view(client, app): 86 | 87 | resp = await client.get('/hello/world') 88 | assert resp.status == HTTPStatus.OK 89 | assert resp.body == b'Hello world' 90 | ``` 91 | 92 | You will have to adapt the import of your `app` given the filename 93 | you gave during the previous part of the tutorial. 94 | 95 | Once it’s done, you can launch `py.test tests.py`. 96 | 97 | According to [pytest-asyncio documentation](https://github.com/pytest-dev/pytest-asyncio#modes), if `pip` has installed `pytest-asyncio >= 0.17` you will have warnings in command line results. You should read the doc to configure `pytest` correctly. To keep this tutorial simple and to avoid warnings, you can launch `py.test tests.py --asyncio-mode=auto`. 98 | 99 | *Note: in case the `client` fixture is not found, you probably did not 100 | [install `Roll`](how-to/basic.md#how-to-install-roll) correctly.* 101 | 102 | 103 | ## Your first Roll form 104 | 105 | Imagine a basic login view which is waiting for a username and password: 106 | 107 | ```python3 108 | from roll import Roll 109 | from roll.extensions import simple_server 110 | 111 | app = Roll() 112 | 113 | 114 | @app.route('/login', methods=['POST']) 115 | async def login(request, response): 116 | username = request.form.get('username') 117 | password = request.form.get('password') 118 | response.body = f'Username: `{username}` password: `{password}`.' 119 | 120 | 121 | if __name__ == '__main__': 122 | simple_server(app) 123 | ``` 124 | 125 | Now if we post our username/password information using HTTPie: 126 | 127 | ``` 128 | $ http --form POST :3579/login username=David password=123456 129 | HTTP/1.1 200 OK 130 | Content-Length: 37 131 | 132 | Username: `David` password: `123456`. 133 | ``` 134 | 135 | Obviously we do not want to return that kind of information but you get 136 | the point! You also have access to optional `.files`, check out the 137 | dedicated [reference section](reference/core.md#request) to learn more. 138 | 139 | 140 | ## Websockets 141 | 142 | Websockets can bring real-time dialog between a client, usually the browser, 143 | and your application. 144 | 145 | In a browser, using a websocket requires javascript. 146 | 147 | Server-side, your websocket endpoint is declared as a route. 148 | The main difference is that the route handler takes the websocket 149 | instead of the response as an argument. This websocket object can send 150 | and receive. 151 | 152 | For our example, we'll implement an echo endpoint that will simply 153 | parrot what it gets through the websocket: 154 | 155 | ```python3 156 | from roll import Roll 157 | from roll.extensions import simple_server 158 | 159 | app = Roll() 160 | 161 | @app.route('/ws', protocol="websocket") 162 | async def echo_websocket(request, ws, **params): 163 | async for message in ws: 164 | await ws.send(message) 165 | ``` 166 | 167 | The websocket will exit and close the communication with the client as 168 | soon as the endpoint execution is done. In this example, we use an 169 | endless loop that will asynchronously await for a message on the 170 | socket and asynchronously send it back. 171 | 172 | 173 | ## Using extensions 174 | 175 | There are a couple of extensions available to “enrich” your application. 176 | 177 | These extensions have to be applied to your Roll app, for instance: 178 | 179 | ```python3 180 | from roll import Roll 181 | from roll.extensions import logger, simple_server 182 | 183 | app = Roll() 184 | logger(app) # <- This is the only change we made! (+ import) 185 | 186 | @app.route('/hello/{parameter}') 187 | async def hello(request, response, parameter): 188 | response.body = f'Hello {parameter}' 189 | 190 | 191 | if __name__ == '__main__': 192 | simple_server(app) 193 | ``` 194 | 195 | Once you had that `logger` extension, each and every request will be 196 | logged on your server-side. Try it by yourself! 197 | 198 | Relaunch the server `$ python yourfile.py` and perform a new request with 199 | httpie: `$ http :3579/hello/world`. On your server terminal you should 200 | have something like that: 201 | 202 | ``` 203 | python yourfile.py 204 | Rolling on http://127.0.0.1:3579 205 | GET /hello/world 206 | ``` 207 | 208 | Notice the `GET` line, if you perform another HTTP request, a new line 209 | will appear. Quite handy for debugging! 210 | 211 | Another extension is very useful for debugging: `traceback`. Try to add 212 | it by yourself and raise any error *within* your view to see it in 213 | application (do not forget to restart your server!). 214 | 215 | See the [reference documentation](reference/extensions.md) for all 216 | built-in extensions. 217 | 218 | 219 | ## Using events 220 | 221 | Last but not least, you can directly use registered events to alter the 222 | behaviour of Roll at runtime. 223 | 224 | *Note: this is how extensions are working internally.* 225 | 226 | Let’s say you want to display a custom message when you launch your 227 | server: 228 | 229 | ```python3 230 | from roll import Roll 231 | from roll.extensions import simple_server 232 | 233 | app = Roll() 234 | 235 | 236 | @app.route('/hello/{parameter}') 237 | async def hello(request, response, parameter): 238 | response.body = f'Hello {parameter}' 239 | 240 | @app.listen('startup') # <- This is the part we added (3 lines) 241 | async def on_startup(): 242 | print('Example message') 243 | 244 | 245 | if __name__ == '__main__': 246 | simple_server(app) 247 | ``` 248 | 249 | Now restart your server and you should see the message printed. 250 | Wonderful. 251 | 252 | See the [reference documentation](reference/events.md) for all available 253 | events. 254 | -------------------------------------------------------------------------------- /examples/basic/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyrates/roll/2d12bb6f091f926fc92f475832aef06b6dbda2a3/examples/basic/__init__.py -------------------------------------------------------------------------------- /examples/basic/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | import uvloop 5 | from roll import Roll 6 | from roll.extensions import cors, igniter, logger, simple_server, traceback 7 | 8 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 9 | 10 | app = Roll() 11 | cors(app) 12 | logger(app) 13 | igniter(app) 14 | traceback(app) 15 | 16 | 17 | @app.route("/hello/{parameter}") 18 | async def hello(request, response, parameter): 19 | response.body = f"Hello {parameter}" 20 | 21 | 22 | @app.listen("startup") 23 | async def on_startup(): 24 | print("https://vimeo.com/34926862") 25 | 26 | 27 | if __name__ == "__main__": 28 | simple_server(app) 29 | -------------------------------------------------------------------------------- /examples/basic/tests.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | 5 | from .__main__ import app as app_ 6 | 7 | pytestmark = pytest.mark.asyncio 8 | 9 | 10 | @pytest.fixture(scope="function") 11 | def app(): 12 | return app_ 13 | 14 | 15 | async def test_hello_view(client, app): 16 | resp = await client.get("/hello/world") 17 | assert resp.status == HTTPStatus.OK 18 | assert resp.body == b"Hello world" 19 | -------------------------------------------------------------------------------- /examples/fullasync/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyrates/roll/2d12bb6f091f926fc92f475832aef06b6dbda2a3/examples/fullasync/__init__.py -------------------------------------------------------------------------------- /examples/fullasync/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | import uvloop 5 | from aiofile import AIOFile, Reader 6 | from roll import Roll 7 | from roll.extensions import cors, igniter, logger, simple_server, traceback 8 | 9 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 10 | 11 | app = Roll() 12 | cors(app) 13 | logger(app) 14 | traceback(app) 15 | 16 | 17 | @app.route("/fullasync", methods=["POST"], lazy_body=True) 18 | async def fullasync(request, response): 19 | response.body = (chunk async for chunk in request) 20 | 21 | 22 | if __name__ == "__main__": 23 | simple_server(app) 24 | -------------------------------------------------------------------------------- /examples/fullasync/tests.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | 5 | from .__main__ import app as app_ 6 | 7 | pytestmark = pytest.mark.asyncio 8 | 9 | 10 | @pytest.fixture(scope="function") 11 | def app(): 12 | return app_ 13 | 14 | 15 | async def test_stream_from_request_to_response(liveclient, app): 16 | # Use an iterable so the request will be chunked. 17 | body = (b"blah" for i in range(100)) 18 | resp = await liveclient.query("POST", "/fullasync", body=body) 19 | assert resp.status == HTTPStatus.OK 20 | assert resp.body == b"blah" * 100 21 | assert resp.chunks is not None 22 | assert len(resp.chunks) == 100 23 | for chunk in resp.chunks: 24 | assert chunk == b"blah" 25 | -------------------------------------------------------------------------------- /examples/html/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyrates/roll/2d12bb6f091f926fc92f475832aef06b6dbda2a3/examples/html/__init__.py -------------------------------------------------------------------------------- /examples/html/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | 4 | import uvloop 5 | from roll import Roll as BaseRoll 6 | from roll import Response 7 | from roll.extensions import logger, simple_server, traceback 8 | 9 | try: 10 | from jinja2 import Environment, PackageLoader, select_autoescape 11 | except ImportError: 12 | sys.exit('Install the Jinja2 package to be able to run this example.') 13 | 14 | 15 | env = Environment( 16 | loader=PackageLoader('html', 'templates'), 17 | autoescape=select_autoescape(['html']) 18 | ) 19 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 20 | 21 | 22 | class HTMLResponse(Response): 23 | 24 | def html(self, template_name, *args, **kwargs): 25 | self.headers['Content-Type'] = 'text/html; charset=utf-8' 26 | self.body = env.get_template(template_name).render(*args, **kwargs) 27 | 28 | 29 | class Roll(BaseRoll): 30 | Response = HTMLResponse 31 | 32 | 33 | app = Roll() 34 | logger(app) 35 | traceback(app) 36 | 37 | 38 | @app.route('/hello/{parameter}') 39 | async def hello(request, response, parameter): 40 | response.html('home.html', title='Hello', content=parameter) 41 | 42 | 43 | if __name__ == '__main__': 44 | simple_server(app) 45 | -------------------------------------------------------------------------------- /examples/html/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}Basic example{% endblock %} 6 | 7 | {% block content %}{% endblock %} 8 | 9 | -------------------------------------------------------------------------------- /examples/html/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ title }} — {{ super() }}{% endblock %} 4 | 5 | {% block content %} 6 |

{{ title }} {{ content }}

7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /examples/html/tests.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | 5 | from .__main__ import app as app_ 6 | 7 | pytestmark = pytest.mark.asyncio 8 | 9 | 10 | @pytest.fixture(scope='function') 11 | def app(): 12 | return app_ 13 | 14 | 15 | async def test_hello_view(client, app): 16 | 17 | resp = await client.get('/hello/world') 18 | assert resp.status == HTTPStatus.OK 19 | assert b'

Hello world

' in resp.body 20 | -------------------------------------------------------------------------------- /examples/streamresponse/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyrates/roll/2d12bb6f091f926fc92f475832aef06b6dbda2a3/examples/streamresponse/__init__.py -------------------------------------------------------------------------------- /examples/streamresponse/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | import uvloop 5 | from aiofile import AIOFile, Reader 6 | from roll import Roll 7 | from roll.extensions import cors, igniter, logger, simple_server, traceback 8 | 9 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 10 | 11 | app = Roll() 12 | cors(app) 13 | logger(app) 14 | traceback(app) 15 | 16 | 17 | cheering = os.path.join(os.path.dirname(__file__), "crowd-cheering.mp3") 18 | 19 | 20 | async def file_iterator(path): 21 | async with AIOFile(path, "rb") as afp: 22 | reader = Reader(afp, chunk_size=4096) 23 | async for data in reader: 24 | yield data 25 | 26 | 27 | @app.route("/cheer") 28 | async def cheer_for_streaming(request, response): 29 | filename = os.path.basename(cheering) 30 | response.body = file_iterator(cheering) 31 | response.headers["Content-Disposition"] = f"attachment; filename={filename}" 32 | 33 | 34 | if __name__ == "__main__": 35 | simple_server(app) 36 | -------------------------------------------------------------------------------- /examples/streamresponse/crowd-cheering.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyrates/roll/2d12bb6f091f926fc92f475832aef06b6dbda2a3/examples/streamresponse/crowd-cheering.mp3 -------------------------------------------------------------------------------- /examples/streamresponse/tests.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | 5 | from .__main__ import app as app_ 6 | 7 | pytestmark = pytest.mark.asyncio 8 | 9 | 10 | @pytest.fixture(scope="function") 11 | def app(): 12 | return app_ 13 | 14 | 15 | async def test_cheer_view(liveclient, app): 16 | resp = await liveclient.query("GET", "/cheer") 17 | assert resp.status == HTTPStatus.OK 18 | assert resp.chunks is not None 19 | assert len(resp.chunks) == 109 20 | assert len(resp.chunks[0]) == 4096 21 | assert sum(len(chunk) for chunk in resp.chunks) == 443926 22 | -------------------------------------------------------------------------------- /examples/websocket/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyrates/roll/2d12bb6f091f926fc92f475832aef06b6dbda2a3/examples/websocket/__init__.py -------------------------------------------------------------------------------- /examples/websocket/__main__.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import uvloop 3 | import asyncio 4 | from pathlib import Path 5 | from roll import Roll 6 | from roll.extensions import static, simple_server 7 | 8 | 9 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 10 | 11 | 12 | app = Roll() 13 | 14 | # Exposing the html folder to serve the files 15 | # Access : http://127.0.0.1:3579/static/websocket.html 16 | htmlfiles = Path(__file__).parent.joinpath('html') 17 | static(app, root=htmlfiles) 18 | 19 | 20 | @app.route('/chat', protocol="websocket") 21 | async def broadcast(request, ws, **params): 22 | wsid = str(uuid.uuid4()) 23 | await ws.send(f'Welcome {wsid} !') 24 | async for message in ws: 25 | for socket in request.app.websockets: 26 | if socket != ws: 27 | await socket.send('{}: {}'.format(wsid, message)) 28 | 29 | 30 | if __name__ == '__main__': 31 | print('Example access : http://127.0.0.1:3579/static/websocket.html') 32 | simple_server(app) 33 | -------------------------------------------------------------------------------- /examples/websocket/html/websocket.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Chat 5 | 6 | 7 | 8 | 9 |
    10 |
11 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Roll - async, simple, fast. 2 | nav: 3 | - Home: index.md 4 | - Tutorials: tutorials.md 5 | - Discussions: discussions.md 6 | - How-to guides: 7 | - Basic: how-to/basic.md 8 | - Developing: how-to/developing.md 9 | - Advanced: how-to/advanced.md 10 | - Reference: 11 | - Core: reference/core.md 12 | - Extensions: reference/extensions.md 13 | - Events: reference/events.md 14 | - Changelog: changelog.md 15 | theme: readthedocs 16 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | aiofile==2.0.4 2 | mimetype-match==1.0.4 3 | mkdocs==1.2.3 4 | pytest==7.1.0 5 | pytest-asyncio==0.18.1 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | autoroutes==0.3.8 2 | biscuits==0.3.2 3 | httptools==0.6.4 4 | multifruits==0.1.7 5 | websockets==8.1 6 | -------------------------------------------------------------------------------- /roll/__init__.py: -------------------------------------------------------------------------------- 1 | """Howdy fellow developer! 2 | 3 | We are glad you are taking a look at our code :-) 4 | Make sure to check out our documentation too: 5 | http://roll.readthedocs.io/en/latest/ 6 | 7 | If you do not understand why something is not working as expected, 8 | please submit an issue (or even better a pull-request with at least 9 | a test failing): https://github.com/pyrates/roll/issues/new 10 | """ 11 | 12 | import inspect 13 | from collections import defaultdict, namedtuple 14 | from http import HTTPStatus 15 | 16 | from autoroutes import Routes 17 | 18 | from .http import Cookies, Files, Form, HttpError, HTTPProtocol, Query 19 | from .io import Request, Response 20 | from .websocket import ConnectionClosed # noqa. Exposed for convenience. 21 | from .websocket import WSProtocol 22 | 23 | Route = namedtuple("Route", ["payload", "vars"]) 24 | HTTP_METHODS = [ 25 | "GET", 26 | "HEAD", 27 | "POST", 28 | "PUT", 29 | "DELETE", 30 | "TRACE", 31 | "OPTIONS", 32 | "CONNECT", 33 | "PATCH", 34 | ] 35 | 36 | 37 | class Roll(dict): 38 | """Deal with routes dispatching and events listening. 39 | 40 | You can subclass it to set your own `Protocol`, `Routes`, `Query`, `Form`, 41 | `Files`, `Request`, `Response` and/or `Cookies` class(es). 42 | """ 43 | 44 | HttpProtocol = HTTPProtocol 45 | WebsocketProtocol = WSProtocol 46 | Routes = Routes 47 | Query = Query 48 | Form = Form 49 | Files = Files 50 | Request = Request 51 | Response = Response 52 | Cookies = Cookies 53 | 54 | def __init__(self): 55 | self.routes = self.Routes() 56 | self.hooks = defaultdict(list) 57 | self._urls = {} 58 | 59 | async def startup(self): 60 | await self.hook("startup") 61 | 62 | async def shutdown(self): 63 | await self.hook("shutdown") 64 | 65 | async def __call__(self, request: Request, response: Response): 66 | payload = request.route.payload 67 | try: 68 | if not await self.hook("headers", request, response): 69 | if not payload: 70 | raise HttpError(HTTPStatus.NOT_FOUND, request.path) 71 | # Uppercased in order to only consider HTTP verbs. 72 | if request.method.upper() not in payload: 73 | raise HttpError(HTTPStatus.METHOD_NOT_ALLOWED) 74 | if not payload.get("lazy_body"): 75 | await request.load_body() 76 | if not await self.hook("request", request, response): 77 | handler = payload[request.method] 78 | await handler(request, response, **request.route.vars) 79 | except Exception as error: 80 | await self.on_error(request, response, error) 81 | try: 82 | # Views exceptions should still pass by the response hooks. 83 | await self.hook("response", request, response) 84 | except Exception as error: 85 | await self.on_error(request, response, error) 86 | return response 87 | 88 | async def on_error(self, request: Request, response: Response, error): 89 | if not isinstance(error, HttpError): 90 | error = HttpError(HTTPStatus.INTERNAL_SERVER_ERROR, context=error) 91 | response.status = error.status 92 | response.body = error.message 93 | try: 94 | await self.hook("error", request, response, error) 95 | except Exception as e: 96 | response.status = HTTPStatus.INTERNAL_SERVER_ERROR 97 | response.body = str(e) 98 | 99 | def factory(self): 100 | return self.HttpProtocol(self) 101 | 102 | def lookup(self, request): 103 | request.route = Route(*self.routes.match(request.path)) 104 | 105 | def _get_protocol_class(self, protocol): 106 | klass_attr = protocol.title() + "Protocol" 107 | klass = getattr(self, klass_attr, None) 108 | assert klass, ( 109 | f"No class handler declared for {protocol} protocol. " 110 | f"Add a {klass_attr} key to your Roll app." 111 | ) 112 | return klass 113 | 114 | def route( 115 | self, path: str, methods: list = None, protocol: str = "http", **extras: dict 116 | ): 117 | protocol_class = self._get_protocol_class(protocol) 118 | # Computed at load time for perf. 119 | extras["protocol"] = protocol 120 | extras["_protocol_class"] = protocol_class 121 | 122 | def add_route(view): 123 | nonlocal methods 124 | if inspect.isclass(view): 125 | inst = view() 126 | if methods is not None: 127 | raise AttributeError("Can't use `methods` with class view") 128 | payload = {} 129 | for method in HTTP_METHODS: 130 | key = f"on_{method.lower()}" 131 | func = getattr(inst, key, None) 132 | if func: 133 | payload[method] = func 134 | if not payload: 135 | raise ValueError(f"Empty view: {view}") 136 | else: 137 | if methods is None: 138 | methods = ["GET"] 139 | payload = {method: view for method in methods} 140 | payload.update(extras) 141 | if protocol_class.ALLOWED_METHODS: 142 | assert set(methods) <= set(protocol_class.ALLOWED_METHODS) 143 | self.routes.add(path, **payload) 144 | self._sync_hook("route:add", path, view, **extras) 145 | return view 146 | 147 | return add_route 148 | 149 | def listen(self, name: str): 150 | def wrapper(func): 151 | self.hooks[name].append(func) 152 | 153 | return wrapper 154 | 155 | def _sync_hook(self, name_: str, *args, **kwargs): 156 | for func in self.hooks[name_]: 157 | result = func(*args, **kwargs) 158 | if result: # Allows to shortcut the chain. 159 | return result 160 | 161 | async def hook(self, name_: str, *args, **kwargs): 162 | for func in self.hooks[name_]: 163 | result = await func(*args, **kwargs) 164 | if result: # Allows to shortcut the chain. 165 | return result 166 | -------------------------------------------------------------------------------- /roll/extensions.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import mimetypes 4 | import re 5 | import sys 6 | from http import HTTPStatus 7 | from pathlib import Path 8 | from textwrap import dedent 9 | from traceback import print_exc 10 | 11 | from . import HTTP_METHODS, HttpError 12 | 13 | 14 | def cors(app, origin="*", methods=None, headers=None, credentials=False): 15 | if methods == "*": 16 | methods = HTTP_METHODS 17 | 18 | @app.listen("response") 19 | async def add_cors_headers(request, response): 20 | response.headers["Access-Control-Allow-Origin"] = origin 21 | if methods is not None: 22 | allow_methods = ",".join(methods) 23 | response.headers["Access-Control-Allow-Methods"] = allow_methods 24 | if headers is not None: 25 | allow_headers = ",".join(headers) 26 | response.headers["Access-Control-Allow-Headers"] = allow_headers 27 | if credentials: 28 | response.headers["Access-Control-Allow-Credentials"] = "true" 29 | 30 | 31 | def websockets_store(app): 32 | if "websockets" not in app: 33 | app["websockets"] = set() 34 | assert isinstance(app["websockets"], set) 35 | 36 | @app.listen("websocket_connect") 37 | async def store(request, ws): 38 | request.app["websockets"].add(ws) 39 | 40 | @app.listen("websocket_disconnect") 41 | async def remove(request, ws): 42 | request.app["websockets"].discard(ws) 43 | 44 | 45 | def logger(app, level=logging.DEBUG, handler=None): 46 | logger = logging.getLogger("roll") 47 | logger.setLevel(level) 48 | handler = handler or logging.StreamHandler() 49 | 50 | @app.listen("request") 51 | async def log_request(request, response): 52 | logger.info("%s %s", request.method, request.url.decode()) 53 | 54 | @app.listen("startup") 55 | async def startup(): 56 | logger.addHandler(handler) 57 | 58 | @app.listen("shutdown") 59 | async def shutdown(): 60 | logger.removeHandler(handler) 61 | 62 | 63 | def options(app): 64 | @app.listen("headers") 65 | async def handle_options(request, response): 66 | # Shortcut the request handling for OPTIONS requests. 67 | return request.method == "OPTIONS" 68 | 69 | 70 | def content_negociation(app): 71 | try: 72 | from mimetype_match import get_best_match 73 | except ImportError: 74 | sys.exit( 75 | "Please install mimetype-match>=1.0.4 to be able to use the " 76 | "content_negociation extension." 77 | ) 78 | 79 | @app.listen("request") 80 | async def reject_unacceptable_requests(request, response): 81 | accept = request.headers.get("ACCEPT") 82 | accepts = request.route.payload["accepts"] 83 | if accept is None or get_best_match(accept, accepts) is None: 84 | raise HttpError(HTTPStatus.NOT_ACCEPTABLE) 85 | 86 | 87 | def traceback(app): 88 | @app.listen("error") 89 | async def on_error(request, response, error): 90 | if error.status == HTTPStatus.INTERNAL_SERVER_ERROR: 91 | print_exc() 92 | 93 | 94 | def igniter(app): 95 | @app.listen("startup") 96 | async def make_it_roll_like_it_never_rolled_before(): 97 | logger = logging.getLogger("roll") 98 | logger.debug(r""" 99 | _ _ _ _ 100 | | | | | () | | | 101 | | | ___| |_ / ___ ____ ___ | | | 102 | | | / _ \ __| / __| | __/ _ \| | | 103 | | |___| __/ |_ \__ \ | | | (_) | | | 104 | |______\___|\__| |___/ |_| \___/|_|_| () 105 | 106 | """) 107 | 108 | 109 | def simple_server(app, port=3579, host="127.0.0.1", quiet=False): 110 | app.loop = asyncio.get_event_loop() 111 | app.loop.run_until_complete(app.startup()) 112 | if not quiet: 113 | print(f"Rolling on http://{host}:{port}") 114 | server = app.loop.create_server(app.factory, host, port) 115 | app.loop.create_task(server) 116 | try: 117 | app.loop.run_forever() 118 | except KeyboardInterrupt: 119 | if not quiet: 120 | print("Bye.") 121 | finally: 122 | app.loop.run_until_complete(app.shutdown()) 123 | server.close() 124 | app.loop.close() 125 | 126 | 127 | def static(app, prefix="/static/", root=Path(), default_index="", name="static"): 128 | """Serve static files. Never use in production.""" 129 | 130 | root = Path(root).resolve() 131 | 132 | if not prefix.endswith("/"): 133 | prefix += "/" 134 | prefix += "{path:path}" 135 | 136 | async def serve(request, response, path): 137 | abspath = (root / path).resolve() 138 | if abspath.is_dir(): 139 | abspath /= default_index 140 | if root not in abspath.parents: 141 | raise HttpError(HTTPStatus.BAD_REQUEST, abspath) 142 | if not abspath.exists(): 143 | raise HttpError(HTTPStatus.NOT_FOUND, abspath) 144 | content_type, encoding = mimetypes.guess_type(str(abspath)) 145 | with abspath.open("rb") as source: 146 | response.body = source.read() 147 | response.headers["Content-Type"] = ( 148 | content_type or "application/octet-stream" 149 | ) 150 | 151 | @app.listen("startup") 152 | async def register_route(): 153 | app.route(prefix, name=name)(serve) 154 | 155 | 156 | def named_url(app): 157 | # Everything between the colon and the closing braket, including the colon but not the 158 | # braket. 159 | clean_path_pattern = re.compile(r":[^}]+(?=})") 160 | registry = {} 161 | 162 | @app.listen("route:add") 163 | def on_route_add(path, view, **extras): 164 | cleaned = clean_path_pattern.sub("", path) 165 | name = extras.pop("name", None) 166 | if not name: 167 | name = view.__name__.lower() 168 | if name in registry: 169 | _, handler = registry[name] 170 | if handler != view: 171 | ref = f"{handler.__module__}.{handler.__name__}" 172 | raise ValueError( 173 | dedent( 174 | f"""\ 175 | Route with name {name} already exists: {ref}. 176 | Hints: 177 | - use a `name` in your `@app.route` declaration 178 | - use functools.wraps or equivalent if you decorate your views 179 | - use a `name` if you use the `static` extension twice 180 | """ 181 | ) 182 | ) 183 | registry[name] = cleaned, view 184 | 185 | def url_for(name: str, **kwargs): 186 | try: 187 | path, _ = registry[name] 188 | return path.format(**kwargs) # Raises a KeyError too if some param misses 189 | except KeyError: 190 | raise ValueError(f"No route found with name {name} and params {kwargs}") 191 | 192 | return url_for 193 | -------------------------------------------------------------------------------- /roll/http.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from http import HTTPStatus 3 | from io import BytesIO 4 | from typing import TypeVar 5 | from urllib.parse import unquote 6 | 7 | from biscuits import Cookie 8 | from httptools import HttpParserError, HttpParserUpgrade, HttpRequestParser, parse_url 9 | from multifruits import Parser, extract_filename, parse_content_disposition 10 | 11 | HttpCode = TypeVar("HttpCode", HTTPStatus, int) 12 | 13 | 14 | # Prevent creating new HTTPStatus instances when 15 | # dealing with integer statuses. 16 | STATUSES = {} 17 | 18 | for status in HTTPStatus: 19 | STATUSES[status.value] = status 20 | 21 | 22 | class HttpError(Exception): 23 | """Exception meant to be raised when an error is occurring. 24 | 25 | E.g.: 26 | Within your view `raise HttpError(HTTPStatus.BAD_REQUEST)` will 27 | direcly return a 400 HTTP status code with descriptive content. 28 | """ 29 | 30 | __slots__ = ("status", "message") 31 | 32 | def __init__( 33 | self, http_code: HttpCode, message: str = None, context: Exception = None 34 | ): 35 | # Idempotent if `http_code` is already an `HTTPStatus` instance. 36 | self.status = HTTPStatus(http_code) 37 | if context: 38 | # Keep track of the original error. 39 | # Mimic what python does we we run "raise X from Y". 40 | if not message: 41 | message = str(context).encode() 42 | self.__context__ = context 43 | self.message = message or self.status.phrase 44 | 45 | 46 | class Multidict(dict): 47 | """Data structure to deal with several values for the same key. 48 | 49 | Useful for query string parameters or form-like POSTed ones. 50 | """ 51 | 52 | def get(self, key: str, default=...): 53 | return self.list(key, [default])[0] 54 | 55 | def list(self, key: str, default=...): 56 | try: 57 | return self[key] 58 | except KeyError: 59 | if default is ... or default == [...]: 60 | raise HttpError(HTTPStatus.BAD_REQUEST, f"Missing '{key}' key") 61 | return default 62 | 63 | 64 | class Query(Multidict): 65 | """Allow to access casted GET parameters from `request.query`. 66 | 67 | E.g.: 68 | `request.query.int('weight', 0)` will return an integer or zero. 69 | """ 70 | 71 | TRUE_STRINGS = ("t", "true", "yes", "1", "on") 72 | FALSE_STRINGS = ("f", "false", "no", "0", "off") 73 | NONE_STRINGS = ("n", "none", "null") 74 | 75 | def bool(self, key: str, default=...): 76 | value = self.get(key, default) 77 | if value in (True, False, None): 78 | return value 79 | value = value.lower() 80 | if value in self.TRUE_STRINGS: 81 | return True 82 | elif value in self.FALSE_STRINGS: 83 | return False 84 | elif value in self.NONE_STRINGS: 85 | return None 86 | raise HttpError( 87 | HTTPStatus.BAD_REQUEST, f"Wrong boolean value for '{key}={value}'" 88 | ) 89 | 90 | def int(self, key: str, default=...): 91 | try: 92 | return int(self.get(key, default)) 93 | except ValueError: 94 | raise HttpError( 95 | HTTPStatus.BAD_REQUEST, f"Key '{key}' must be castable to int" 96 | ) 97 | 98 | def float(self, key: str, default=...): 99 | try: 100 | return float(self.get(key, default)) 101 | except ValueError: 102 | raise HttpError( 103 | HTTPStatus.BAD_REQUEST, f"Key '{key}' must be castable to float" 104 | ) 105 | 106 | 107 | class Form(Query): 108 | """Allow to access casted POST parameters from `request.body`.""" 109 | 110 | 111 | class Files(Multidict): 112 | """Allow to access POSTed files from `request.body`.""" 113 | 114 | 115 | class Multipart: 116 | """Responsible of the parsing of multipart encoded `request.body`.""" 117 | 118 | __slots__ = ( 119 | "app", 120 | "form", 121 | "files", 122 | "_parser", 123 | "_current", 124 | "_current_headers", 125 | "_current_params", 126 | ) 127 | 128 | def __init__(self, app): 129 | self.app = app 130 | 131 | def initialize(self, content_type: str): 132 | self._parser = Parser(self, content_type.encode()) 133 | self.form = self.app.Form() 134 | self.files = self.app.Files() 135 | return self.form, self.files 136 | 137 | def feed_data(self, data: bytes): 138 | self._parser.feed_data(data) 139 | 140 | def on_part_begin(self): 141 | self._current_headers = {} 142 | 143 | def on_header(self, field: bytes, value: bytes): 144 | self._current_headers[field] = value 145 | 146 | def on_headers_complete(self): 147 | disposition_type, params = parse_content_disposition( 148 | self._current_headers.get(b"Content-Disposition") 149 | ) 150 | if not disposition_type: 151 | return 152 | self._current_params = params 153 | if b"Content-Type" in self._current_headers: 154 | self._current = BytesIO() 155 | self._current.filename = extract_filename(params) 156 | self._current.content_type = self._current_headers[b"Content-Type"] 157 | self._current.params = params 158 | else: 159 | self._current = "" 160 | 161 | def on_data(self, data: bytes): 162 | if b"Content-Type" in self._current_headers: 163 | self._current.write(data) 164 | else: 165 | self._current += data.decode() 166 | 167 | def on_part_complete(self): 168 | name = self._current_params.get(b"name", b"").decode() 169 | if b"Content-Type" in self._current_headers: 170 | if name not in self.files: 171 | self.files[name] = [] 172 | self._current.seek(0) 173 | self.files[name].append(self._current) 174 | else: 175 | if name not in self.form: 176 | self.form[name] = [] 177 | self.form[name].append(self._current) 178 | self._current = None 179 | 180 | 181 | class Cookies(dict): 182 | """A Cookies management class, built on top of biscuits.""" 183 | 184 | def set(self, name, *args, **kwargs): 185 | self[name] = Cookie(name, *args, **kwargs) 186 | 187 | 188 | class HTTPProtocol(asyncio.Protocol): 189 | """Responsible of parsing the request and writing the response.""" 190 | 191 | __slots__ = ( 192 | "app", 193 | "request", 194 | "parser", 195 | "response", 196 | "transport", 197 | "task", 198 | "is_chunked", 199 | "draining", 200 | ) 201 | _BODYLESS_METHODS = ("HEAD", "CONNECT") 202 | _BODYLESS_STATUSES = ( 203 | HTTPStatus.CONTINUE, 204 | HTTPStatus.SWITCHING_PROTOCOLS, 205 | HTTPStatus.PROCESSING, 206 | HTTPStatus.NO_CONTENT, 207 | HTTPStatus.NOT_MODIFIED, 208 | ) 209 | RequestParser = HttpRequestParser 210 | NEEDS_UPGRADE = False 211 | ALLOWED_METHODS = None # Means all. 212 | 213 | def __init__(self, app): 214 | self.app = app 215 | self.parser = self.RequestParser(self) 216 | self.task = None 217 | self.is_chunked = False 218 | self.draining = False 219 | 220 | def connection_made(self, transport): 221 | self.transport = transport 222 | 223 | def data_received(self, data: bytes): 224 | try: 225 | self.parser.feed_data(data) 226 | except HttpParserUpgrade: 227 | # The upgrade raise is done after all the on_x 228 | # We acted upon the upgrade earlier, so we just pass. 229 | pass 230 | except HttpParserError as error: 231 | # If the parsing failed before on_message_begin, we don't have a 232 | # response. 233 | self.response = self.app.Response(self.app, self) 234 | # Original error stored by httptools. 235 | if isinstance(error.__context__, HttpError): 236 | error = error.__context__ 237 | self.response.status = error.status 238 | self.response.body = error.message 239 | else: 240 | self.response.status = HTTPStatus.BAD_REQUEST 241 | self.response.body = ( 242 | b"Unparsable request:" + str(error.__context__).encode() 243 | ) 244 | self.task = self.app.loop.create_task(self.write()) 245 | 246 | async def upgraded(self): 247 | handler_protocol = self.request.route.payload.get("protocol", "http") 248 | 249 | if self.request.upgrade != handler_protocol: 250 | raise HttpError(HTTPStatus.NOT_IMPLEMENTED, "Request cannot be upgraded.") 251 | 252 | protocol_class = self.request.route.payload["_protocol_class"] 253 | new_protocol = protocol_class(self.request) 254 | new_protocol.handshake(self.response) 255 | self.response.status = HTTPStatus.SWITCHING_PROTOCOLS 256 | await self.write() 257 | new_protocol.connection_made(self.transport) 258 | new_protocol.connection_open() 259 | self.transport.set_protocol(new_protocol) 260 | await new_protocol.run() 261 | 262 | # All on_xxx methods are in use by httptools parser. 263 | # See https://github.com/MagicStack/httptools#apis 264 | def on_header(self, name: bytes, value: bytes): 265 | self.request.headers[name.decode().upper()] = value.decode() 266 | 267 | def on_body(self, data: bytes): 268 | if self.draining: 269 | # Draining mode: do not load data at all. 270 | return 271 | # Save the first chunk. 272 | self.request.queue.put(data) 273 | # And let the user decide if we should continue reading or not. 274 | self.pause_reading() 275 | 276 | def on_url(self, url: bytes): 277 | self.request.method = self.parser.get_method().decode().upper() 278 | self.request.url = url 279 | parsed = parse_url(url) 280 | self.request.path = unquote(parsed.path.decode()) 281 | self.request.query_string = (parsed.query or b"").decode() 282 | self.app.lookup(self.request) 283 | 284 | def on_message_begin(self): 285 | self.request = self.app.Request(self.app, self) 286 | self.response = self.app.Response(self.app, self) 287 | 288 | def on_message_complete(self): 289 | self.request.queue.end() 290 | 291 | def on_headers_complete(self): 292 | if self.parser.should_upgrade(): 293 | # An upgrade has been requested 294 | self.request.upgrade = self.request.headers["UPGRADE"].lower() 295 | handler_protocol = self.request.route.payload.get("protocol", "http") 296 | if self.request.upgrade != handler_protocol: 297 | raise HttpError( 298 | HTTPStatus.NOT_IMPLEMENTED, "Request cannot be upgraded." 299 | ) 300 | self.task = self.app.loop.create_task(self.upgraded()) 301 | else: 302 | # No upgrade was requested 303 | payload = self.request.route.payload 304 | if payload and payload["_protocol_class"].NEEDS_UPGRADE: 305 | # The handler need and upgrade: we need to complain. 306 | raise HttpError(HTTPStatus.UPGRADE_REQUIRED) 307 | # No upgrade was required and the handler didn't need any. 308 | # We run the normal task. 309 | self.task = self.app.loop.create_task(self()) 310 | 311 | async def __call__(self): 312 | await self.app(self.request, self.response) 313 | await self.write() 314 | 315 | async def write_body(self): 316 | if self.is_chunked: 317 | async for data in self.response.body: 318 | # Writing the chunk. 319 | if not isinstance(data, bytes): 320 | data = str(data).encode() 321 | self.transport.write(b"%x\r\n%b\r\n" % (len(data), data)) 322 | self.transport.write(b"0\r\n\r\n") 323 | else: 324 | self.transport.write(self.response.body) 325 | 326 | # May or may not have "future" as arg. 327 | async def write(self, *args): 328 | # Appends bytes for performances. 329 | payload = b"HTTP/1.1 %a %b\r\n" % ( 330 | self.response.status.value, 331 | self.response.status.phrase.encode(), 332 | ) 333 | 334 | # https://tools.ietf.org/html/rfc7230#section-3.3.2 :scream: 335 | bodyless = self.response.status in self._BODYLESS_STATUSES or ( 336 | hasattr(self, "request") and self.request.method in self._BODYLESS_METHODS 337 | ) 338 | 339 | if not bodyless: 340 | self.is_chunked = hasattr(self.response.body, "__aiter__") 341 | if self.is_chunked: 342 | self.response.headers.setdefault("Transfer-Encoding", "chunked") 343 | else: 344 | if not isinstance(self.response.body, bytes): 345 | self.response.body = str(self.response.body).encode() 346 | if "Content-Length" not in self.response.headers: 347 | length = len(self.response.body) 348 | self.response.headers["Content-Length"] = length 349 | 350 | if self.response._cookies: 351 | # https://tools.ietf.org/html/rfc7230#page-23 352 | for cookie in self.response.cookies.values(): 353 | payload += b"Set-Cookie: %b\r\n" % str(cookie).encode() 354 | for key, value in self.response.headers.items(): 355 | payload += b"%b: %b\r\n" % (key.encode(), str(value).encode()) 356 | payload += b"\r\n" 357 | if self.transport.is_closing(): 358 | # Request has been aborted, thus socket as been closed, thus 359 | # transport has been closed? 360 | return 361 | try: 362 | self.transport.write(payload) 363 | if self.response.body and not bodyless: 364 | await self.write_body() 365 | except RuntimeError: # transport may still be closed during write. 366 | # TODO: Pass into error hook when write is async. 367 | pass 368 | else: 369 | if not self.parser.should_keep_alive(): 370 | self.transport.close() 371 | # Drain request body, in case an error has raised before fully 372 | # consuming it in the normal process, so the transport is free to handle 373 | # a new request. 374 | self.drain() 375 | 376 | def pause_reading(self): 377 | self.transport.pause_reading() 378 | 379 | def resume_reading(self): 380 | self.transport.resume_reading() 381 | 382 | def drain(self): 383 | # Consume the request body, but prevent on_body to load it in memory. 384 | self.draining = True 385 | self.resume_reading() 386 | self.draining = False 387 | -------------------------------------------------------------------------------- /roll/io.py: -------------------------------------------------------------------------------- 1 | from asyncio import Event 2 | from http import HTTPStatus 3 | from queue import deque 4 | from urllib.parse import parse_qs 5 | 6 | from biscuits import parse 7 | 8 | from .http import STATUSES, HttpCode, HttpError, Multipart 9 | 10 | try: 11 | # In case you use json heavily, we recommend installing 12 | # https://pypi.python.org/pypi/ujson for better performances. 13 | import ujson as json 14 | 15 | JSONDecodeError = ValueError 16 | except ImportError: 17 | import json as json 18 | from json.decoder import JSONDecodeError 19 | 20 | 21 | class StreamQueue: 22 | def __init__(self): 23 | self.items = deque() 24 | self.event = Event() 25 | self.waiting = False 26 | self.dirty = False 27 | self.finished = False 28 | 29 | async def get(self): 30 | try: 31 | return self.items.popleft() 32 | except IndexError: 33 | if self.finished is True: 34 | return b"" 35 | else: 36 | self.event.clear() 37 | self.waiting = True 38 | await self.event.wait() 39 | self.event.clear() 40 | self.waiting = False 41 | return self.items.popleft() 42 | 43 | def put(self, item): 44 | self.dirty = True 45 | self.items.append(item) 46 | if self.waiting is True: 47 | self.event.set() 48 | 49 | def clear(self): 50 | if self.dirty: 51 | self.items.clear() 52 | self.event.clear() 53 | self.dirty = False 54 | self.finished = False 55 | 56 | def end(self): 57 | if self.waiting: 58 | self.put(None) 59 | self.finished = True 60 | 61 | 62 | class Request(dict): 63 | """A container for the result of the parsing on each request. 64 | 65 | The default parsing is made by `httptools.HttpRequestParser`. 66 | """ 67 | 68 | __slots__ = ( 69 | "app", 70 | "url", 71 | "path", 72 | "query_string", 73 | "_query", 74 | "_body", 75 | "method", 76 | "_chunk", 77 | "headers", 78 | "route", 79 | "_cookies", 80 | "_form", 81 | "_files", 82 | "upgrade", 83 | "protocol", 84 | "queue", 85 | "_json", 86 | ) 87 | 88 | def __init__(self, app, protocol): 89 | self.app = app 90 | self.protocol = protocol 91 | self.queue = StreamQueue() 92 | self.headers = {} 93 | self._body = None 94 | self._chunk = b"" 95 | self.method = None 96 | self.upgrade = None 97 | self._cookies = None 98 | self._query = None 99 | self._form = None 100 | self._files = None 101 | self._json = None 102 | 103 | @property 104 | def cookies(self): 105 | if self._cookies is None: 106 | self._cookies = parse(self.headers.get("COOKIE", "")) 107 | return self._cookies 108 | 109 | @property 110 | def query(self): 111 | if self._query is None: 112 | parsed_qs = parse_qs(self.query_string, keep_blank_values=True) 113 | self._query = self.app.Query(parsed_qs) 114 | return self._query 115 | 116 | def _parse_multipart(self): 117 | parser = Multipart(self.app) 118 | self._form, self._files = parser.initialize(self.content_type) 119 | try: 120 | parser.feed_data(self.body) 121 | except ValueError: 122 | raise HttpError(HTTPStatus.BAD_REQUEST, "Unparsable multipart body") 123 | 124 | def _parse_urlencoded(self): 125 | try: 126 | parsed_qs = parse_qs( 127 | self.body.decode(), keep_blank_values=True, strict_parsing=True 128 | ) 129 | except ValueError: 130 | raise HttpError(HTTPStatus.BAD_REQUEST, "Unparsable urlencoded body") 131 | self._form = self.app.Form(parsed_qs) 132 | 133 | @property 134 | def form(self): 135 | if self._form is None: 136 | if "multipart/form-data" in self.content_type: 137 | self._parse_multipart() 138 | elif "application/x-www-form-urlencoded" in self.content_type: 139 | self._parse_urlencoded() 140 | else: 141 | self._form = self.app.Form() 142 | return self._form 143 | 144 | @property 145 | def files(self): 146 | if self._files is None: 147 | if "multipart/form-data" in self.content_type: 148 | self._parse_multipart() 149 | else: 150 | self._files = self.app.Files() 151 | return self._files 152 | 153 | @property 154 | def json(self): 155 | if self._json is None: 156 | try: 157 | self._json = json.loads(self.body) 158 | except (UnicodeDecodeError, JSONDecodeError): 159 | raise HttpError(HTTPStatus.BAD_REQUEST, "Unparsable JSON body") 160 | return self._json 161 | 162 | @property 163 | def content_type(self): 164 | return self.headers.get("CONTENT-TYPE", "") 165 | 166 | @property 167 | def host(self): 168 | return self.headers.get("HOST", "") 169 | 170 | @property 171 | def referrer(self): 172 | # https://en.wikipedia.org/wiki/HTTP_referer#Etymology 173 | return self.headers.get("REFERER", "") 174 | 175 | referer = referrer 176 | 177 | @property 178 | def origin(self): 179 | return self.headers.get("ORIGIN", "") 180 | 181 | @property 182 | def body(self): 183 | if self._body is None: 184 | raise HttpError( 185 | HTTPStatus.INTERNAL_SERVER_ERROR, "Trying to consume lazy body" 186 | ) 187 | return self._body 188 | 189 | @body.setter 190 | def body(self, data): 191 | self._body = data 192 | 193 | async def load_body(self): 194 | if self._body is None: 195 | self._body = b"" 196 | async for chunk in self: 197 | self._body += chunk 198 | 199 | async def read(self): 200 | await self.load_body() 201 | return self._body 202 | 203 | async def __aiter__(self): 204 | # TODO raise if already consumed? 205 | while True: 206 | self.protocol.resume_reading() 207 | data = await self.queue.get() 208 | if not data: 209 | break 210 | self.protocol.pause_reading() 211 | yield data 212 | 213 | 214 | class Response: 215 | """A container for `status`, `headers` and `body`.""" 216 | 217 | __slots__ = ("app", "_status", "headers", "body", "_cookies", "protocol") 218 | 219 | def __init__(self, app, protocol): 220 | self.app = app 221 | self.protocol = protocol 222 | self.body = b"" 223 | self.status = HTTPStatus.OK 224 | self.headers = {} 225 | self._cookies = None 226 | 227 | @property 228 | def status(self): 229 | return self._status 230 | 231 | @status.setter 232 | def status(self, http_code: HttpCode): 233 | # Idempotent if `http_code` is already an `HTTPStatus` instance. 234 | self._status = STATUSES[http_code] 235 | 236 | def json(self, value: dict): 237 | # Shortcut from a dict to JSON with proper content type. 238 | self.headers["Content-Type"] = "application/json; charset=utf-8" 239 | self.body = json.dumps(value) 240 | 241 | json = property(None, json) 242 | 243 | @property 244 | def cookies(self): 245 | if self._cookies is None: 246 | self._cookies = self.app.Cookies() 247 | return self._cookies 248 | 249 | @property 250 | def redirect(self): 251 | return self.headers.get("Location"), self.status 252 | 253 | @redirect.setter 254 | def redirect(self, to): 255 | """Shortcut to set a redirect.""" 256 | location, status = to 257 | self.headers["Location"] = location 258 | self.status = status 259 | -------------------------------------------------------------------------------- /roll/testing.py: -------------------------------------------------------------------------------- 1 | import http.client 2 | import json 3 | import mimetypes 4 | from functools import partial 5 | from http import HTTPStatus 6 | from io import BytesIO 7 | from urllib.parse import urlencode, quote, urlparse, parse_qsl 8 | from uuid import uuid4 9 | 10 | import pytest 11 | 12 | 13 | def encode_multipart(data, charset="utf-8"): 14 | # Ported from Werkzeug testing. 15 | boundary = f"---------------Boundary{uuid4().hex}" 16 | body = BytesIO() 17 | 18 | def write(string): 19 | body.write(string.encode(charset)) 20 | 21 | if isinstance(data, dict): 22 | data = data.items() 23 | 24 | for key, values in data: 25 | if not isinstance(values, (list, tuple)): 26 | values = [values] 27 | for value in values: 28 | write(f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"') 29 | reader = getattr(value, "read", None) 30 | if reader is not None: 31 | filename = getattr(value, "filename", getattr(value, "name", None)) 32 | content_type = getattr(value, "content_type", None) 33 | if content_type is None: 34 | content_type = ( 35 | filename 36 | and mimetypes.guess_type(filename)[0] 37 | or "application/octet-stream" 38 | ) 39 | if filename is not None: 40 | write(f'; filename="{filename}"\r\n') 41 | else: 42 | write("\r\n") 43 | write(f"Content-Type: {content_type}\r\n\r\n") 44 | while 1: 45 | chunk = reader(16384) 46 | if not chunk: 47 | break 48 | body.write(chunk) 49 | else: 50 | if not isinstance(value, str): 51 | value = str(value) 52 | else: 53 | value = value.encode(charset) 54 | write("\r\n\r\n") 55 | body.write(value) 56 | write("\r\n") 57 | write(f"--{boundary}--\r\n") 58 | 59 | body.seek(0) 60 | content_type = f"multipart/form-data; boundary={boundary}" 61 | return body.read(), content_type 62 | 63 | 64 | def encode_path(path): 65 | parsed = urlparse(path) 66 | out = quote(parsed.path) 67 | if parsed.query: 68 | query = parse_qsl(parsed.query, keep_blank_values=True) 69 | out += "?" + "&".join(f"{k}={quote(v)}" for k, v in query) 70 | return out.encode() 71 | 72 | 73 | class Transport: 74 | def __init__(self): 75 | self.data = b"" 76 | self._closing = False 77 | 78 | def is_closing(self): 79 | return self._closing 80 | 81 | def write(self, data): 82 | self.data += data 83 | 84 | def close(self): 85 | self._closing = True 86 | 87 | def pause_reading(self): 88 | pass 89 | 90 | def resume_reading(self): 91 | pass 92 | 93 | 94 | class Client: 95 | # Default content type for request body encoding, change it to your own 96 | # taste if needed. 97 | content_type = "application/json; charset=utf-8" 98 | # Default headers to use eg. for patching Auth in tests. 99 | default_headers = {} 100 | 101 | def __init__(self, app): 102 | self.app = app 103 | 104 | def handle_files(self, kwargs): 105 | kwargs.setdefault("headers", {}) 106 | files = kwargs.pop("files", None) 107 | if files: 108 | kwargs["headers"]["Content-Type"] = "multipart/form-data" 109 | if isinstance(files, dict): 110 | files = files.items() 111 | for key, els in files: 112 | if not els: 113 | continue 114 | if not isinstance(els, (list, tuple)): 115 | # Allow passing a file instance. 116 | els = [els] 117 | file_ = els[0] 118 | if isinstance(file_, str): 119 | file_ = file_.encode() 120 | if isinstance(file_, bytes): 121 | file_ = BytesIO(file_) 122 | if len(els) > 1: 123 | file_.name = els[1] 124 | if len(els) > 2: 125 | file_.charset = els[2] 126 | kwargs["body"][key] = file_ 127 | 128 | def encode_body(self, body, headers): 129 | content_type = headers.get("Content-Type") 130 | if not body or isinstance(body, (str, bytes)): 131 | return body, headers 132 | if not content_type: 133 | if self.content_type: 134 | headers["Content-Type"] = content_type = self.content_type 135 | if content_type: 136 | if "application/x-www-form-urlencoded" in content_type: 137 | body = urlencode(body) 138 | elif "application/json" in content_type: 139 | body = json.dumps(body) 140 | elif "multipart/form-data" in content_type: 141 | body, headers["Content-Type"] = encode_multipart(body) 142 | else: 143 | raise NotImplementedError("Content-Type not supported") 144 | return body, headers 145 | 146 | async def request( 147 | self, path, method="GET", body=b"", headers=None, content_type=None 148 | ): 149 | headers = headers or {} 150 | for key, value in self.default_headers.items(): 151 | headers.setdefault(key, value) 152 | if content_type: 153 | headers["Content-Type"] = content_type 154 | body, headers = self.encode_body(body, headers) 155 | if isinstance(body, str): 156 | body = body.encode() 157 | if body and "Content-Length" not in headers: 158 | headers["Content-Length"] = len(body) 159 | self.protocol = self.app.factory() 160 | self.protocol.connection_made(Transport()) 161 | headers = "\r\n".join(f"{k}: {v}" for k, v in headers.items()) 162 | data = b"%b %b HTTP/1.1\r\n%b\r\n\r\n%b" % ( 163 | method.encode(), 164 | encode_path(path), 165 | headers.encode(), 166 | body or b"", 167 | ) 168 | 169 | self.protocol.data_received(data) 170 | if self.protocol.task: 171 | await self.protocol.task 172 | return self.protocol.response 173 | 174 | async def get(self, path, **kwargs): 175 | return await self.request(path, method="GET", **kwargs) 176 | 177 | async def head(self, path, **kwargs): 178 | return await self.request(path, method="HEAD", **kwargs) 179 | 180 | async def post(self, path, data=None, **kwargs): 181 | kwargs.setdefault("body", data or {}) 182 | self.handle_files(kwargs) 183 | return await self.request(path, method="POST", **kwargs) 184 | 185 | async def put(self, path, data=None, **kwargs): 186 | kwargs.setdefault("body", data or {}) 187 | self.handle_files(kwargs) 188 | return await self.request(path, method="PUT", **kwargs) 189 | 190 | async def patch(self, path, data=None, **kwargs): 191 | kwargs.setdefault("body", data or {}) 192 | self.handle_files(kwargs) 193 | return await self.request(path, method="PATCH", **kwargs) 194 | 195 | async def delete(self, path, **kwargs): 196 | return await self.request(path, method="DELETE", **kwargs) 197 | 198 | async def options(self, path, **kwargs): 199 | return await self.request(path, method="OPTIONS", **kwargs) 200 | 201 | async def connect(self, path, **kwargs): 202 | return await self.request(path, method="CONNECT", **kwargs) 203 | 204 | 205 | @pytest.fixture 206 | def client(app, event_loop): 207 | app.loop = event_loop 208 | app.loop.run_until_complete(app.startup()) 209 | yield Client(app) 210 | app.loop.run_until_complete(app.shutdown()) 211 | 212 | 213 | def read_chunked_body(response): 214 | def chunk_size(): 215 | size_str = response.read(2) 216 | while size_str[-2:] != b"\r\n": 217 | size_str += response.read(1) 218 | return int(size_str[:-2], 16) 219 | 220 | def chunk_data(chunk_size): 221 | data = response.read(chunk_size) 222 | response.read(2) 223 | return data 224 | 225 | while True: 226 | size = chunk_size() 227 | if size == 0: 228 | break 229 | else: 230 | yield chunk_data(size) 231 | 232 | 233 | class LiveResponse: 234 | def __init__(self, status: int, reason: str): 235 | self.status = HTTPStatus(status) 236 | self.reason = reason 237 | self.body = b"" 238 | self.chunks = None 239 | 240 | def write(self, data): 241 | self.body += data 242 | 243 | def write_chunk(self, data): 244 | self.body += data 245 | if self.chunks is None: 246 | self.chunks = [] 247 | self.chunks.append(data) 248 | 249 | @classmethod 250 | def from_query(cls, result): 251 | response = cls(result.status, result.reason) 252 | if result.chunked: 253 | result.chunked = False 254 | for data in read_chunked_body(result): 255 | response.write_chunk(data) 256 | else: 257 | response.write(result.read()) 258 | return response 259 | 260 | 261 | class LiveClient: 262 | def __init__(self, app): 263 | self.app = app 264 | self.url = None 265 | self.wsl = None 266 | 267 | def start(self): 268 | self.app.loop.run_until_complete(self.app.startup()) 269 | self.server = self.app.loop.run_until_complete( 270 | self.app.loop.create_server(self.app.factory, "127.0.0.1", 0) 271 | ) 272 | self.port = self.server.sockets[0].getsockname()[1] 273 | self.url = f"http://127.0.0.1:{self.port}" 274 | self.wsl = f"ws://127.0.0.1:{self.port}" 275 | 276 | def stop(self): 277 | self.server.close() 278 | self.port = self.url = self.wsl = None 279 | self.app.loop.run_until_complete(self.server.wait_closed()) 280 | self.app.loop.run_until_complete(self.app.shutdown()) 281 | 282 | def execute_query(self, method, uri, headers, body=None): 283 | self.conn.request(method, uri, headers=headers, body=body) 284 | result = self.conn.getresponse() 285 | return LiveResponse.from_query(result) 286 | 287 | async def query(self, method, uri, headers: dict = None, body=None): 288 | if headers is None: 289 | headers = {} 290 | 291 | self.conn = http.client.HTTPConnection("127.0.0.1", self.port) 292 | requester = partial(self.execute_query, method.upper(), uri, headers, body) 293 | response = await self.app.loop.run_in_executor(None, requester) 294 | self.conn.close() 295 | return response 296 | 297 | 298 | @pytest.fixture 299 | def liveclient(app, event_loop): 300 | app.loop = event_loop 301 | client = LiveClient(app) 302 | client.start() 303 | yield client 304 | client.stop() 305 | -------------------------------------------------------------------------------- /roll/websocket.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import websockets 4 | from websockets import ConnectionClosed # exposed for convenience 5 | 6 | 7 | class WSProtocol(websockets.WebSocketCommonProtocol): 8 | NEEDS_UPGRADE = True 9 | ALLOWED_METHODS = {"GET"} 10 | TIMEOUT = 5 11 | MAX_SIZE = 2**20 # 1 megabytes 12 | MAX_QUEUE = 64 13 | READ_LIMIT = 2**16 14 | WRITE_LIMIT = 2**16 15 | 16 | is_client = False 17 | side = "server" # Useful for websockets logging. 18 | 19 | def __init__(self, request): 20 | self.request = request 21 | super().__init__( 22 | timeout=self.TIMEOUT, 23 | max_size=self.MAX_SIZE, 24 | max_queue=self.MAX_QUEUE, 25 | read_limit=self.READ_LIMIT, 26 | write_limit=self.WRITE_LIMIT, 27 | ) 28 | 29 | def handshake(self, response): 30 | """Websocket handshake, handled by `websockets`""" 31 | try: 32 | headers = websockets.http.Headers(**self.request.headers) 33 | key = websockets.handshake.check_request(headers) 34 | websockets.handshake.build_response(response.headers, key) 35 | except websockets.InvalidHandshake: 36 | raise RuntimeError("Invalid websocket request") 37 | 38 | subprotocol = None 39 | ws_protocol = ",".join(headers.get_all("Sec-Websocket-Protocol")) 40 | subprotocols = self.request.route.payload.get("subprotocols") 41 | if subprotocols and ws_protocol: 42 | # select a subprotocol 43 | client_subprotocols = tuple((p.strip() for p in ws_protocol.split(","))) 44 | for p in client_subprotocols: 45 | if p in subprotocols: 46 | subprotocol = p 47 | response.headers["Sec-Websocket-Protocol"] = subprotocol 48 | break 49 | 50 | # Return the subprotocol agreed upon, if any 51 | self.subprotocol = subprotocol 52 | 53 | async def run(self): 54 | # See https://tools.ietf.org/html/rfc6455#page-45 55 | try: 56 | await self.request.app.hook("websocket_connect", self.request, self) 57 | await self.request.route.payload["GET"](self.request, self) 58 | except ConnectionClosed: 59 | # The client closed the connection. 60 | # We cancel the future to be sure it's in order. 61 | await self.close(1002, "Connection closed untimely.") 62 | except asyncio.CancelledError: 63 | # The websocket task was cancelled 64 | # We need to warn the client. 65 | await self.close(1001, "Handler cancelled.") 66 | except Exception: 67 | # A more serious error happened. 68 | # The websocket handler was untimely terminated 69 | # by an unwarranted exception. Warn the client. 70 | await self.close(1011, "Handler died prematurely.") 71 | raise 72 | else: 73 | # The handler finished gracefully. 74 | # We can close the socket in peace. 75 | await self.close() 76 | finally: 77 | await self.request.app.hook("websocket_disconnect", self.request, self) 78 | -------------------------------------------------------------------------------- /roll/worker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import socket 4 | import sys 5 | 6 | try: 7 | import uvloop 8 | except ImportError: 9 | uvloop = None 10 | import warnings 11 | 12 | warnings.warn("You should install uvloop for better performance") 13 | 14 | from gunicorn.workers.base import Worker 15 | 16 | 17 | class Worker(Worker): 18 | def init_process(self): 19 | self.server = None 20 | asyncio.get_event_loop().close() 21 | if uvloop: 22 | uvloop.install() 23 | self.loop = asyncio.new_event_loop() 24 | asyncio.set_event_loop(self.loop) 25 | super().init_process() 26 | 27 | def run(self): 28 | self.wsgi.loop = self.loop 29 | self.loop.run_until_complete(self.wsgi.startup()) 30 | try: 31 | self.loop.run_until_complete(self._run()) 32 | finally: 33 | self.loop.close() 34 | sys.exit() 35 | 36 | async def close(self): 37 | if self.server: 38 | server = self.server 39 | self.server = None 40 | self.log.info("Stopping server: %s", self.pid) 41 | await self.wsgi.shutdown() 42 | server.close() 43 | await server.wait_closed() 44 | 45 | async def _run(self): 46 | sock = self.sockets[0] 47 | if hasattr(socket, "AF_UNIX") and sock.family == socket.AF_UNIX: 48 | self.server = await self.loop.create_unix_server( 49 | self.wsgi.factory, sock=sock.sock 50 | ) 51 | else: 52 | self.server = await self.loop.create_server( 53 | self.wsgi.factory, sock=sock.sock 54 | ) 55 | 56 | pid = os.getpid() 57 | try: 58 | while self.alive: 59 | self.notify() 60 | if pid == os.getpid() and self.ppid != os.getppid(): 61 | self.log.info("Parent changed, shutting down: %s", self) 62 | break 63 | await asyncio.sleep(1.0) 64 | 65 | except Exception as e: 66 | print(e) 67 | 68 | await self.close() 69 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | "Roll is a pico framework with performances and aesthetic in mind." 2 | 3 | import sys 4 | from codecs import open # To use a consistent encoding 5 | from os import path 6 | 7 | from setuptools import Extension, find_packages, setup 8 | 9 | here = path.abspath(path.dirname(__file__)) 10 | 11 | # Get the long description from the relevant file 12 | with open(path.join(here, "README.md"), encoding="utf-8") as f: 13 | long_description = f.read() 14 | 15 | 16 | def is_pkg(line): 17 | return line and not line.startswith(("--", "git", "#")) 18 | 19 | 20 | with open("requirements.txt", encoding="utf-8") as reqs: 21 | install_requires = [l for l in reqs.read().split("\n") if is_pkg(l)] 22 | 23 | try: 24 | from Cython.Distutils import build_ext 25 | 26 | CYTHON = True 27 | except ImportError: 28 | sys.stdout.write( 29 | "\nNOTE: Cython not installed. Roll will " 30 | "still roll fine, but may roll a bit slower.\n\n" 31 | ) 32 | CYTHON = False 33 | cmdclass = {} 34 | ext_modules = [] 35 | else: 36 | ext_modules = [ 37 | Extension("roll", ["roll/__init__.py"]), 38 | Extension("roll.extensions", ["roll/extensions.py"]), 39 | Extension("roll.worker", ["roll/worker.py"]), 40 | ] 41 | cmdclass = {"build_ext": build_ext} 42 | 43 | VERSION = (0, 13, 3) 44 | 45 | __author__ = "Pyrates" 46 | __contact__ = "yohanboniface@free.fr" 47 | __homepage__ = "https://github.com/pyrates/roll" 48 | __version__ = ".".join(map(str, VERSION)) 49 | 50 | setup( 51 | name="roll", 52 | version=__version__, 53 | description=__doc__, 54 | long_description=long_description, 55 | long_description_content_type="text/markdown", 56 | url=__homepage__, 57 | project_urls={ 58 | "Documentation": "https://roll.readthedocs.io/", 59 | "Changes": "https://roll.readthedocs.io/en/latest/changelog/", 60 | "Source Code": "https://github.com/pyrates/roll", 61 | "Issue Tracker": "https://github.com/pyrates/roll/issues", 62 | "Chat": "https://web.libera.chat/#pyrates", 63 | }, 64 | author=__author__, 65 | author_email=__contact__, 66 | license="WTFPL", 67 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 68 | classifiers=[ 69 | "Development Status :: 4 - Beta", 70 | "Intended Audience :: Developers", 71 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers", 72 | "Programming Language :: Python :: 3", 73 | "Programming Language :: Python :: 3.6", 74 | "Programming Language :: Python :: 3.7", 75 | "Programming Language :: Python :: 3.8", 76 | "Programming Language :: Python :: 3.9", 77 | "Programming Language :: Python :: 3.10", 78 | ], 79 | keywords="async asyncio http server", 80 | packages=find_packages(exclude=["tests", "examples"]), 81 | install_requires=install_requires, 82 | extras_require={"test": ["pytest"], "docs": "mkdocs"}, 83 | include_package_data=True, 84 | ext_modules=ext_modules, 85 | entry_points={ 86 | "pytest11": ["roll=roll.testing"], 87 | }, 88 | cmdclass=cmdclass, 89 | ) 90 | -------------------------------------------------------------------------------- /tests/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test 4 |

Test

5 | -------------------------------------------------------------------------------- /tests/static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: chocolate; 3 | } 4 | -------------------------------------------------------------------------------- /tests/static/sub/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Subtest 4 |

Subtest

5 | -------------------------------------------------------------------------------- /tests/test_class_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytestmark = pytest.mark.asyncio 4 | 5 | 6 | async def test_can_use_class_as_handler(client, app): 7 | @app.route("/test") 8 | class MyHandler: 9 | async def on_get(self, request, response): 10 | response.body = "called" 11 | 12 | async def on_post(self, request, response): 13 | response.body = "called with post" 14 | 15 | resp = await client.get("/test") 16 | assert resp.status == 200 17 | assert resp.body == b"called" 18 | 19 | resp = await client.post("/test") 20 | assert resp.status == 200 21 | assert resp.body == b"called with post" 22 | 23 | resp = await client.put("/test") 24 | assert resp.status == 405 25 | 26 | 27 | async def test_inherited_class_based_view(client, app): 28 | class View: 29 | CUSTOM = None 30 | 31 | async def on_get(self, request, response): 32 | response.body = self.CUSTOM 33 | 34 | @app.route("/tomatoes") 35 | class Tomato(View): 36 | CUSTOM = "tomato" 37 | 38 | @app.route("/cucumbers") 39 | class Cucumber(View): 40 | CUSTOM = "cucumber" 41 | 42 | @app.route("/gherkins") 43 | class Gherkin(Cucumber): 44 | CUSTOM = "gherkin" 45 | 46 | resp = await client.get("/tomatoes") 47 | assert resp.status == 200 48 | assert resp.body == b"tomato" 49 | 50 | resp = await client.get("/cucumbers") 51 | assert resp.status == 200 52 | assert resp.body == b"cucumber" 53 | 54 | resp = await client.get("/gherkins") 55 | assert resp.status == 200 56 | assert resp.body == b"gherkin" 57 | 58 | 59 | async def test_can_use_extra_payload_with_class(client, app): 60 | @app.route("/test", custom="tomato") 61 | class MyHandler: 62 | async def on_get(self, request, response): 63 | response.body = request.route.payload["custom"] 64 | 65 | resp = await client.get("/test") 66 | assert resp.status == 200 67 | assert resp.body == b"tomato" 68 | 69 | 70 | async def test_can_use_placeholders_in_route(client, app): 71 | @app.route("/test/{mystery}") 72 | class MyHandler: 73 | async def on_get(self, request, response, mystery): 74 | response.body = mystery 75 | 76 | resp = await client.get("/test/salad") 77 | assert resp.status == 200 78 | assert resp.body == b"salad" 79 | 80 | 81 | async def test_cannot_define_methods_on_class_view(app): 82 | 83 | with pytest.raises(AttributeError): 84 | 85 | @app.route("/test", methods=["POST"]) 86 | class MyHandler: 87 | async def on_get(self, request, response): 88 | response.body = "called" 89 | 90 | 91 | async def test_cannot_define_empty_view(app): 92 | 93 | with pytest.raises(ValueError): 94 | 95 | @app.route("/test") 96 | class MyHandler: 97 | async def bad_get_name(self, request, response): 98 | response.body = "called" 99 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from http import HTTPStatus 3 | 4 | from roll import HttpError 5 | 6 | pytestmark = pytest.mark.asyncio 7 | 8 | 9 | async def test_simple_error(client, app): 10 | 11 | @app.route('/test') 12 | async def get(req, resp): 13 | raise HttpError(500, 'Oops.') 14 | 15 | resp = await client.get('/test') 16 | assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR 17 | assert resp.body == b'Oops.' 18 | 19 | 20 | async def test_httpstatus_error(client, app): 21 | 22 | @app.route('/test') 23 | async def get(req, resp): 24 | raise HttpError(HTTPStatus.BAD_REQUEST, 'Really bad.') 25 | 26 | resp = await client.get('/test') 27 | assert resp.status == HTTPStatus.BAD_REQUEST 28 | assert resp.body == b'Really bad.' 29 | 30 | 31 | async def test_error_only_with_status(client, app): 32 | 33 | @app.route('/test') 34 | async def get(req, resp): 35 | raise HttpError(500) 36 | 37 | resp = await client.get('/test') 38 | assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR 39 | assert resp.body == b'Internal Server Error' 40 | 41 | 42 | async def test_error_only_with_httpstatus(client, app): 43 | 44 | @app.route('/test') 45 | async def get(req, resp): 46 | raise HttpError(HTTPStatus.INTERNAL_SERVER_ERROR) 47 | 48 | resp = await client.get('/test') 49 | assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR 50 | assert resp.body == b'Internal Server Error' 51 | 52 | 53 | async def test_error_subclasses_with_super(client, app): 54 | 55 | class CustomHttpError(HttpError): 56 | def __init__(self, code): 57 | super().__init__(code) 58 | self.message = '

Oops.

' 59 | 60 | @app.route('/test') 61 | async def get(req, resp): 62 | raise CustomHttpError(HTTPStatus.INTERNAL_SERVER_ERROR) 63 | 64 | resp = await client.get('/test') 65 | assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR 66 | assert resp.body == b'

Oops.

' 67 | 68 | 69 | async def test_error_subclasses_without_super(client, app): 70 | 71 | class CustomHttpError(HttpError): 72 | def __init__(self, code): 73 | self.status = HTTPStatus(code) 74 | self.message = '

Oops.

' 75 | 76 | @app.route('/test') 77 | async def get(req, resp): 78 | raise CustomHttpError(HTTPStatus.INTERNAL_SERVER_ERROR) 79 | 80 | resp = await client.get('/test') 81 | assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR 82 | assert resp.body == b'

Oops.

' 83 | -------------------------------------------------------------------------------- /tests/test_extensions.py: -------------------------------------------------------------------------------- 1 | import json 2 | from http import HTTPStatus 3 | from pathlib import Path 4 | 5 | import pytest 6 | from roll import extensions 7 | 8 | pytestmark = pytest.mark.asyncio 9 | 10 | 11 | async def test_cors(client, app): 12 | 13 | extensions.cors(app) 14 | 15 | @app.route('/test') 16 | async def get(req, resp): 17 | resp.body = 'test response' 18 | 19 | resp = await client.get('/test') 20 | assert resp.status == HTTPStatus.OK 21 | assert resp.body == b'test response' 22 | assert resp.headers['Access-Control-Allow-Origin'] == '*' 23 | 24 | 25 | async def test_custom_cors_origin(client, app): 26 | 27 | extensions.cors(app, origin='mydomain.org') 28 | 29 | @app.route('/test') 30 | async def get(req, resp): 31 | resp.body = 'test response' 32 | 33 | resp = await client.get('/test') 34 | assert resp.headers['Access-Control-Allow-Origin'] == 'mydomain.org' 35 | assert 'Access-Control-Allow-Methods' not in resp.headers 36 | assert 'Access-Control-Allow-Credentials' not in resp.headers 37 | 38 | 39 | async def test_custom_cors_methods(client, app): 40 | 41 | extensions.cors(app, methods=['PATCH', 'PUT']) 42 | 43 | @app.route('/test') 44 | async def get(req, resp): 45 | resp.body = 'test response' 46 | 47 | resp = await client.get('/test') 48 | assert resp.headers['Access-Control-Allow-Methods'] == 'PATCH,PUT' 49 | 50 | 51 | async def test_wildcard_cors_methods(client, app): 52 | 53 | extensions.cors(app, methods='*') 54 | 55 | resp = await client.get('/test') 56 | assert (resp.headers['Access-Control-Allow-Methods'] == 57 | ','.join(extensions.HTTP_METHODS)) 58 | 59 | 60 | async def test_custom_cors_headers(client, app): 61 | 62 | extensions.cors(app, headers=['X-Powered-By', 'X-Requested-With']) 63 | 64 | @app.route('/test') 65 | async def get(req, resp): 66 | resp.body = 'test response' 67 | 68 | resp = await client.get('/test') 69 | assert (resp.headers['Access-Control-Allow-Headers'] == 70 | 'X-Powered-By,X-Requested-With') 71 | 72 | 73 | async def test_cors_credentials(client, app): 74 | 75 | extensions.cors(app, credentials=True) 76 | 77 | @app.route('/test') 78 | async def get(req, resp): 79 | resp.body = 'test response' 80 | 81 | resp = await client.get('/test') 82 | assert resp.headers['Access-Control-Allow-Credentials'] == "true" 83 | 84 | 85 | async def test_logger(client, app, capsys): 86 | 87 | # startup has yet been called, but logger extensions was not registered 88 | # yet, so let's simulate a new startup. 89 | app.hooks['startup'] = [] 90 | extensions.logger(app) 91 | await app.startup() 92 | 93 | @app.route('/test') 94 | async def get(req, resp): 95 | return 'test response' 96 | 97 | await client.get('/test') 98 | _, err = capsys.readouterr() 99 | assert err == 'GET /test\n' 100 | 101 | 102 | async def test_json_with_default_code(client, app): 103 | 104 | @app.route('/test') 105 | async def get(req, resp): 106 | resp.json = {'key': 'value'} 107 | 108 | resp = await client.get('/test') 109 | assert resp.headers['Content-Type'] == 'application/json; charset=utf-8' 110 | assert json.loads(resp.body.decode()) == {'key': 'value'} 111 | assert resp.status == HTTPStatus.OK 112 | 113 | 114 | async def test_json_with_custom_code(client, app): 115 | 116 | @app.route('/test') 117 | async def get(req, resp): 118 | resp.json = {'key': 'value'} 119 | resp.status = 400 120 | 121 | resp = await client.get('/test') 122 | assert resp.headers['Content-Type'] == 'application/json; charset=utf-8' 123 | assert json.loads(resp.body.decode()) == {'key': 'value'} 124 | assert resp.status == HTTPStatus.BAD_REQUEST 125 | 126 | 127 | async def test_traceback(client, app, capsys): 128 | 129 | extensions.traceback(app) 130 | 131 | @app.route('/test') 132 | async def get(req, resp): 133 | raise ValueError('Unhandled exception') 134 | 135 | resp = await client.get('/test') 136 | _, err = capsys.readouterr() 137 | assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR 138 | assert 'Unhandled exception' in err 139 | 140 | 141 | async def test_options(client, app): 142 | 143 | extensions.options(app) 144 | 145 | @app.route('/test') 146 | async def get(req, resp): 147 | raise # Should not be called. 148 | 149 | resp = await client.options('/test') 150 | assert resp.status == HTTPStatus.OK 151 | 152 | 153 | async def test_static(client, app): 154 | 155 | # startup has yet been called, but static extensions was not registered 156 | # yet, so let's simulate a new startup. 157 | app.hooks['startup'] = [] 158 | extensions.static(app, root=Path(__file__).parent / 'static') 159 | url_for = extensions.named_url(app) 160 | await app.startup() 161 | 162 | resp = await client.get('/static/index.html') 163 | assert resp.status == HTTPStatus.OK 164 | assert b'Test' in resp.body 165 | assert resp.headers['Content-Type'] == 'text/html' 166 | 167 | resp = await client.get('/static/sub/index.html') 168 | assert resp.status == HTTPStatus.OK 169 | assert b'Subtest' in resp.body 170 | assert resp.headers['Content-Type'] == 'text/html' 171 | 172 | resp = await client.get('/static/style.css') 173 | assert resp.status == HTTPStatus.OK 174 | assert b'chocolate' in resp.body 175 | assert resp.headers['Content-Type'] == 'text/css' 176 | 177 | assert url_for("static", path="path/myfile.png") == "/static/path/myfile.png" 178 | 179 | 180 | async def test_static_with_default_index(client, app): 181 | 182 | app.hooks['startup'] = [] 183 | extensions.static(app, root=Path(__file__).parent / 'static', 184 | default_index='index.html') 185 | await app.startup() 186 | 187 | resp = await client.get('/static/index.html') 188 | assert resp.status == HTTPStatus.OK 189 | assert b'Test' in resp.body 190 | assert resp.headers['Content-Type'] == 'text/html' 191 | 192 | resp = await client.get('/static/') 193 | assert resp.status == HTTPStatus.OK 194 | assert b'Test' in resp.body 195 | assert resp.headers['Content-Type'] == 'text/html' 196 | 197 | resp = await client.get('/static/sub/index.html') 198 | assert resp.status == HTTPStatus.OK 199 | assert b'Subtest' in resp.body 200 | assert resp.headers['Content-Type'] == 'text/html' 201 | 202 | resp = await client.get('/static/sub/') 203 | assert resp.status == HTTPStatus.OK 204 | assert b'Subtest' in resp.body 205 | assert resp.headers['Content-Type'] == 'text/html' 206 | 207 | 208 | async def test_static_raises_if_path_is_outside_root(client, app): 209 | 210 | app.hooks['startup'] = [] 211 | extensions.static(app, root=Path(__file__).parent / 'static') 212 | await app.startup() 213 | 214 | resp = await client.get('/static/../../README.md') 215 | assert resp.status == HTTPStatus.BAD_REQUEST 216 | 217 | 218 | async def test_can_change_static_prefix(client, app): 219 | 220 | app.hooks['startup'] = [] 221 | extensions.static(app, root=Path(__file__).parent / 'static', 222 | prefix='/foo') 223 | await app.startup() 224 | 225 | resp = await client.get('/foo/index.html') 226 | assert resp.status == HTTPStatus.OK 227 | assert b'Test' in resp.body 228 | 229 | 230 | async def test_get_accept_content_negociation(client, app): 231 | 232 | extensions.content_negociation(app) 233 | 234 | @app.route('/test', accepts=['text/html']) 235 | async def get(req, resp): 236 | resp.headers['Content-Type'] = 'text/html' 237 | resp.body = 'accepted' 238 | 239 | resp = await client.get('/test', headers={'Accept': 'text/html'}) 240 | assert resp.status == HTTPStatus.OK 241 | assert resp.body == b'accepted' 242 | assert resp.headers['Content-Type'] == 'text/html' 243 | 244 | 245 | async def test_get_accept_content_negociation_if_many(client, app): 246 | 247 | extensions.content_negociation(app) 248 | 249 | @app.route('/test', accepts=['text/html', 'application/json']) 250 | async def get(req, resp): 251 | if req.headers['ACCEPT'] == 'text/html': 252 | resp.headers['Content-Type'] = 'text/html' 253 | resp.body = '

accepted

' 254 | elif req.headers['ACCEPT'] == 'application/json': 255 | resp.json = {'status': 'accepted'} 256 | 257 | resp = await client.get('/test', headers={'Accept': 'text/html'}) 258 | assert resp.status == HTTPStatus.OK 259 | assert resp.body == b'

accepted

' 260 | assert resp.headers['Content-Type'] == 'text/html' 261 | resp = await client.get('/test', headers={'Accept': 'application/json'}) 262 | assert resp.status == HTTPStatus.OK 263 | assert json.loads(resp.body.decode()) == {'status': 'accepted'} 264 | assert resp.headers['Content-Type'] == 'application/json; charset=utf-8' 265 | 266 | 267 | async def test_get_reject_content_negociation(client, app): 268 | 269 | extensions.content_negociation(app) 270 | 271 | @app.route('/test', accepts=['text/html']) 272 | async def get(req, resp): 273 | resp.body = 'rejected' 274 | 275 | resp = await client.get('/test', headers={'Accept': 'text/css'}) 276 | assert resp.status == HTTPStatus.NOT_ACCEPTABLE 277 | 278 | 279 | async def test_get_reject_content_negociation_if_no_accept_header(client, app): 280 | 281 | extensions.content_negociation(app) 282 | 283 | @app.route('/test', accepts=['*/*']) 284 | async def get(req, resp): 285 | resp.body = 'rejected' 286 | 287 | resp = await client.get('/test') 288 | assert resp.status == HTTPStatus.NOT_ACCEPTABLE 289 | 290 | 291 | async def test_get_accept_star_content_negociation(client, app): 292 | 293 | extensions.content_negociation(app) 294 | 295 | @app.route('/test', accepts=['text/css']) 296 | async def get(req, resp): 297 | resp.body = 'accepted' 298 | 299 | resp = await client.get('/test', headers={'Accept': 'text/*'}) 300 | assert resp.status == HTTPStatus.OK 301 | 302 | 303 | async def test_post_accept_content_negociation(client, app): 304 | 305 | extensions.content_negociation(app) 306 | 307 | @app.route('/test', methods=['POST'], accepts=['application/json']) 308 | async def get(req, resp): 309 | resp.json = {'status': 'accepted'} 310 | 311 | client.content_type = 'application/x-www-form-urlencoded' 312 | resp = await client.post('/test', body={'key': 'value'}, 313 | headers={'Accept': 'application/json'}) 314 | assert resp.status == HTTPStatus.OK 315 | assert resp.headers['Content-Type'] == 'application/json; charset=utf-8' 316 | assert json.loads(resp.body.decode()) == {'status': 'accepted'} 317 | 318 | 319 | async def test_post_reject_content_negociation(client, app): 320 | 321 | extensions.content_negociation(app) 322 | 323 | @app.route('/test', methods=['POST'], accepts=['text/html']) 324 | async def get(req, resp): 325 | resp.json = {'status': 'accepted'} 326 | 327 | client.content_type = 'application/x-www-form-urlencoded' 328 | resp = await client.post('/test', body={'key': 'value'}, 329 | headers={'Accept': 'application/json'}) 330 | assert resp.status == HTTPStatus.NOT_ACCEPTABLE 331 | 332 | 333 | async def test_can_call_static_twice(client, app): 334 | 335 | # startup has yet been called, but static extensions was not registered 336 | # yet, so let's simulate a new startup. 337 | app.hooks["startup"] = [] 338 | extensions.static( 339 | app, root=Path(__file__).parent / "static", prefix="/static/", name="statics" 340 | ) 341 | extensions.static( 342 | app, root=Path(__file__).parent / "medias", prefix="/medias/", name="medias" 343 | ) 344 | url_for = extensions.named_url(app) 345 | await app.startup() 346 | assert url_for("statics", path="myfile.png") == "/static/myfile.png" 347 | assert url_for("medias", path="myfile.mp3") == "/medias/myfile.mp3" 348 | -------------------------------------------------------------------------------- /tests/test_hooks.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | import json 3 | 4 | import pytest 5 | 6 | from roll import HttpError 7 | 8 | pytestmark = pytest.mark.asyncio 9 | 10 | 11 | async def test_request_hook_can_alter_response(client, app): 12 | 13 | @app.listen('request') 14 | async def listener(request, response): 15 | response.status = 400 16 | response.body = b'another response' 17 | return True # Shortcut the response process. 18 | 19 | @app.route('/test') 20 | async def get(req, resp): 21 | resp.body = 'test response' 22 | 23 | resp = await client.get('/test') 24 | assert resp.status == HTTPStatus.BAD_REQUEST 25 | assert resp.body == b'another response' 26 | 27 | 28 | async def test_response_hook_can_alter_response(client, app): 29 | 30 | @app.listen('response') 31 | async def listener(request, response): 32 | assert response.body == 'test response' 33 | response.body = 'another response' 34 | response.status = 400 35 | 36 | @app.route('/test') 37 | async def get(req, resp): 38 | resp.body = 'test response' 39 | 40 | resp = await client.get('/test') 41 | assert resp.status == HTTPStatus.BAD_REQUEST 42 | assert resp.body == b'another response' 43 | 44 | 45 | async def test_error_with_json_format(client, app): 46 | 47 | @app.listen('error') 48 | async def listener(request, response, error): 49 | assert error.message == 'JSON error' 50 | response.json = {'status': error.status, 'message': error.message} 51 | 52 | @app.route('/test') 53 | async def get(req, resp): 54 | raise HttpError(HTTPStatus.INTERNAL_SERVER_ERROR, message='JSON error') 55 | 56 | resp = await client.get('/test') 57 | assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR 58 | error = json.loads(resp.body.decode()) 59 | assert error == {"status": 500, "message": "JSON error"} 60 | 61 | 62 | async def test_third_parties_can_call_hook_their_way(client, app): 63 | 64 | @app.listen('custom') 65 | async def listener(myarg): 66 | return myarg 67 | 68 | assert await app.hook('custom', myarg='kwarg') == 'kwarg' 69 | assert await app.hook('custom', 'arg') == 'arg' 70 | 71 | 72 | async def test_headers_hook_is_called_even_if_path_is_not_found(client, app): 73 | 74 | @app.listen('headers') 75 | async def listener(request, response): 76 | if not request.route.payload: 77 | response.status = 400 78 | response.body = b'Really this is a bad request' 79 | return True # Shortcuts the response process. 80 | 81 | resp = await client.get('/not-found') 82 | assert resp.status == HTTPStatus.BAD_REQUEST 83 | assert resp.body == b'Really this is a bad request' 84 | 85 | 86 | async def test_headers_hook_cannot_consume_request_body(client, app): 87 | 88 | @app.listen('headers') 89 | async def listener(request, response): 90 | if not request.route.payload: 91 | try: 92 | request.body 93 | except HttpError: 94 | response.status = 200 95 | response.body = b'raised as expected' 96 | return True # Shortcuts the response process. 97 | 98 | resp = await client.get('/not-found') 99 | assert resp.status == HTTPStatus.OK 100 | assert resp.body == b'raised as expected' 101 | 102 | 103 | async def test_headers_hook_can_consume_request_body_explicitly(client, app): 104 | 105 | @app.listen('headers') 106 | async def listener(request, response): 107 | response.status = 200 108 | response.body = await request.read() 109 | return True # Shortcuts the response process. 110 | 111 | resp = await client.post('/test', "blah") 112 | assert resp.status == HTTPStatus.OK 113 | assert resp.body == b'blah' 114 | 115 | 116 | async def test_request_hook_can_consume_request_body(client, app): 117 | 118 | @app.route('/test', methods=["POST"]) 119 | async def get(req, resp): 120 | pass 121 | 122 | @app.listen('request') 123 | async def listener(request, response): 124 | response.status = 200 125 | response.body = request.body 126 | return True # Shortcuts the response process. 127 | 128 | resp = await client.post('/test', "blah") 129 | assert resp.status == HTTPStatus.OK 130 | assert resp.body == b'blah' 131 | 132 | 133 | async def test_can_retrieve_original_error_on_error_hook(client, app): 134 | original_error = None 135 | 136 | @app.listen('error') 137 | async def listener(request, response, error): 138 | nonlocal original_error 139 | original_error = error.__context__ 140 | 141 | @app.route('/raise') 142 | async def handler(request, response): 143 | raise ValueError("Custom Error Message") 144 | 145 | resp = await client.get('/raise') 146 | assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR 147 | assert resp.body == b'Custom Error Message' 148 | assert isinstance(original_error, ValueError) 149 | -------------------------------------------------------------------------------- /tests/test_named_url.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from roll.extensions import named_url 4 | 5 | pytestmark = pytest.mark.asyncio 6 | 7 | 8 | @pytest.fixture(autouse=True) 9 | def activate_named_url(app, client): 10 | app.url_for = named_url(app) 11 | 12 | 13 | async def test_named_url(app): 14 | @app.route("/test", name="myroute") 15 | async def get(req, resp): 16 | pass 17 | 18 | assert app.url_for("myroute") == "/test" 19 | 20 | 21 | async def test_default_url_name(app): 22 | @app.route("/test") 23 | async def myroute(req, resp): 24 | pass 25 | 26 | assert app.url_for("myroute") == "/test" 27 | 28 | 29 | async def test_url_with_simple_params(app): 30 | @app.route("/test/{param}") 31 | async def myroute(req, resp): 32 | pass 33 | 34 | assert app.url_for("myroute", param="foo") == "/test/foo" 35 | 36 | 37 | async def test_url_with_typed_param(app): 38 | @app.route("/test/{param:int}") 39 | async def myroute(req, resp): 40 | pass 41 | 42 | assert app.url_for("myroute", param=22) == "/test/22" 43 | 44 | 45 | async def test_url_with_regex_param(app): 46 | @app.route("/test/{param:[xyz]+}") 47 | async def myroute(req, resp): 48 | pass 49 | 50 | assert app.url_for("myroute", param=22) == "/test/22" 51 | 52 | 53 | async def test_missing_name(app): 54 | with pytest.raises(ValueError): 55 | app.url_for("missing") 56 | 57 | 58 | async def test_missing_param(app): 59 | @app.route("/test/{param}") 60 | async def myroute(req, resp): 61 | pass 62 | 63 | with pytest.raises(ValueError): 64 | assert app.url_for("myroute", badparam=22) 65 | 66 | 67 | async def test_with_class_based_view(app): 68 | @app.route("/test") 69 | class MyRoute: 70 | async def on_get(self, request, response): 71 | pass 72 | 73 | assert app.url_for("myroute") == "/test" 74 | 75 | 76 | async def test_duplicate_name(app): 77 | @app.route("/test") 78 | async def myroute(req, resp): 79 | pass 80 | 81 | with pytest.raises(ValueError): 82 | 83 | @app.route("/something", name="myroute") 84 | async def other(req, resp): 85 | pass 86 | 87 | 88 | async def test_can_decorate_twice_same_handler(app): 89 | @app.route("/test") 90 | @app.route("/alias-url", name="legacy") 91 | async def myroute(req, resp): 92 | pass 93 | 94 | assert app.url_for("myroute") == "/test" 95 | -------------------------------------------------------------------------------- /tests/test_request.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from io import BytesIO 3 | 4 | import pytest 5 | from roll import HttpError, Request 6 | from roll.testing import Transport 7 | 8 | pytestmark = pytest.mark.asyncio 9 | 10 | 11 | @pytest.fixture 12 | def protocol(app, event_loop): 13 | app.loop = event_loop 14 | protocol = app.HttpProtocol(app) 15 | protocol.connection_made(Transport()) 16 | return protocol 17 | 18 | 19 | async def test_request_parse_simple_get_response(protocol): 20 | protocol.data_received( 21 | b'GET /feeds HTTP/1.1\r\n' 22 | b'Host: localhost:1707\r\n' 23 | b'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:54.0) ' 24 | b'Gecko/20100101 Firefox/54.0\r\n' 25 | b'Accept: */*\r\n' 26 | b'Accept-Language: en-US,en;q=0.5\r\n' 27 | b'Accept-Encoding: gzip, deflate\r\n' 28 | b'Origin: http://localhost:7777\r\n' 29 | b'Referer: http://localhost:7777/\r\n' 30 | b'DNT: 1\r\n' 31 | b'Connection: keep-alive\r\n' 32 | b'\r\n') 33 | assert protocol.request.method == 'GET' 34 | assert protocol.request.path == '/feeds' 35 | assert protocol.request.headers['ACCEPT'] == '*/*' 36 | await protocol.task 37 | assert protocol.response.status == HTTPStatus.NOT_FOUND 38 | 39 | 40 | async def test_request_headers_are_uppercased(protocol): 41 | protocol.data_received( 42 | b'GET /feeds HTTP/1.1\r\n' 43 | b'Host: localhost:1707\r\n' 44 | b'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:54.0) ' 45 | b'Gecko/20100101 Firefox/54.0\r\n' 46 | b'Accept: */*\r\n' 47 | b'Accept-Language: en-US,en;q=0.5\r\n' 48 | b'Accept-Encoding: gzip, deflate\r\n' 49 | b'Origin: http://localhost:7777\r\n' 50 | b'Referer: http://localhost:7777/\r\n' 51 | b'DNT: 1\r\n' 52 | b'Connection: keep-alive\r\n' 53 | b'\r\n') 54 | assert protocol.request.headers['ACCEPT-LANGUAGE'] == 'en-US,en;q=0.5' 55 | assert protocol.request.headers['ACCEPT'] == '*/*' 56 | assert protocol.request.headers.get('HOST') == 'localhost:1707' 57 | assert 'DNT' in protocol.request.headers 58 | assert protocol.request.headers.get('accept') is None 59 | 60 | 61 | async def test_request_referrer(protocol): 62 | protocol.data_received( 63 | b'GET /feeds HTTP/1.1\r\n' 64 | b'Host: localhost:1707\r\n' 65 | b'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:54.0) ' 66 | b'Gecko/20100101 Firefox/54.0\r\n' 67 | b'Accept: */*\r\n' 68 | b'Accept-Language: en-US,en;q=0.5\r\n' 69 | b'Accept-Encoding: gzip, deflate\r\n' 70 | b'Origin: http://localhost:7777\r\n' 71 | b'Referer: http://localhost:7777/\r\n' 72 | b'DNT: 1\r\n' 73 | b'Connection: keep-alive\r\n' 74 | b'\r\n') 75 | assert protocol.request.referrer == 'http://localhost:7777/' 76 | assert protocol.request.referer == 'http://localhost:7777/' 77 | 78 | 79 | async def test_request_path_is_unquoted(protocol): 80 | protocol.data_received( 81 | b'GET /foo%2Bbar HTTP/1.1\r\n' 82 | b'Host: localhost:1707\r\n' 83 | b'User-Agent: HTTPie/0.9.8\r\n' 84 | b'Accept-Encoding: gzip, deflate\r\n' 85 | b'Accept: */*\r\n' 86 | b'Connection: keep-alive\r\n' 87 | b'\r\n') 88 | assert protocol.request.path == '/foo+bar' 89 | 90 | 91 | async def test_request_parse_query_string(protocol): 92 | protocol.data_received( 93 | b'GET /feeds?foo=bar&bar=baz HTTP/1.1\r\n' 94 | b'Host: localhost:1707\r\n' 95 | b'User-Agent: HTTPie/0.9.8\r\n' 96 | b'Accept-Encoding: gzip, deflate\r\n' 97 | b'Accept: */*\r\n' 98 | b'Connection: keep-alive\r\n' 99 | b'\r\n') 100 | assert protocol.request.path == '/feeds' 101 | assert protocol.request.query['foo'][0] == 'bar' 102 | assert protocol.request.query['bar'][0] == 'baz' 103 | 104 | 105 | async def test_request_parse_multivalue_query_string(protocol): 106 | protocol.data_received( 107 | b'GET /feeds?foo=bar&foo=baz HTTP/1.1\r\n' 108 | b'Host: localhost:1707\r\n' 109 | b'User-Agent: HTTPie/0.9.8\r\n' 110 | b'Accept-Encoding: gzip, deflate\r\n' 111 | b'Accept: */*\r\n' 112 | b'Connection: keep-alive\r\n' 113 | b'\r\n') 114 | assert protocol.request.path == '/feeds' 115 | assert protocol.request.query['foo'] == ['bar', 'baz'] 116 | 117 | 118 | async def test_request_parse_POST_body(protocol): 119 | protocol.data_received( 120 | b'POST /feed HTTP/1.1\r\n' 121 | b'Host: localhost:1707\r\n' 122 | b'User-Agent: HTTPie/0.9.8\r\n' 123 | b'Accept-Encoding: gzip, deflate\r\n' 124 | b'Accept: application/json, */*\r\n' 125 | b'Connection: keep-alive\r\n' 126 | b'Content-Type: application/json\r\n' 127 | b'Content-Length: 31\r\n' 128 | b'\r\n' 129 | b'{"link": "https://example.org"}') 130 | await protocol.task 131 | assert protocol.request.method == 'POST' 132 | await protocol.request.load_body() 133 | assert protocol.request.body == b'{"link": "https://example.org"}' 134 | 135 | 136 | async def test_request_parse_chunked_body(protocol): 137 | protocol.data_received( 138 | b'POST /feed HTTP/1.1\r\n' 139 | b'Host: localhost:1707\r\n' 140 | b'User-Agent: HTTPie/0.9.8\r\n' 141 | b'Accept-Encoding: gzip, deflate\r\n' 142 | b'Accept: application/json, */*\r\n' 143 | b'Connection: keep-alive\r\n' 144 | b'Content-Type: application/json\r\n' 145 | b'Content-Length: 31\r\n' 146 | b'\r\n' 147 | b'{"link": "https://') 148 | protocol.data_received(b'example.org"}') 149 | await protocol.task 150 | assert protocol.request.method == 'POST' 151 | await protocol.request.load_body() 152 | assert protocol.request.body == b'{"link": "https://example.org"}' 153 | 154 | 155 | async def test_request_content_type_shortcut(protocol): 156 | protocol.data_received( 157 | b'POST /feed HTTP/1.1\r\n' 158 | b'Host: localhost:1707\r\n' 159 | b'User-Agent: HTTPie/0.9.8\r\n' 160 | b'Accept-Encoding: gzip, deflate\r\n' 161 | b'Accept: application/json, */*\r\n' 162 | b'Connection: keep-alive\r\n' 163 | b'Content-Type: application/json\r\n' 164 | b'Content-Length: 31\r\n' 165 | b'\r\n' 166 | b'{"link": "https://example.org"}') 167 | assert protocol.request.content_type == 'application/json' 168 | 169 | 170 | async def test_request_host_shortcut(protocol): 171 | protocol.data_received( 172 | b'POST /feed HTTP/1.1\r\n' 173 | b'Host: localhost:1707\r\n' 174 | b'User-Agent: HTTPie/0.9.8\r\n' 175 | b'Accept-Encoding: gzip, deflate\r\n' 176 | b'Accept: application/json, */*\r\n' 177 | b'Connection: keep-alive\r\n' 178 | b'Content-Type: application/json\r\n' 179 | b'Content-Length: 31\r\n' 180 | b'\r\n' 181 | b'{"link": "https://example.org"}') 182 | assert protocol.request.host == 'localhost:1707' 183 | 184 | 185 | async def test_invalid_request(protocol): 186 | protocol.data_received(b'INVALID HTTP/1.22\r\n') 187 | assert protocol.response.status == HTTPStatus.BAD_REQUEST 188 | 189 | 190 | async def test_invalid_request_method(protocol): 191 | protocol.data_received( 192 | b'SPAM /path HTTP/1.1\r\nContent-Length: 8\r\n\r\nblahblah') 193 | assert protocol.response.status == HTTPStatus.BAD_REQUEST 194 | await protocol.write() # should not fail. 195 | assert protocol.request.method is None 196 | 197 | 198 | async def test_query_get_should_return_value(protocol): 199 | protocol.on_message_begin() 200 | protocol.on_url(b'/?key=value') 201 | assert protocol.request.query.get('key') == 'value' 202 | 203 | 204 | async def test_query_get_should_return_first_value_if_multiple(protocol): 205 | protocol.on_message_begin() 206 | protocol.on_url(b'/?key=value&key=value2') 207 | assert protocol.request.query.get('key') == 'value' 208 | 209 | 210 | async def test_query_get_should_raise_if_no_key_and_no_default(protocol): 211 | protocol.on_message_begin() 212 | protocol.on_url(b'/?key=value') 213 | with pytest.raises(HttpError): 214 | protocol.request.query.get('other') 215 | 216 | 217 | async def test_query_getlist_should_return_list_of_values(protocol): 218 | protocol.on_message_begin() 219 | protocol.on_url(b'/?key=value&key=value2') 220 | assert protocol.request.query.list('key') == ['value', 'value2'] 221 | 222 | 223 | async def test_query_get_should_return_default_if_key_is_missing(protocol): 224 | protocol.on_message_begin() 225 | protocol.on_url(b'/?key=value') 226 | assert protocol.request.query.get('other', None) is None 227 | assert protocol.request.query.get('other', 'default') == 'default' 228 | 229 | 230 | @pytest.mark.parametrize('input,expected', [ 231 | (b't', True), 232 | (b'true', True), 233 | (b'True', True), 234 | (b'1', True), 235 | (b'on', True), 236 | (b'f', False), 237 | (b'false', False), 238 | (b'False', False), 239 | (b'0', False), 240 | (b'off', False), 241 | (b'n', None), 242 | (b'none', None), 243 | (b'null', None), 244 | (b'NULL', None), 245 | ]) 246 | async def test_query_bool_should_cast_to_boolean(input, expected, protocol): 247 | protocol.on_message_begin() 248 | protocol.on_url(b'/?key=' + input) 249 | assert protocol.request.query.bool('key') == expected 250 | 251 | 252 | async def test_query_bool_should_return_default(protocol): 253 | protocol.on_message_begin() 254 | protocol.on_url(b'/?key=1') 255 | assert protocol.request.query.bool('other', default=False) is False 256 | 257 | 258 | async def test_query_bool_should_raise_if_not_castable(protocol): 259 | protocol.on_message_begin() 260 | protocol.on_url(b'/?key=one') 261 | with pytest.raises(HttpError): 262 | assert protocol.request.query.bool('key') 263 | 264 | 265 | async def test_query_bool_should_raise_if_not_key_and_no_default(protocol): 266 | protocol.on_message_begin() 267 | protocol.on_url(b'/?key=one') 268 | with pytest.raises(HttpError): 269 | assert protocol.request.query.bool('other') 270 | 271 | 272 | async def test_query_bool_should_return_default_if_key_not_present(protocol): 273 | protocol.on_message_begin() 274 | protocol.on_url(b'/?key=one') 275 | assert protocol.request.query.bool('other', default=False) is False 276 | 277 | 278 | async def test_query_int_should_cast_to_int(protocol): 279 | protocol.on_message_begin() 280 | protocol.on_url(b'/?key=22') 281 | assert protocol.request.query.int('key') == 22 282 | 283 | 284 | async def test_query_int_should_return_default(protocol): 285 | protocol.on_message_begin() 286 | protocol.on_url(b'/?key=1') 287 | assert protocol.request.query.int('other', default=22) == 22 288 | 289 | 290 | async def test_query_int_should_raise_if_not_castable(protocol): 291 | protocol.on_message_begin() 292 | protocol.on_url(b'/?key=one') 293 | with pytest.raises(HttpError): 294 | assert protocol.request.query.int('key') 295 | 296 | 297 | async def test_query_int_should_raise_if_not_key_and_no_default(protocol): 298 | protocol.on_message_begin() 299 | protocol.on_url(b'/?key=one') 300 | with pytest.raises(HttpError): 301 | assert protocol.request.query.int('other') 302 | 303 | 304 | async def test_query_int_should_return_default_if_key_not_present(protocol): 305 | protocol.on_message_begin() 306 | protocol.on_url(b'/?key=one') 307 | assert protocol.request.query.int('other', default=22) == 22 308 | 309 | 310 | async def test_query_float_should_cast_to_float(protocol): 311 | protocol.on_message_begin() 312 | protocol.on_url(b'/?key=2.234') 313 | assert protocol.request.query.float('key') == 2.234 314 | 315 | 316 | async def test_query_float_should_return_default(protocol): 317 | protocol.on_message_begin() 318 | protocol.on_url(b'/?key=1') 319 | assert protocol.request.query.float('other', default=2.234) == 2.234 320 | 321 | 322 | async def test_query_float_should_raise_if_not_castable(protocol): 323 | protocol.on_message_begin() 324 | protocol.on_url(b'/?key=one') 325 | with pytest.raises(HttpError): 326 | assert protocol.request.query.float('key') 327 | 328 | 329 | async def test_query_float_should_raise_if_not_key_and_no_default(protocol): 330 | protocol.on_message_begin() 331 | protocol.on_url(b'/?key=one') 332 | with pytest.raises(HttpError): 333 | assert protocol.request.query.float('other') 334 | 335 | 336 | async def test_query_float_should_return_default_if_key_not_present(protocol): 337 | protocol.on_message_begin() 338 | protocol.on_url(b'/?key=one') 339 | assert protocol.request.query.float('other', default=2.234) == 2.234 340 | 341 | 342 | async def test_request_parse_cookies(protocol): 343 | protocol.data_received( 344 | b'GET /feeds HTTP/1.1\r\n' 345 | b'Host: localhost:1707\r\n' 346 | b'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:54.0) ' 347 | b'Gecko/20100101 Firefox/54.0\r\n' 348 | b'Origin: http://localhost:7777\r\n' 349 | b'Cookie: key=value\r\n' 350 | b'\r\n') 351 | assert protocol.request.cookies['key'] == 'value' 352 | 353 | 354 | async def test_request_parse_multiple_cookies(protocol): 355 | protocol.data_received( 356 | b'GET /feeds HTTP/1.1\r\n' 357 | b'Host: localhost:1707\r\n' 358 | b'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:54.0) ' 359 | b'Gecko/20100101 Firefox/54.0\r\n' 360 | b'Origin: http://localhost:7777\r\n' 361 | b'Cookie: key=value; other=new_value\r\n' 362 | b'\r\n') 363 | assert protocol.request.cookies['key'] == 'value' 364 | assert protocol.request.cookies['other'] == 'new_value' 365 | 366 | 367 | async def test_request_cookies_get(protocol): 368 | protocol.data_received( 369 | b'GET /feeds HTTP/1.1\r\n' 370 | b'Host: localhost:1707\r\n' 371 | b'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:54.0) ' 372 | b'Gecko/20100101 Firefox/54.0\r\n' 373 | b'Origin: http://localhost:7777\r\n' 374 | b'Cookie: key=value\r\n' 375 | b'\r\n') 376 | cookie = protocol.request.cookies.get('key') 377 | cookie == 'value' 378 | 379 | 380 | async def test_request_cookies_get_unknown_key(protocol): 381 | protocol.data_received( 382 | b'GET /feeds HTTP/1.1\r\n' 383 | b'Host: localhost:1707\r\n' 384 | b'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:54.0) ' 385 | b'Gecko/20100101 Firefox/54.0\r\n' 386 | b'Origin: http://localhost:7777\r\n' 387 | b'Cookie: key=value\r\n' 388 | b'\r\n') 389 | cookie = protocol.request.cookies.get('foo') 390 | assert cookie is None 391 | 392 | 393 | async def test_request_get_unknown_cookie_key_raises_keyerror(protocol): 394 | protocol.data_received( 395 | b'GET /feeds HTTP/1.1\r\n' 396 | b'Host: localhost:1707\r\n' 397 | b'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:54.0) ' 398 | b'Gecko/20100101 Firefox/54.0\r\n' 399 | b'Origin: http://localhost:7777\r\n' 400 | b'Cookie: key=value\r\n' 401 | b'\r\n') 402 | with pytest.raises(KeyError): 403 | protocol.request.cookies['foo'] 404 | 405 | 406 | async def test_can_store_arbitrary_keys_on_request(): 407 | request = Request(None, None) 408 | request['custom'] = 'value' 409 | assert 'custom' in request 410 | assert request['custom'] == 'value' 411 | 412 | 413 | async def test_parse_multipart(protocol): 414 | protocol.data_received( 415 | b'POST /post HTTP/1.1\r\n' 416 | b'Host: localhost:1707\r\n' 417 | b'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:54.0) ' 418 | b'Gecko/20100101 Firefox/54.0\r\n' 419 | b'Origin: http://localhost:7777\r\n' 420 | b'Content-Length: 180\r\n' 421 | b'Content-Type: multipart/form-data; boundary=foofoo\r\n' 422 | b'\r\n' 423 | b'--foofoo\r\n' 424 | b'Content-Disposition: form-data; name=baz; filename="baz.png"\r\n' 425 | b'Content-Type: image/png\r\n' 426 | b'\r\n' 427 | b'abcdef\r\n' 428 | b'--foofoo\r\n' 429 | b'Content-Disposition: form-data; name="text1"\r\n' 430 | b'\r\n' 431 | b'abc\r\n--foofoo--') 432 | await protocol.request.load_body() 433 | assert protocol.request.form.get('text1') == 'abc' 434 | assert protocol.request.files.get('baz').filename == 'baz.png' 435 | assert protocol.request.files.get('baz').content_type == b'image/png' 436 | assert protocol.request.files.get('baz').read() == b'abcdef' 437 | 438 | 439 | async def test_parse_multipart_filename_star(protocol): 440 | protocol.data_received( 441 | b'POST /post HTTP/1.1\r\n' 442 | b'Host: localhost:1707\r\n' 443 | b'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:54.0) ' 444 | b'Gecko/20100101 Firefox/54.0\r\n' 445 | b'Origin: http://localhost:7777\r\n' 446 | b'Content-Length: 195\r\n' 447 | b'Content-Type: multipart/form-data; boundary=foofoo\r\n' 448 | b'\r\n' 449 | b'--foofoo\r\n' 450 | b'Content-Disposition: form-data; name=baz; ' 451 | b'filename*="iso-8859-1\'\'baz-\xe9.png"\r\n' 452 | b'Content-Type: image/png\r\n' 453 | b'\r\n' 454 | b'abcdef\r\n' 455 | b'--foofoo\r\n' 456 | b'Content-Disposition: form-data; name="text1"\r\n' 457 | b'\r\n' 458 | b'abc\r\n--foofoo--') 459 | await protocol.request.load_body() 460 | assert protocol.request.form.get('text1') == 'abc' 461 | assert protocol.request.files.get('baz').filename == 'baz-é.png' 462 | assert protocol.request.files.get('baz').content_type == b'image/png' 463 | assert protocol.request.files.get('baz').read() == b'abcdef' 464 | 465 | 466 | async def test_parse_unparsable_multipart(protocol): 467 | protocol.data_received( 468 | b'POST /post HTTP/1.1\r\n' 469 | b'Host: localhost:1707\r\n' 470 | b'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:54.0) ' 471 | b'Gecko/20100101 Firefox/54.0\r\n' 472 | b'Origin: http://localhost:7777\r\n' 473 | b'Content-Length: 18\r\n' 474 | b'Content-Type: multipart/form-data; boundary=foofoo\r\n' 475 | b'\r\n' 476 | b'--foofoo--foofoo--') 477 | await protocol.request.load_body() 478 | with pytest.raises(HttpError) as e: 479 | assert await protocol.request.form 480 | assert e.value.message == 'Unparsable multipart body' 481 | 482 | 483 | async def test_parse_unparsable_urlencoded(protocol): 484 | protocol.data_received( 485 | b'POST /post HTTP/1.1\r\n' 486 | b'Host: localhost:1707\r\n' 487 | b'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:54.0) ' 488 | b'Gecko/20100101 Firefox/54.0\r\n' 489 | b'Origin: http://localhost:7777\r\n' 490 | b'Content-Length: 3\r\n' 491 | b'Content-Type: application/x-www-form-urlencoded\r\n' 492 | b'\r\n' 493 | b'foo') 494 | await protocol.request.load_body() 495 | with pytest.raises(HttpError) as e: 496 | assert await protocol.request.form 497 | assert e.value.message == 'Unparsable urlencoded body' 498 | 499 | 500 | @pytest.mark.parametrize('params', [ 501 | ('filecontent', 'afile.txt'), 502 | (b'filecontent', 'afile.txt'), 503 | (BytesIO(b'filecontent'), 'afile.txt'), 504 | ]) 505 | async def test_post_multipart(client, app, params): 506 | 507 | @app.route('/test', methods=['POST']) 508 | async def post(req, resp): 509 | assert req.files.get('afile').filename == 'afile.txt' 510 | resp.body = req.files.get('afile').read() 511 | 512 | client.content_type = 'multipart/form-data' 513 | resp = await client.post('/test', files={'afile': params}) 514 | assert resp.status == HTTPStatus.OK 515 | assert resp.body == b'filecontent' 516 | 517 | 518 | async def test_post_urlencoded(client, app): 519 | 520 | @app.route('/test', methods=['POST']) 521 | async def post(req, resp): 522 | assert req.form.get('foo') == 'bar' 523 | resp.body = b'done' 524 | 525 | client.content_type = 'application/x-www-form-urlencoded' 526 | resp = await client.post('/test', data={'foo': 'bar'}) 527 | assert resp.status == HTTPStatus.OK 528 | assert resp.body == b'done' 529 | 530 | 531 | async def test_post_urlencoded_list(client, app): 532 | 533 | @app.route('/test', methods=['POST']) 534 | async def post(req, resp): 535 | assert req.form.get('foo') == 'bar' 536 | assert req.form.list('foo') == ['bar', 'baz'] 537 | resp.body = b'done' 538 | 539 | client.content_type = 'application/x-www-form-urlencoded' 540 | resp = await client.post('/test', data=[('foo', 'bar'), ('foo', 'baz')]) 541 | assert resp.status == HTTPStatus.OK 542 | assert resp.body == b'done' 543 | 544 | 545 | async def test_post_json(client, app): 546 | 547 | @app.route('/test', methods=['POST']) 548 | async def post(req, resp): 549 | assert req.json == {'foo': 'bar'} 550 | resp.body = b'done' 551 | 552 | resp = await client.post('/test', data={'foo': 'bar'}) 553 | assert resp.status == HTTPStatus.OK 554 | assert resp.body == b'done' 555 | 556 | 557 | async def test_post_json_is_cached(client, app): 558 | 559 | @app.route('/test', methods=['POST']) 560 | async def post(req, resp): 561 | assert req.body == b'{"foo": "bar"}' 562 | assert req.json == {'foo': 'bar'} 563 | # Even if we change the body, req.json is not reevaluated. 564 | req.body = b'{"baz": "quux"}' 565 | assert req.json == {'foo': 'bar'} 566 | resp.body = b'done' 567 | 568 | resp = await client.post('/test', data={'foo': 'bar'}) 569 | assert resp.status == HTTPStatus.OK 570 | assert resp.body == b'done' 571 | 572 | 573 | async def test_post_unparsable_json(client, app): 574 | 575 | @app.route('/test', methods=['POST']) 576 | async def post(req, resp): 577 | assert req.json 578 | 579 | resp = await client.post('/test', data='{"foo') 580 | assert resp.status == HTTPStatus.BAD_REQUEST 581 | assert resp.body == b'Unparsable JSON body' 582 | 583 | 584 | async def test_cannot_consume_lazy_body_if_not_loaded(client, app): 585 | 586 | @app.route('/test', methods=['POST'], lazy_body=True) 587 | async def post(req, resp): 588 | with pytest.raises(HttpError): 589 | resp.body = req.body 590 | resp.body = "Error has been raised" 591 | 592 | resp = await client.post('/test', data='blah') 593 | assert resp.status == HTTPStatus.OK 594 | assert resp.body == b'Error has been raised' 595 | 596 | 597 | async def test_can_consume_lazy_body_if_manually_loaded(client, app): 598 | 599 | @app.route('/test', methods=['POST'], lazy_body=True) 600 | async def post(req, resp): 601 | await req.load_body() 602 | resp.body = req.body 603 | 604 | resp = await client.post('/test', data='blah') 605 | assert resp.status == HTTPStatus.OK 606 | assert resp.body == b'blah' 607 | 608 | 609 | async def test_can_load_lazy_body_twice(client, app): 610 | 611 | @app.route('/test', methods=['POST'], lazy_body=True) 612 | async def post(req, resp): 613 | await req.load_body() 614 | await req.load_body() 615 | resp.body = req.body 616 | 617 | resp = await client.post('/test', data='blah') 618 | assert resp.status == HTTPStatus.OK 619 | assert resp.body == b'blah' 620 | 621 | 622 | async def test_can_consume_lazy_request_iterating(client, app): 623 | 624 | @app.route('/test', methods=['POST'], lazy_body=True) 625 | async def post(req, resp): 626 | async for chunk in req: 627 | resp.body = chunk 628 | 629 | resp = await client.post('/test', data='blah'*1000) 630 | assert resp.status == HTTPStatus.OK 631 | assert resp.body == b'blah'*1000 632 | 633 | 634 | async def test_can_consume_body_with_read(client, app): 635 | 636 | @app.route('/test', methods=['POST'], lazy_body=True) 637 | async def post(req, resp): 638 | resp.body = await req.read() 639 | 640 | resp = await client.post('/test', data='blah') 641 | assert resp.status == HTTPStatus.OK 642 | assert resp.body == b'blah' 643 | 644 | 645 | async def test_can_pause_reading(liveclient, app): 646 | 647 | @app.route('/test', methods=['POST'], lazy_body=True) 648 | async def post(req, resp): 649 | # Only first chunk should be read 650 | assert len(req._chunk) != 400 651 | data = b'' 652 | async for chunk in req: 653 | data += chunk 654 | assert len(data) == 400 655 | 656 | # Use an iterable so the request will be chunked. 657 | body = (b'blah' for i in range(100)) 658 | resp = await liveclient.query('POST', '/test', body=body) 659 | assert resp.status == HTTPStatus.OK 660 | 661 | 662 | async def test_can_read_empty_body(protocol): 663 | protocol.data_received( 664 | b'GET /foo%2Bbar HTTP/1.1\r\n' 665 | b'Host: localhost:1707\r\n' 666 | b'User-Agent: HTTPie/0.9.8\r\n' 667 | b'Accept-Encoding: gzip, deflate\r\n' 668 | b'Accept: */*\r\n' 669 | b'Connection: keep-alive\r\n' 670 | b'\r\n') 671 | await protocol.request.load_body() 672 | assert protocol.request.body == b'' 673 | -------------------------------------------------------------------------------- /tests/test_response.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from http import HTTPStatus 3 | 4 | import pytest 5 | 6 | pytestmark = pytest.mark.asyncio 7 | 8 | 9 | async def test_can_set_status_from_numeric_value(client, app): 10 | 11 | @app.route('/test') 12 | async def get(req, resp): 13 | resp.status = 202 14 | 15 | resp = await client.get('/test') 16 | assert resp.status == HTTPStatus.ACCEPTED 17 | 18 | 19 | async def test_raises_a_500_if_code_is_unknown(client, app): 20 | 21 | @app.route('/test') 22 | async def get(req, resp): 23 | resp.status = 999 24 | 25 | resp = await client.get('/test') 26 | assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR 27 | 28 | 29 | async def test_can_set_status_from_httpstatus(client, app): 30 | 31 | @app.route('/test') 32 | async def get(req, resp): 33 | resp.status = HTTPStatus.ACCEPTED 34 | 35 | resp = await client.get('/test') 36 | assert resp.status == HTTPStatus.ACCEPTED 37 | assert client.protocol.transport.data == \ 38 | b'HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n' 39 | 40 | 41 | async def test_write(client, app): 42 | 43 | @app.route('/test') 44 | async def get(req, resp): 45 | resp.status = HTTPStatus.OK 46 | resp.body = 'body' 47 | 48 | await client.get('/test') 49 | assert client.protocol.transport.data == \ 50 | b'HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nbody' 51 | 52 | 53 | async def test_write_get_204_no_content_type(client, app): 54 | 55 | @app.route('/test') 56 | async def get(req, resp): 57 | resp.status = HTTPStatus.NO_CONTENT 58 | 59 | await client.get('/test') 60 | assert client.protocol.transport.data == b'HTTP/1.1 204 No Content\r\n\r\n' 61 | 62 | 63 | async def test_write_get_304_no_content_type(client, app): 64 | 65 | @app.route('/test') 66 | async def get(req, resp): 67 | resp.status = HTTPStatus.NOT_MODIFIED 68 | 69 | await client.get('/test') 70 | assert client.protocol.transport.data == b'HTTP/1.1 304 Not Modified\r\n\r\n' 71 | 72 | 73 | async def test_write_get_1XX_no_content_type(client, app): 74 | 75 | @app.route('/test') 76 | async def get(req, resp): 77 | resp.status = HTTPStatus.CONTINUE 78 | 79 | await client.get('/test') 80 | assert client.protocol.transport.data == b'HTTP/1.1 100 Continue\r\n\r\n' 81 | 82 | 83 | async def test_write_head_no_content_type(client, app): 84 | 85 | @app.route('/test', methods=['HEAD']) 86 | async def head(req, resp): 87 | resp.status = HTTPStatus.OK 88 | 89 | await client.head('/test') 90 | assert client.protocol.transport.data == b'HTTP/1.1 200 OK\r\n\r\n' 91 | 92 | 93 | async def test_write_cookies(client, app): 94 | 95 | @app.route('/test') 96 | async def get(req, resp): 97 | resp.status = HTTPStatus.OK 98 | resp.body = 'body' 99 | resp.cookies.set(name='name', value='value') 100 | 101 | await client.get('/test') 102 | data = client.protocol.transport.data 103 | assert b'\r\nSet-Cookie: name=value; Path=/\r\n' in data 104 | 105 | 106 | async def test_write_multiple_cookies(client, app): 107 | 108 | @app.route('/test') 109 | async def get(req, resp): 110 | resp.status = HTTPStatus.OK 111 | resp.body = 'body' 112 | resp.cookies.set('name', 'value') 113 | resp.cookies.set('other', 'value2') 114 | 115 | await client.get('/test') 116 | data = client.protocol.transport.data 117 | assert b'\r\nSet-Cookie: name=value; Path=/\r\n' in data 118 | assert b'\r\nSet-Cookie: other=value2; Path=/\r\n' in data 119 | 120 | 121 | async def test_write_cookies_with_path(client, app): 122 | 123 | @app.route('/test') 124 | async def get(req, resp): 125 | resp.status = HTTPStatus.OK 126 | resp.body = 'body' 127 | resp.cookies.set('name', 'value', path='/foo') 128 | 129 | await client.get('/test') 130 | data = client.protocol.transport.data 131 | assert b'\r\nSet-Cookie: name=value; Path=/foo\r\n' in data 132 | 133 | 134 | async def test_write_cookies_with_expires(client, app): 135 | 136 | @app.route('/test') 137 | async def get(req, resp): 138 | resp.status = HTTPStatus.OK 139 | resp.body = 'body' 140 | resp.cookies.set('name', 'value', 141 | expires=datetime(2027, 9, 21, 11, 22)) 142 | 143 | await client.get('/test') 144 | data = client.protocol.transport.data 145 | assert (b'\r\nSet-Cookie: name=value; ' 146 | b'Expires=Tue, 21 Sep 2027 11:22:00 GMT; Path=/\r\n') in data 147 | 148 | 149 | async def test_write_cookies_with_max_age(client, app): 150 | 151 | @app.route('/test') 152 | async def get(req, resp): 153 | resp.status = HTTPStatus.OK 154 | resp.body = 'body' 155 | resp.cookies.set('name', 'value', max_age=600) 156 | 157 | await client.get('/test') 158 | data = client.protocol.transport.data 159 | assert (b'\r\nSet-Cookie: name=value; Max-Age=600; Path=/\r\n') in data 160 | 161 | 162 | async def test_write_cookies_with_domain(client, app): 163 | 164 | @app.route('/test') 165 | async def get(req, resp): 166 | resp.status = HTTPStatus.OK 167 | resp.body = 'body' 168 | resp.cookies.set('name', 'value', domain='www.example.com') 169 | 170 | await client.get('/test') 171 | data = client.protocol.transport.data 172 | assert (b'\r\nSet-Cookie: name=value; Domain=www.example.com; ' 173 | b'Path=/\r\n') in data 174 | 175 | 176 | async def test_write_cookies_with_http_only(client, app): 177 | 178 | @app.route('/test') 179 | async def get(req, resp): 180 | resp.status = HTTPStatus.OK 181 | resp.body = 'body' 182 | resp.cookies.set('name', 'value', httponly=True) 183 | 184 | await client.get('/test') 185 | data = client.protocol.transport.data 186 | assert (b'\r\nSet-Cookie: name=value; Path=/; HttpOnly\r\n') in data 187 | 188 | 189 | async def test_write_cookies_with_secure(client, app): 190 | 191 | @app.route('/test') 192 | async def get(req, resp): 193 | resp.status = HTTPStatus.OK 194 | resp.body = 'body' 195 | resp.cookies.set('name', 'value', secure=True) 196 | 197 | await client.get('/test') 198 | data = client.protocol.transport.data 199 | assert (b'\r\nSet-Cookie: name=value; Path=/; Secure\r\n') in data 200 | 201 | 202 | async def test_write_cookies_with_multiple_attributes(client, app): 203 | 204 | @app.route('/test') 205 | async def get(req, resp): 206 | resp.status = HTTPStatus.OK 207 | resp.body = 'body' 208 | resp.cookies.set('name', 'value', secure=True, max_age=300) 209 | 210 | await client.get('/test') 211 | data = client.protocol.transport.data 212 | assert (b'\r\nSet-Cookie: name=value; Max-Age=300; Path=/; ' 213 | b'Secure\r\n') in data 214 | 215 | 216 | async def test_delete_cookies(client, app): 217 | 218 | @app.route('/test') 219 | async def get(req, resp): 220 | resp.status = HTTPStatus.OK 221 | resp.body = 'body' 222 | resp.cookies.set(name='name', value='value') 223 | del resp.cookies['name'] 224 | 225 | resp = await client.get('/test') 226 | assert resp.status == HTTPStatus.OK 227 | data = client.protocol.transport.data 228 | assert b'\r\nSet-Cookie: name=value\r\n' not in data 229 | 230 | 231 | async def test_write_chunks(client, app): 232 | 233 | async def mygen(): 234 | for i in range(3): 235 | yield ("chunk" + str(i)).encode() 236 | 237 | @app.route('/test') 238 | async def head(req, resp): 239 | resp.body = mygen() 240 | 241 | await client.get('/test') 242 | assert client.protocol.transport.data == ( 243 | b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n6\r\nchunk0" 244 | b"\r\n6\r\nchunk1\r\n6\r\nchunk2\r\n0\r\n\r\n") 245 | 246 | 247 | async def test_write_non_bytes_chunks(client, app): 248 | 249 | async def mygen(): 250 | for i in range(3): 251 | yield i 252 | 253 | @app.route('/test') 254 | async def head(req, resp): 255 | resp.body = mygen() 256 | 257 | await client.get('/test') 258 | assert client.protocol.transport.data == ( 259 | b'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n1\r\n0\r\n1' 260 | b'\r\n1\r\n1\r\n2\r\n0\r\n\r\n') 261 | 262 | 263 | async def test_set_redirect(client, app): 264 | 265 | @app.route('/test') 266 | async def get(req, resp): 267 | resp.redirect = "https://example.org", 307 268 | 269 | resp = await client.get('/test') 270 | assert resp.status == 307 271 | assert resp.headers["Location"] == "https://example.org" 272 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | 5 | pytestmark = pytest.mark.asyncio 6 | 7 | 8 | async def test_simple_get_request(client, app): 9 | 10 | @app.route('/test') 11 | async def get(req, resp): 12 | resp.body = 'test response' 13 | 14 | resp = await client.get('/test') 15 | assert resp.status == HTTPStatus.OK 16 | assert resp.body == b'test response' 17 | 18 | 19 | async def test_simple_non_200_response(client, app): 20 | 21 | @app.route('/test') 22 | async def get(req, resp): 23 | resp.status = 204 24 | 25 | resp = await client.get('/test') 26 | assert resp.status == HTTPStatus.NO_CONTENT 27 | assert resp.body == b'' 28 | 29 | 30 | async def test_not_found_path(client, app): 31 | 32 | @app.route('/test') 33 | async def get(req, resp): 34 | ... 35 | 36 | resp = await client.get('/testing') 37 | assert resp.status == HTTPStatus.NOT_FOUND 38 | 39 | 40 | async def test_invalid_method(client, app): 41 | 42 | @app.route('/test', methods=['GET']) 43 | async def get(req, resp): 44 | ... 45 | 46 | resp = await client.post('/test', body=b'') 47 | assert resp.status == HTTPStatus.METHOD_NOT_ALLOWED 48 | 49 | 50 | async def test_post_json(client, app): 51 | 52 | @app.route('/test', methods=['POST']) 53 | async def post(req, resp): 54 | resp.body = req.body 55 | 56 | resp = await client.post('/test', body={'key': 'value'}) 57 | assert resp.status == HTTPStatus.OK 58 | assert resp.body == b'{"key": "value"}' 59 | 60 | 61 | async def test_post_urlencoded(client, app): 62 | 63 | @app.route('/test', methods=['POST']) 64 | async def post(req, resp): 65 | resp.body = req.body 66 | 67 | client.content_type = 'application/x-www-form-urlencoded' 68 | resp = await client.post('/test', body={'key': 'value'}) 69 | assert resp.status == HTTPStatus.OK 70 | assert resp.body == b'key=value' 71 | 72 | 73 | async def test_can_define_twice_a_route_with_different_payloads(client, app): 74 | 75 | @app.route('/test', methods=['GET']) 76 | async def get(req, resp): 77 | resp.body = b'GET' 78 | 79 | @app.route('/test', methods=['POST']) 80 | async def post(req, resp): 81 | resp.body = b'POST' 82 | 83 | resp = await client.get('/test') 84 | assert resp.status == HTTPStatus.OK 85 | assert resp.body == b'GET' 86 | 87 | resp = await client.post('/test', {}) 88 | assert resp.status == HTTPStatus.OK 89 | assert resp.body == b'POST' 90 | 91 | 92 | async def test_simple_get_request_with_accent(client, app): 93 | 94 | @app.route('/testé') 95 | async def get(req, resp): 96 | resp.body = 'testé response' 97 | 98 | resp = await client.get('/testé') 99 | assert resp.status == HTTPStatus.OK 100 | assert resp.body == 'testé response'.encode() 101 | 102 | 103 | async def test_simple_get_request_with_query_string(client, app): 104 | 105 | @app.route('/testé') 106 | async def get(req, resp): 107 | resp.body = req.query.get("q") 108 | 109 | resp = await client.get('/testé?q=baré') 110 | assert resp.status == HTTPStatus.OK 111 | assert resp.body == 'baré'.encode() 112 | 113 | 114 | async def test_simple_get_request_with_empty_query_string(client, app): 115 | 116 | @app.route('/test') 117 | async def get(req, resp): 118 | assert req.query.get("q") == "" 119 | resp.body = "Empty string" 120 | 121 | resp = await client.get('/test?q=') 122 | assert resp.status == HTTPStatus.OK 123 | assert resp.body == b'Empty string' 124 | 125 | 126 | async def test_route_with_different_signatures_on_same_handler(client, app): 127 | 128 | @app.route("/test/", name="collection") 129 | @app.route("/test/{id}", name="item") 130 | async def myhandler(request, response, id="default"): 131 | response.body = id 132 | 133 | assert (await client.get("/test/")).body == b"default" 134 | assert (await client.get("/test/other")).body == b"other" 135 | -------------------------------------------------------------------------------- /tests/test_websockets.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import asyncio 3 | import websockets 4 | from http import HTTPStatus 5 | from roll.extensions import websockets_store 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_websocket_route(liveclient): 10 | ev = asyncio.Event() 11 | 12 | @liveclient.app.route('/ws', protocol="websocket") 13 | async def handler(request, ws, **params): 14 | assert ws.subprotocol is None 15 | ev.set() 16 | 17 | response = await liveclient.query('GET', '/ws', headers={ 18 | 'Upgrade': 'websocket', 19 | 'Connection': 'upgrade', 20 | 'Sec-WebSocket-Key': 'hojIvDoHedBucveephosh8==', 21 | 'Sec-WebSocket-Version': '13'}) 22 | 23 | assert ev.is_set() 24 | assert response.status == HTTPStatus.SWITCHING_PROTOCOLS 25 | 26 | # With keep-alive in Connection 27 | response = await liveclient.query('GET', '/ws', headers={ 28 | 'Upgrade': 'websocket', 29 | 'Connection': 'keep-alive, upgrade', 30 | 'Sec-WebSocket-Key': 'hojIvDoHedBucveephosh8==', 31 | 'Sec-WebSocket-Version': '13'}) 32 | 33 | assert response.status == HTTPStatus.SWITCHING_PROTOCOLS 34 | 35 | 36 | @pytest.mark.asyncio 37 | async def test_websocket_communication(liveclient): 38 | 39 | @liveclient.app.route('/echo', protocol="websocket") 40 | async def echo(request, ws, **params): 41 | while True: 42 | message = await ws.recv() 43 | await ws.send(message) 44 | 45 | # Echo 46 | websocket = await websockets.connect(liveclient.wsl + '/echo') 47 | try: 48 | for message in ('This', 'is', 'a', 'simple', 'test'): 49 | await websocket.send(message) 50 | echoed = await websocket.recv() 51 | assert echoed == message 52 | finally: 53 | await websocket.close() 54 | 55 | # Nothing sent, just close 56 | websocket = await websockets.connect(liveclient.wsl + '/echo') 57 | await websocket.close() 58 | 59 | 60 | @pytest.mark.asyncio 61 | async def test_websocket_broadcasting(app, liveclient): 62 | 63 | websockets_store(app) 64 | 65 | @app.route('/broadcast', protocol="websocket") 66 | async def feed(request, ws, **params): 67 | while True: 68 | message = await ws.recv() 69 | await asyncio.wait([ 70 | socket.send(message) for socket 71 | in request.app['websockets'] if socket != ws]) 72 | 73 | # connecting 74 | connected = [] 75 | for n in range(1, 6): 76 | ws = await websockets.connect(liveclient.wsl + '/broadcast') 77 | connected.append(ws) 78 | 79 | # Broadcasting 80 | for wid, ws in enumerate(connected, 1): 81 | broadcast = 'this is a broadcast from {}'.format(wid) 82 | await ws.send(broadcast) 83 | for ows in connected: 84 | if ows != ws: 85 | message = await ows.recv() 86 | assert message == broadcast 87 | 88 | # Closing 89 | for ws in connected: 90 | await ws.close() 91 | 92 | 93 | @pytest.mark.asyncio 94 | async def test_websocket_binary(liveclient): 95 | 96 | @liveclient.app.route('/bin', protocol="websocket") 97 | async def binary(request, ws, **params): 98 | await ws.send(b'test') 99 | 100 | # Echo 101 | websocket = await websockets.connect(liveclient.wsl + '/bin') 102 | bdata = await websocket.recv() 103 | await websocket.close_connection_task 104 | assert bdata == b'test' 105 | assert websocket.close_reason == '' 106 | assert websocket.state == websockets.protocol.State.CLOSED 107 | 108 | 109 | @pytest.mark.asyncio 110 | async def test_websocket_route_with_subprotocols(liveclient): 111 | results = [] 112 | 113 | @liveclient.app.route( 114 | '/ws', protocol="websocket", subprotocols=['foo', 'bar']) 115 | async def handler(request, ws): 116 | results.append(ws.subprotocol) 117 | 118 | response = await liveclient.query('GET', '/ws', headers={ 119 | 'Upgrade': 'websocket', 120 | 'Connection': 'upgrade', 121 | 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', 122 | 'Sec-WebSocket-Version': '13', 123 | 'Sec-WebSocket-Protocol': 'bar'}) 124 | assert response.status == HTTPStatus.SWITCHING_PROTOCOLS 125 | 126 | response = await liveclient.query('GET', '/ws', headers={ 127 | 'Upgrade': 'websocket', 128 | 'Connection': 'upgrade', 129 | 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', 130 | 'Sec-WebSocket-Version': '13', 131 | 'Sec-WebSocket-Protocol': 'bar, foo'}) 132 | assert response.status == HTTPStatus.SWITCHING_PROTOCOLS 133 | 134 | response = await liveclient.query('GET', '/ws', headers={ 135 | 'Upgrade': 'websocket', 136 | 'Connection': 'upgrade', 137 | 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', 138 | 'Sec-WebSocket-Version': '13', 139 | 'Sec-WebSocket-Protocol': 'baz'}) 140 | assert response.status == HTTPStatus.SWITCHING_PROTOCOLS 141 | 142 | response = await liveclient.query('GET', '/ws', headers={ 143 | 'Upgrade': 'websocket', 144 | 'Connection': 'upgrade', 145 | 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', 146 | 'Sec-WebSocket-Version': '13'}) 147 | assert response.status == HTTPStatus.SWITCHING_PROTOCOLS 148 | assert results == ['bar', 'bar', None, None] 149 | -------------------------------------------------------------------------------- /tests/test_websockets_failure.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from http import HTTPStatus 3 | 4 | import pytest 5 | import websockets 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_websocket_upgrade_error(liveclient): 10 | 11 | @liveclient.app.route('/ws', protocol='websocket') 12 | async def handler(request, ws): 13 | pass 14 | 15 | # Wrong upgrade 16 | response = await liveclient.query('GET', '/ws', headers={ 17 | 'Upgrade': 'h2c', 18 | 'Connection': 'upgrade', 19 | 'Sec-WebSocket-Key': 'hojIvDoHedBucveephosh8==', 20 | 'Sec-WebSocket-Version': '13', 21 | }) 22 | assert response.status == HTTPStatus.NOT_IMPLEMENTED 23 | assert response.reason == 'Not Implemented' 24 | 25 | # No upgrade 26 | response = await liveclient.query('GET', '/ws', headers={ 27 | 'Connection': 'keep-alive', 28 | }) 29 | assert response.status == HTTPStatus.UPGRADE_REQUIRED 30 | assert response.reason == 'Upgrade Required' 31 | 32 | # Connection upgrade with no upgrade header 33 | response = await liveclient.query('GET', '/ws', headers={ 34 | 'Connection': 'upgrade', 35 | }) 36 | assert response.status == HTTPStatus.UPGRADE_REQUIRED 37 | assert response.reason == 'Upgrade Required' 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_websocket_failure(liveclient, monkeypatch): 42 | 43 | @liveclient.app.route('/failure', protocol="websocket") 44 | async def failme(request, ws): 45 | raise NotImplementedError('OUCH') 46 | 47 | websocket = await websockets.connect(liveclient.wsl + '/failure') 48 | 49 | monkeypatch.setattr('roll.WSProtocol.TIMEOUT', 1) 50 | with pytest.raises(websockets.exceptions.ConnectionClosed): 51 | # The client has 1 second timeout before the 52 | # closing handshake timeout and the brutal disconnection. 53 | # We wait beyond the closing frame timeout : 54 | await asyncio.sleep(2) 55 | 56 | # No, we try sending while the closing frame timespan has expired 57 | await websocket.send('first') 58 | 59 | # No need to close here, the closing was unilateral, as we 60 | # did not comply in time. 61 | # We check the remains of the disowned client : 62 | assert websocket.state == websockets.protocol.State.CLOSED 63 | assert websocket.close_code == 1011 64 | assert websocket.close_reason == 'Handler died prematurely.' 65 | 66 | 67 | @pytest.mark.asyncio 68 | async def test_websocket_failure_intime(liveclient): 69 | 70 | @liveclient.app.route('/failure', protocol="websocket") 71 | async def failme(request, ws): 72 | raise NotImplementedError('OUCH') 73 | 74 | websocket = await websockets.connect(liveclient.wsl + '/failure') 75 | # Sent within the closing frame span messages will be ignored but 76 | # won't create any error as the server is polite and awaits the 77 | # closing frame to ends the interaction in a friendly manner. 78 | await websocket.send('first') 79 | 80 | # The server is on hold, waiting for the closing handshake 81 | # We provide it to be civilized. 82 | await websocket.close() 83 | 84 | # The websocket was closed with the error, but in a gentle way. 85 | # No exception raised. 86 | assert websocket.state == websockets.protocol.State.CLOSED 87 | assert websocket.close_code == 1011 88 | assert websocket.close_reason == 'Handler died prematurely.' 89 | 90 | 91 | @pytest.mark.asyncio 92 | async def test_websocket_failure_receive(liveclient): 93 | 94 | @liveclient.app.route('/failure', protocol="websocket") 95 | async def failme(request, ws): 96 | raise NotImplementedError('OUCH') 97 | 98 | websocket = await websockets.connect(liveclient.wsl + '/failure') 99 | with pytest.raises(websockets.exceptions.ConnectionClosed): 100 | # Receiving, on the other hand, will raise immediatly an 101 | # error as the reader is closed. Only the writer is opened 102 | # as we are expected to send back the closing frame. 103 | await websocket.recv() 104 | 105 | await websocket.close() 106 | assert websocket.state == websockets.protocol.State.CLOSED 107 | assert websocket.close_code == 1011 108 | assert websocket.close_reason == 'Handler died prematurely.' 109 | --------------------------------------------------------------------------------