diff --git a/CHANGELOG.md b/CHANGELOG.md index 3232e8c..fff4991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# v0.2.0 +- WebSocket support. + # v0.1.6 - 500 support. diff --git a/Pipfile.lock b/Pipfile.lock index 1573d49..e6fd3b1 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -215,9 +215,9 @@ }, "starlette": { "hashes": [ - "sha256:ce5c684fad4edb2967cd491518cd3c2724e420508202c2d48f519ea68dcec9d6" + "sha256:eac0f6cab6b48846a0c1af16615430ae0e7a95f669ee0841a7e2f242d51d8935" ], - "version": "==0.5.4" + "version": "==0.5.5" }, "urllib3": { "hashes": [ @@ -228,9 +228,9 @@ }, "uvicorn": { "hashes": [ - "sha256:7c4550c7e6f7c8727fa5ccd5200baf62c9e055895e058933ee88f5d0c246ca0c" + "sha256:e2b742fdaa0b52f4aac92fd2c078e7f1f17d11322bb3efb09d341d5c6998b4b5" ], - "version": "==0.3.14" + "version": "==0.3.16" }, "websockets": { "hashes": [ @@ -591,11 +591,11 @@ }, "pytest": { "hashes": [ - "sha256:10e59f84267370ab20cec9305bafe7505ba4d6b93ecbf66a1cce86193ed511d5", - "sha256:8c827e7d4816dfe13e9329c8226aef8e6e75d65b939bc74fda894143b6d1df59" + "sha256:212be78a6fa5352c392738a49b18f74ae9aeec1040f47c81cadbfd8d1233c310", + "sha256:6f6c1efc8d0ccc21f8f6c34d8330baca883cf109b66b3df954b0a117e5528fb4" ], "index": "pypi", - "version": "==3.9.1" + "version": "==3.9.2" }, "pytest-cov": { "hashes": [ @@ -671,10 +671,10 @@ }, "tqdm": { "hashes": [ - "sha256:a0be569511161220ff709a5b60d0890d47921f746f1c737a11d965e1b29e7b2e", - "sha256:e293e6d7a7f41a529a27f8d6624ab11544ccbfe82a205af6fad102545099fc21" + "sha256:3c4d4a5a41ef162dd61f1edb86b0e1c7859054ab656b2e7c7b77e7fbf6d9f392", + "sha256:5b4d5549984503050883bc126280b386f5f4ca87e6c023c5d015655ad75bdebb" ], - "version": "==4.27.0" + "version": "==4.28.1" }, "twine": { "hashes": [ diff --git a/docs/source/index.rst b/docs/source/index.rst index 2ebba01..383b123 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -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. diff --git a/docs/source/tour.rst b/docs/source/tour.rst index 5791723..089908a 100644 --- a/docs/source/tour.rst +++ b/docs/source/tour.rst @@ -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 `_. 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) ------------------------ diff --git a/responder/__version__.py b/responder/__version__.py index 0a8da88..d3ec452 100644 --- a/responder/__version__.py +++ b/responder/__version__.py @@ -1 +1 @@ -__version__ = "0.1.6" +__version__ = "0.2.0" diff --git a/responder/api.py b/responder/api.py index c3fdd6b..1671a7e 100644 --- a/responder/api.py +++ b/responder/api.py @@ -9,8 +9,6 @@ import asyncio import jinja2 import itsdangerous from graphql_server import encode_execution_results, json_encode, default_format_error -from starlette.websockets import WebSocket -from starlette.debug import DebugMiddleware from starlette.routing import Router from starlette.staticfiles import StaticFiles from starlette.testclient import TestClient @@ -27,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: @@ -43,7 +42,6 @@ class API: def __init__( self, *, - debug=False, title=None, version=None, openapi=None, @@ -89,8 +87,6 @@ class API: self.default_endpoint = None self.app = self.dispatch self.add_middleware(GZipMiddleware) - if debug: - self.add_middleware(DebugMiddleware) if self.hsts_enabled: self.add_middleware(HTTPSRedirectMiddleware) @@ -104,6 +100,8 @@ class API: ) self.jinja_values_base = {"api": self} # Give reference to self. + self.requests = self.session() + @property def _apispec(self): spec = APISpec( @@ -153,15 +151,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, scope=scope, send=send, receive=receive - ) - 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: @@ -177,7 +182,7 @@ class API: from marshmallow import Schema, fields @api.schema("Pet") - class PetSchema(Schema): + class PetScrhema(Schema): name = fields.Str() """ @@ -188,17 +193,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,7 +222,7 @@ class API: def no_response(req, resp, **params): pass - async def _dispatch_request(self, req, **options): + async def _dispatch_request(self, req): # Set formats on Request object. req.formats = self.formats @@ -226,31 +231,43 @@ class API: route = self.routes.get(route) # 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 not route.uses_websocket: - resp = models.Response(req=req, formats=self.formats) + 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: - resp = WebSocket(**options) - - params = route.incoming_matches(req.url.path) + 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: @@ -262,40 +279,38 @@ 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: - resp = models.Response(req=req, formats=self.formats) - 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, @@ -303,20 +318,28 @@ class API: endpoint=None, *, default=False, + websocket=False, static=False, check_existing=True, - websocket=False, ): """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 @@ -331,7 +354,7 @@ class API: except AttributeError: pass - self.routes[route] = Route(route, endpoint, websocket=websocket) + 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()) @@ -464,13 +487,6 @@ class API: self._session = TestClient(self) return self._session - def _route_for(self, endpoint): - for (route, route_object) in self.routes.items(): - if route_object.endpoint == endpoint: - return route_object - elif route_object.endpoint_name == endpoint: - return route_object - def url_for(self, endpoint, testing=False, **params): # TODO: Absolute_url """Given an endpoint, returns a rendered URL for its route. @@ -478,9 +494,11 @@ class API: :param view: The route endpoint you're searching for. :param params: Data to pass into the URL generator (for parameterized URLs). """ - route_object = self._route_for(endpoint) - if route_object: - return route_object.url(testing=testing, **params) + for (route, route_object) in self.routes.items(): + if route_object.endpoint == endpoint: + return route_object.url(testing=testing, **params) + elif route_object.endpoint_name == endpoint: + return route_object.url(testing=testing, **params) raise ValueError def static_url(self, asset): diff --git a/responder/models.py b/responder/models.py index ebce351..a8e6ff0 100644 --- a/responder/models.py +++ b/responder/models.py @@ -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)