Compare commits

...

20 Commits

Author SHA1 Message Date
kennethreitz b8cff1655a websockets 2018-10-22 10:18:37 -04:00
kennethreitz 232856ca3a Merge branch 'master' of github.com:kennethreitz/responder 2018-10-22 10:07:38 -04:00
kennethreitz 3f168ac6fd slots 2018-10-22 10:06:55 -04:00
kennethreitz c59cb1d0d3 websocket 2018-10-22 10:06:03 -04:00
kennethreitz ec13df75d0 kinda test websocket support 2018-10-22 10:05:20 -04:00
kennethreitz 6fc02964ba cleanup 2018-10-22 09:59:38 -04:00
kennethreitz ed79e45680 Merge pull request #116 from tkamenoko/patch-1
doc: fix Class-based views
2018-10-22 09:30:14 -04:00
kennethreitz 1be983bf89 cleanup 2018-10-22 09:28:14 -04:00
T.Kameyama b09d6a9d04 doc: fix Class-based views
In Class-based views, each method needs `self` as 1st argument.
2018-10-22 14:37:55 +09:00
taoufik07 db143d845d cleanup 2018-10-21 18:17:56 +01:00
taoufik07 2e23501f9d Fix check_existing 2018-10-21 18:15:13 +01:00
taoufik07 bd6addcd3a Add websocket support 2018-10-21 18:00:25 +01:00
taoufik07 631e1fb604 Add WebSocket 2018-10-21 17:36:21 +01:00
kennethreitz 30ee6726a8 Merge pull request #113 from metakermit/extend-static-docs
Extend static=True docs
2018-10-21 06:25:56 -04:00
kennethreitz 1c397db9d8 cleanup 2018-10-21 06:23:02 -04:00
kennethreitz cc23ca80f4 Merge pull request #112 from Nitish18/feat/server_debug_mode
feat: added debug mode for uvicorn server
2018-10-21 06:20:16 -04:00
Dražen Lučanin 449379a0ed extend static=True docs 2018-10-21 11:58:54 +02:00
Nitish Chauhan b3208b1c5b feat: added debug mode for uvicorn server 2018-10-21 15:20:08 +05:30
kennethreitz 4df60b55a6 Merge pull request #110 from sheb/patch-2
fix an AttributeError when route does not exist
2018-10-20 13:54:58 -07:00
Sébastien Geffroy 379553a1a5 fix an AttributeError when route does not exist 2018-10-20 21:55:43 +02:00
9 changed files with 183 additions and 39 deletions
+3
View File
@@ -1,3 +1,6 @@
# v0.2.0
- WebSocket support.
# v0.1.6
- 500 support.
+1
View File
@@ -50,6 +50,7 @@ Features
- A pleasant API, with a single import statement.
- Class-based views without inheritence.
- ASGI framework, the future of Python web services.
- WebSocket support!
- The ability to mount any ASGI / WSGI app at a subroute.
- *f-string syntax* route declaration.
- Mutable response object, passed into each view. No need to return anything.
+17 -2
View File
@@ -9,7 +9,7 @@ Class-based views (and setting some headers and stuff)::
@api.route("/{greeting}")
class GreetingResource:
def on_request(req, resp, *, greeting): # or on_get...
def on_request(self, req, resp, *, greeting): # or on_get...
resp.text = f"{greeting}, world!"
resp.headers.update({'X-Life': '42'})
resp.status_code = api.status_codes.HTTP_416
@@ -147,7 +147,11 @@ If you have a single-page webapp, you can tell Responder to serve up your ``stat
api.add_route("/", static=True)
This will make ``index.html`` the default response to all undefined routes.
This will make ``index.html`` the default response to all undefined routes. Responder's CLI comes with a ``build`` command that will call ``npm run build`` for you::
responder build
For an example of how to seamlessly integrate a React single page app with Responder check out `this project <https://github.com/metakermit/responder-react>`_.
Reading / Writing Cookies
-------------------------
@@ -176,6 +180,17 @@ You can easily read a Request's session data, that can be trusted to have origin
**Note**: if you are using this in production, you should pass the ``secret_key`` argument to ``API(...)``.
WebSocket Support
-----------------
Responder supports WebSockets::
@api.ws_route('/ws')
async def hello(ws):
await ws.accept()
await ws.send_text("Hello via websocket!")
await ws.close()
HSTS (Redirect to HTTPS)
------------------------
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.6"
__version__ = "0.2.0"
+72 -29
View File
@@ -25,6 +25,7 @@ from .routes import Route
from .formats import get_formats
from .background import BackgroundQueue
from .templates import GRAPHIQL
from .models import WebSocket
# TODO: consider moving status codes here
class API:
@@ -148,13 +149,22 @@ class API:
# Call the main dispatcher.
async def asgi(receive, send):
nonlocal scope, self
if scope["type"] == "websocket":
req = models.Request(scope, receive=receive, api=self)
resp = await self._dispatch_request(req)
await resp(receive, send)
ws = WebSocket(scope, receive, send)
await self._dispatch_ws(ws)
else:
req = models.Request(scope, receive=receive, api=self)
resp = await self._dispatch_request(req)
await resp(receive, send)
return asgi
async def _dispatch_ws(self, ws):
route = self.path_matches_route(ws.url.path, protocol="ws")
route = self.routes.get(route)
await self._dispatch(route, ws=ws)
def add_schema(self, name, schema, check_existing=True):
"""Adds a mashmallow schema to the API specification."""
if check_existing:
@@ -181,17 +191,17 @@ class API:
return decorator
def path_matches_route(self, path):
# TODO: Remove protocol
def path_matches_route(self, path, protocol="http"):
"""Given a path portion of a URL, tests that it matches against any registered route.
:param path: The path portion of a URL, to test all known routes against.
"""
for (route, route_object) in self.routes.items():
if route_object.does_match(path):
if route_object.does_match(path, protocol):
return route
def _prepare_cookies(self, resp):
# print(resp.cookies)
if resp.cookies:
header = " ".join([f"{k}={v}" for k, v in resp.cookies.items()])
resp.headers["Set-Cookie"] = header
@@ -217,28 +227,45 @@ class API:
# Get the route.
route = self.path_matches_route(req.url.path)
route = self.routes.get(route)
params = route.incoming_matches(req.url.path)
# Create the response object.
resp = models.Response(req=req, formats=self.formats)
self.default_response(req, resp)
await self._dispatch(route, req=req, resp=resp)
self._prepare_session(resp)
self._prepare_cookies(resp)
return resp
async def _dispatch(self, route, **kwargs):
cont = False
if route:
if "req" in kwargs:
params = route.incoming_matches(kwargs["req"].url.path)
elif "ws" in kwargs:
params = route.incoming_matches(kwargs["ws"].url.path)
else:
params = {}
if route.is_graphql:
await self.graphql_response(req, resp, schema=route.endpoint)
await self.graphql_response(schema=route.endpoint, **kwargs)
elif route.is_function:
try:
try:
# Run the view.
r = route.endpoint(req, resp, **params)
r = route.endpoint(**kwargs, **params)
# If it's async, await it.
if hasattr(r, "cr_running"):
await r
except TypeError as e:
cont = True
except Exception:
self.default_response(req, resp, error=True)
self.default_response(error=True, **kwargs)
if route.is_class_based or cont:
try:
@@ -250,53 +277,67 @@ class API:
try:
# Run the view.
r = getattr(view, "on_request", self.no_response)(
req, resp, **params
**kwargs, **params
)
# If it's async, await it.
if hasattr(r, "send"):
await r
except Exception as e:
self.default_response(req, resp, error=True)
except Exception:
self.default_response(error=True, **kwargs)
# Then on_get.
method = req.method
if "req" in kwargs:
method = kwargs["req"].method
elif "ws" in kwargs:
method = kwargs["ws"].method
else:
method = "get"
# Run on_request first.
try:
# Run the view.
r = getattr(view, f"on_{method}", self.no_response)(
req, resp, **params
**kwargs, **params
)
# If it's async, await it.
if hasattr(r, "send"):
await r
except Exception as e:
self.default_response(req, resp, error=True)
self.default_response(error=True, **kwargs)
else:
self.default_response(req, resp, notfound=True)
self.default_response(notfound=True, **kwargs)
self.default_response(req, resp)
self._prepare_session(resp)
self._prepare_cookies(resp)
return resp
return kwargs
def add_route(
self, route, endpoint=None, *, default=False, static=False, check_existing=True
self,
route,
endpoint=None,
*,
default=False,
websocket=False,
static=False,
check_existing=True,
):
"""Add a route to the API.
:param route: A string representation of the route.
:param endpoint: The endpoint for the route -- can be a callable, a class, or graphene schema (GraphQL).
:param default: If ``True``, all unknown requests will route to this view.
:param websocket: If ``True``, Requests to route will be treated as websockets.
:param static: If ``True``, and no endpoint was passed, render "static/index.html", and it will become a default route.
:param check_existing: If ``True``, an AssertionError will be raised, if the route is already defined.
"""
if websocket:
protocol = "ws"
else:
protocol = "http"
if check_existing:
assert route not in self.routes
assert not (
route in self.routes and self.routes[route].protocol == protocol
)
if not endpoint and static:
endpoint = self.static_response
@@ -311,7 +352,7 @@ class API:
except AttributeError:
pass
self.routes[route] = Route(route, endpoint)
self.routes[route] = Route(route, endpoint, protocol)
# TODO: A better datastructer or sort it once the app is loaded
self.routes = dict(
sorted(self.routes.items(), key=lambda item: item[1]._weight())
@@ -490,15 +531,17 @@ class API:
template = self.jinja_env.from_string(s_)
return template.render(**values)
def run(self, address=None, port=None, **options):
def run(self, address=None, port=None, debug=False, **options):
"""Runs the application with uvicorn. If the ``PORT`` environment
variable is set, requests will be served on that port automatically to all
known hosts.
:param address: The address to bind to.
:param port: The port to bind to. If none is provided, one will be selected at random.
:param debug: Run uvicorn server in debug mode.
:param options: Additional keyword arguments to send to ``uvicorn.run()``.
"""
if "PORT" in os.environ:
if address is None:
address = "0.0.0.0"
@@ -509,4 +552,4 @@ class API:
if port is None:
port = 5042
uvicorn.run(self, host=address, port=port, **options)
uvicorn.run(self, host=address, port=port, debug=debug, **options)
+1 -1
View File
@@ -34,7 +34,7 @@ async def format_files(r, encode=False):
if encode:
pass
else:
decoded = decoder.MultipartDecoder(await r.content, r.headers["Content-Type"])
decoded = decoder.MultipartDecoder(await r.content, r.mimetype)
dump = {}
for part in decoded.parts:
header = part.headers[b"Content-Disposition"].decode("utf-8")
+63
View File
@@ -13,6 +13,7 @@ from requests.cookies import RequestsCookieJar
from starlette.datastructures import MutableHeaders
from starlette.requests import Request as StarletteRequest
from starlette.responses import Response as StarletteResponse
from starlette.websockets import WebSocket as StarletteWebSocket
from urllib.parse import parse_qs
@@ -287,3 +288,65 @@ class Response:
body, status_code=self.status_code, headers=headers
)
await response(receive, send)
class WebSocket:
__slots__ = ("_starlette", "_headers")
def __init__(self, scope, receive, send):
self._starlette = StarletteWebSocket(scope, receive, send)
headers = CaseInsensitiveDict()
for header, value in self._starlette.headers.items():
headers[header] = value
self._headers = headers
@property
def url(self):
return rfc3986.urlparse(str(self._starlette.url))
@property
def params(self):
"""A dictionary of the parsed query parameters used for the Request."""
try:
return QueryDict(self.url.query)
except AttributeError:
return QueryDict({})
async def accept(self):
return await self._starlette.accept()
async def receive(self):
return await self._starlette.receive()
async def content(self):
"""Receive bytes."""
return await self._starlette.receive_bytes()
async def text(self):
"""Receive text."""
return await self._starlette.receive_text()
# async def json(self):
# """Receive json"""
# return await self._starlette.receive_json()
# async def media(self):
# """Receive json"""
# return (await self.receive_json())
async def send(self):
await self._starlette.send()
async def send_text(self, data):
await self._starlette.send_text(data)
# async def send_json(self, data):
# await self._starlette.send_json(data)
# async def send_media(self, data):
# await self.send_json(data)
async def close(self, code=1000):
await self._starlette.close(code)
+7 -4
View File
@@ -3,10 +3,10 @@ from parse import parse
def memoize(f):
def helper(self, s):
def helper(self, s, *args, **kwargs):
memoize_key = f"{f.__name__}:{s}"
if memoize_key not in self._memo:
self._memo[memoize_key] = f(self, s)
self._memo[memoize_key] = f(self, s, *args, **kwargs)
return self._memo[memoize_key]
return helper
@@ -15,9 +15,10 @@ def memoize(f):
class Route:
_param_pattern = re.compile(r"{([^{}]*)}")
def __init__(self, route, endpoint):
def __init__(self, route, endpoint, protocol="http"):
self.route = route
self.endpoint = endpoint
self.protocol = protocol
self._memo = {}
def __repr__(self):
@@ -44,8 +45,10 @@ class Route:
return bool(self._param_pattern.search(self.route))
@memoize
def does_match(self, s):
def does_match(self, s, protocol="http"):
if s == self.route:
if self.protocol != protocol:
return False
return True
named = self.incoming_matches(s)
+18 -2
View File
@@ -151,7 +151,7 @@ def test_request_and_get(api, session):
def on_request(self, req, resp):
resp.headers.update({"DEATH": "666"})
def on_get(self, request, resp):
def on_get(self, req, resp):
resp.headers.update({"LIFE": "42"})
r = session.get(api.url_for(ThingsResource))
@@ -428,8 +428,24 @@ def test_file_uploads(api, session):
def test_500(api, session):
@api.route("/")
def view(rea, resp):
def view(req, resp):
raise ValueError
r = session.get(api.url_for(view))
assert not r.ok
def test_404(session):
r = session.get("/foo")
assert r.status_code == responder.status_codes.HTTP_404
def test_kinda_websockets(api):
@api.route("/ws", websocket=True)
async def websocket(ws):
await ws.accept()
await ws.send_text("Hello via websocket!")
await ws.close()