mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8cff1655a | |||
| 232856ca3a | |||
| 3f168ac6fd | |||
| c59cb1d0d3 | |||
| ec13df75d0 | |||
| 6fc02964ba | |||
| ed79e45680 | |||
| 1be983bf89 | |||
| b09d6a9d04 | |||
| db143d845d | |||
| 2e23501f9d | |||
| bd6addcd3a | |||
| 631e1fb604 | |||
| 30ee6726a8 | |||
| 1c397db9d8 | |||
| cc23ca80f4 | |||
| 449379a0ed | |||
| b3208b1c5b | |||
| 4df60b55a6 | |||
| 379553a1a5 | |||
| a2eaa5c7b5 | |||
| 175c46e68c | |||
| a58cc11079 | |||
| 218a375c27 | |||
| 567b1577c6 | |||
| 3c3687d11f | |||
| 19dfac8340 | |||
| b61feafe5a | |||
| 0c342c8b3e | |||
| dbcba8fad7 | |||
| b8053e20f2 | |||
| 1896901aa8 | |||
| 383c9132ed | |||
| 57b144c3e7 | |||
| 5d43c0418c | |||
| 87c0076e12 | |||
| 95252ac697 |
+8
-2
@@ -1,3 +1,9 @@
|
||||
# v0.2.0
|
||||
- WebSocket support.
|
||||
|
||||
# v0.1.6
|
||||
- 500 support.
|
||||
|
||||
# v0.1.5
|
||||
- Improvements to sequential media reading.
|
||||
- File upload support.
|
||||
@@ -18,7 +24,7 @@
|
||||
- Prototype of static application support.
|
||||
|
||||
# v0.0.10
|
||||
- Bufgix for async class-based views.
|
||||
- Bugfix for async class-based views.
|
||||
|
||||
# v0.0.9
|
||||
- Bugfix for async class-based views.
|
||||
@@ -39,7 +45,7 @@
|
||||
- Safe load/dump yaml.
|
||||
|
||||
# v0.0.4:
|
||||
- Asyncronous support for data uploads.
|
||||
- Asynchronous support for data uploads.
|
||||
- Bug fixes.
|
||||
|
||||
# v0.0.3:
|
||||
|
||||
@@ -86,7 +86,7 @@ class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return "Hello " + name
|
||||
return f"Hello {name}"
|
||||
|
||||
api.add_route("/graph", graphene.Schema(query=Query))
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
+18
-3
@@ -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
|
||||
@@ -43,7 +43,7 @@ Serve a GraphQL API::
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return "Hello " + name
|
||||
return f"Hello {name}"
|
||||
|
||||
api.add_route("/graph", graphene.Schema(query=Query))
|
||||
|
||||
@@ -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 @@
|
||||
__version__ = "0.1.5"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
+107
-39
@@ -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
|
||||
@@ -205,6 +215,7 @@ class API:
|
||||
if resp.session:
|
||||
data = self._signer.sign(json.dumps(resp.session).encode("utf-8"))
|
||||
resp.cookies[self.session_cookie] = data.decode("utf-8")
|
||||
|
||||
@staticmethod
|
||||
def no_response(req, resp, **params):
|
||||
pass
|
||||
@@ -216,25 +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:
|
||||
# Run the view.
|
||||
r = route.endpoint(req, resp, **params)
|
||||
# If it's async, await it.
|
||||
if hasattr(r, "cr_running"):
|
||||
await r
|
||||
except TypeError as e:
|
||||
cont = True
|
||||
try:
|
||||
# Run the view.
|
||||
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(error=True, **kwargs)
|
||||
|
||||
if route.is_class_based or cont:
|
||||
try:
|
||||
@@ -243,42 +274,70 @@ class API:
|
||||
view = route.endpoint
|
||||
|
||||
# Run on_request first.
|
||||
# Run the view.
|
||||
r = getattr(view, "on_request", self.no_response)(req, resp, **params)
|
||||
# If it's async, await it.
|
||||
if hasattr(r, "send"):
|
||||
await r
|
||||
try:
|
||||
# Run the view.
|
||||
r = getattr(view, "on_request", self.no_response)(
|
||||
**kwargs, **params
|
||||
)
|
||||
# If it's async, await it.
|
||||
if hasattr(r, "send"):
|
||||
await r
|
||||
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 the view.
|
||||
r = getattr(view, f"on_{method}", self.no_response)(req, resp, **params)
|
||||
# If it's async, await it.
|
||||
if hasattr(r, "send"):
|
||||
await r
|
||||
# Run on_request first.
|
||||
try:
|
||||
# Run the view.
|
||||
r = getattr(view, f"on_{method}", self.no_response)(
|
||||
**kwargs, **params
|
||||
)
|
||||
# If it's async, await it.
|
||||
if hasattr(r, "send"):
|
||||
await r
|
||||
except Exception as e:
|
||||
self.default_response(error=True, **kwargs)
|
||||
|
||||
else:
|
||||
self.default_response(req, resp)
|
||||
self.default_response(notfound=True, **kwargs)
|
||||
|
||||
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
|
||||
@@ -293,18 +352,25 @@ 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())
|
||||
)
|
||||
|
||||
def default_response(self, req, resp):
|
||||
def default_response(self, req, resp, notfound=False, error=False):
|
||||
if resp.status_code is None:
|
||||
resp.status_code = 200
|
||||
|
||||
if self.default_endpoint:
|
||||
self.default_endpoint(req, resp)
|
||||
else:
|
||||
resp.status_code = status_codes.HTTP_404
|
||||
resp.text = "Not found."
|
||||
if notfound:
|
||||
resp.status_code = status_codes.HTTP_404
|
||||
resp.text = "Not found."
|
||||
if error:
|
||||
resp.status_code = status_codes.HTTP_500
|
||||
resp.text = "Application error."
|
||||
|
||||
def static_response(self, req, resp):
|
||||
index = (self.static_dir / "index.html").resolve()
|
||||
@@ -465,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"
|
||||
@@ -484,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)
|
||||
|
||||
@@ -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")
|
||||
@@ -51,9 +51,8 @@ async def format_files(r, encode=False):
|
||||
if key == "filename":
|
||||
filename = value
|
||||
|
||||
content = part.text
|
||||
if filename:
|
||||
dump[filename] = content
|
||||
dump[filename] = part.content
|
||||
return dump
|
||||
|
||||
|
||||
|
||||
+64
-1
@@ -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
|
||||
|
||||
@@ -244,7 +245,7 @@ class Response:
|
||||
|
||||
def __init__(self, req, *, formats):
|
||||
self.req = req
|
||||
self.status_code = HTTP_200 #: The HTTP Status Code to use for the Response.
|
||||
self.status_code = None #: The HTTP Status Code to use for the Response.
|
||||
self.text = None #: A unicode representation of the response body.
|
||||
self.content = None #: A bytes representation of the response body.
|
||||
self.encoding = DEFAULT_ENCODING
|
||||
@@ -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)
|
||||
|
||||
+9
-7
@@ -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):
|
||||
@@ -33,7 +34,6 @@ class Route:
|
||||
|
||||
@property
|
||||
def endpoint_name(self):
|
||||
print(self.endpoint.__name__)
|
||||
return self.endpoint.__name__
|
||||
|
||||
@property
|
||||
@@ -45,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)
|
||||
@@ -66,8 +68,8 @@ class Route:
|
||||
|
||||
def _weight(self):
|
||||
params = set(self._param_pattern.findall(self.route))
|
||||
params_count = -len(params) or 0
|
||||
return params_count != 0, params_count
|
||||
params_count = len(params)
|
||||
return params_count != 0, -params_count
|
||||
|
||||
@property
|
||||
def is_graphql(self):
|
||||
|
||||
@@ -22,15 +22,3 @@ def test_bytes_encoding(api, session):
|
||||
|
||||
r = session.get(api.url_for(route), data=data)
|
||||
assert r.content == data
|
||||
|
||||
|
||||
def test_false_encoding_raises(api, session):
|
||||
data = "hi mom!"
|
||||
|
||||
@api.route("/")
|
||||
async def route(req, resp):
|
||||
req.encoding = "non-existient"
|
||||
resp.text = await req.text
|
||||
|
||||
with pytest.raises(LookupError):
|
||||
session.get(api.url_for(route), data=data)
|
||||
|
||||
+30
-3
@@ -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))
|
||||
@@ -416,9 +416,36 @@ def test_file_uploads(api, session):
|
||||
@api.route("/")
|
||||
async def upload(req, resp):
|
||||
|
||||
resp.media = {"files": await req.media("files")}
|
||||
files = await req.media("files")
|
||||
files["hello"] = files["hello"].decode("utf-8")
|
||||
resp.media = {"files": files}
|
||||
|
||||
world = io.StringIO("world")
|
||||
data = {"hello": world}
|
||||
r = session.get(api.url_for(upload), files=data)
|
||||
r = session.post(api.url_for(upload), files=data)
|
||||
assert r.json() == {"files": {"hello": "world"}}
|
||||
|
||||
|
||||
def test_500(api, session):
|
||||
@api.route("/")
|
||||
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user