From 0693a8f6c982eb01e6b1b41ac4e5b74f67140301 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 30 Aug 2018 13:53:37 +0100 Subject: [PATCH] Class based views (#52) * Renaming asgi_application, implementing CBV pattern, add_route method on router * Refactor view to allow both sync/async methods * Type hints for CBV * Implement asgi decorator method directly in view class, remove classmethod * Refactor CBV, remove router add_route method in favor of App.add_route method, tests, documentation * Include tests * Add support for class-based views --- docs/views.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + starlette/app.py | 17 +++++++++++++---- starlette/views.py | 22 ++++++++++++++++++++++ tests/test_views.py | 38 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 docs/views.md create mode 100644 starlette/views.py create mode 100644 tests/test_views.py diff --git a/docs/views.md b/docs/views.md new file mode 100644 index 00000000..f2628c8e --- /dev/null +++ b/docs/views.md @@ -0,0 +1,44 @@ + +Starlette includes a `View` class that provides a class-based view pattern which +handles HTTP method dispatching. + +The `View` class can be used as an other ASGI application: + +```python +from starlette.response import PlainTextResponse +from starlette.views import View + + +class App(View): + async def get(self, request): + return PlainTextResponse(f"Hello, world!") +``` + +If you're using a Starlette application instance to handle routing, you can +dispatch to a View class by using the `@app.route()` decorator, or the +`app.add_route()` function. Make sure to dispatch to the class itself, rather +than to an instance of the class: + +```python +from starlette.app import App +from starlette.response import PlainTextResponse +from starlette.views import View + + +app = App() + + +@app.route("/") +class Homepage(View): + async def get(self, request): + return PlainTextResponse(f"Hello, world!") + + +@app.route("/{username}") +class User(View): + async def get(self, request, username): + return PlainTextResponse(f"Hello, {username}") +``` + +Class-based views will respond with "406 Method not allowed" responses for any +request methods which do not map to a corresponding handler. diff --git a/mkdocs.yml b/mkdocs.yml index f80ae1a4..0b2c0dbd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,6 +18,7 @@ nav: - Applications: 'applications.md' - Test Client: 'test_client.md' - Debugging: 'debugging.md' + - Views: 'views.md' markdown_extensions: - markdown.extensions.codehilite: diff --git a/starlette/app.py b/starlette/app.py index f125e94f..31bf3494 100644 --- a/starlette/app.py +++ b/starlette/app.py @@ -3,6 +3,7 @@ from starlette.routing import Path, PathPrefix, Router from starlette.types import ASGIApp, ASGIInstance, Receive, Scope, Send from starlette.websockets import WebSocketSession import asyncio +import inspect def request_response(func): @@ -52,24 +53,32 @@ class App: self.router.routes.append(prefix) def add_route(self, path: str, route, methods=None) -> None: - if methods is None: - methods = ["GET"] - instance = Path(path, request_response(route), protocol="http", methods=methods) + if not inspect.isclass(route): + route = request_response(route) + if methods is None: + methods = ["GET"] + + instance = Path(path, route, protocol="http", methods=methods) self.router.routes.append(instance) def add_websocket_route(self, path: str, route) -> None: - instance = Path(path, websocket_session(route), protocol="websocket") + if not inspect.isclass(route): + route = websocket_session(route) + + instance = Path(path, route, protocol="websocket") self.router.routes.append(instance) def route(self, path: str): def decorator(func): self.add_route(path, func) + return func return decorator def websocket_route(self, path: str): def decorator(func): self.add_websocket_route(path, func) + return func return decorator diff --git a/starlette/views.py b/starlette/views.py new file mode 100644 index 00000000..7d6dff85 --- /dev/null +++ b/starlette/views.py @@ -0,0 +1,22 @@ +from starlette.request import Request +from starlette.response import Response, PlainTextResponse +from starlette.types import Receive, Send, Scope + + +class View: + def __init__(self, scope: Scope): + self.scope = scope + + async def __call__(self, receive: Receive, send: Send): + request = Request(self.scope, receive=receive) + kwargs = self.scope.get("kwargs", {}) + response = await self.dispatch(request, **kwargs) + await response(receive, send) + + async def dispatch(self, request: Request, **kwargs) -> Response: + handler_name = "get" if request.method == "HEAD" else request.method.lower() + handler = getattr(self, handler_name, self.method_not_allowed) + return await handler(request, **kwargs) + + async def method_not_allowed(self, request: Request, **kwargs) -> Response: + return PlainTextResponse("Method not allowed", 406) diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 00000000..c59e2992 --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,38 @@ +import pytest +from starlette import App +from starlette.views import View +from starlette.response import PlainTextResponse +from starlette.testclient import TestClient + + +app = App() + + +@app.route("/") +@app.route("/{username}") +class Homepage(View): + async def get(self, request, username=None): + if username is None: + return PlainTextResponse("Hello, world!") + return PlainTextResponse(f"Hello, {username}!") + + +client = TestClient(app) + + +def test_route(): + response = client.get("/") + assert response.status_code == 200 + assert response.text == "Hello, world!" + + +def test_route_kwargs(): + response = client.get("/tomchristie") + assert response.status_code == 200 + assert response.text == "Hello, tomchristie!" + + +def test_route_method(): + response = client.post("/") + assert response.status_code == 406 + assert response.text == "Method not allowed"