Skip to content

Commit

Permalink
Merge pull request #5 from encode/routing
Browse files Browse the repository at this point in the history
Add Router, Path, PathPrefix
  • Loading branch information
tomchristie authored Jun 26, 2018
2 parents 5ab86e1 + b5a8d61 commit 4c621d5
Show file tree
Hide file tree
Showing 8 changed files with 220 additions and 16 deletions.
52 changes: 47 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<p align="center">
<h1 align="center">Starlette</h1>
</p>
<h1 align="center">
Starlette
</h1>
<p align="center">
<em>✨ The little ASGI library that shines. ✨</em>
</p>
Expand All @@ -20,7 +20,7 @@

Starlette is a small library for working with [ASGI](https://asgi.readthedocs.io/en/latest/).

It gives you `Request` and `Response` classes, a test client, and a
It gives you `Request` and `Response` classes, routing, a test client, and a
decorator for writing super-minimal applications.

**Requirements:**
Expand Down Expand Up @@ -221,6 +221,48 @@ raise an error.

---

## Routing

Starlette includes a `Router` class which is an ASGI application that
dispatches to other ASGI applications.

```python
from starlette import Router, Path, PathPrefix
from myproject import Homepage, StaticFiles


app = Router([
Path('/', app=Homepage, methods=['GET']),
PathPrefix('/static', app=StaticFiles, methods=['GET'])
])
```

Paths can use URI templating style to capture path components.

```python
Path('/users/{username}', app=User, methods=['GET'])
```

Path components are made available in the scope, as `scope["kwargs"]`.

Because each target of the router is an ASGI instance itself, routers
allow for easy composition. For example:

```python
app = Router([
Path('/', app=Homepage, methods=['GET']),
PathPrefix('/users', app=Router([
Path('/', app=Users, methods=['GET', 'POST']),
Path('/{username}', app=User, methods=['GET']),
]))
])
```

The router will respond with "404 Not found" or "406 Method not allowed"
responses for requests which do not match.

---

## Test Client

The test client allows you to make requests against your ASGI application,
Expand Down Expand Up @@ -264,4 +306,4 @@ async def app(request):

---

<p align="center"><i>API Star is <a href="https://github.com/tomchristie/apistar/blob/master/LICENSE.md">BSD licensed</a> code.<br/>Designed & built in Brighton, England.</i><br/>&mdash; ⭐️ &mdash;</p>
<p align="center"><i>Starlette is <a href="https://github.com/tomchristie/starlette/blob/master/LICENSE.md">BSD licensed</a> code.<br/>Designed & built in Brighton, England.</i><br/>&mdash; ⭐️ &mdash;</p>
6 changes: 5 additions & 1 deletion starlette/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
from starlette.decorators import asgi_application
from starlette.response import HTMLResponse, JSONResponse, Response, StreamingResponse
from starlette.request import Request
from starlette.routing import Path, PathPrefix, Router
from starlette.testclient import TestClient


__all__ = (
"asgi_application",
"HTMLResponse",
"JSONResponse",
"Path",
"PathPrefix",
"Response",
"Router",
"StreamingResponse",
"Request",
"TestClient",
)
__version__ = "0.1.2"
__version__ = "0.1.3"
9 changes: 5 additions & 4 deletions starlette/datastructures.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ def __init__(
value = parse_qsl(value)

if hasattr(value, "items"):
items = list(value.items())
items = list(typing.cast(StrDict, value).items())
else:
items = list(value)
items = list(typing.cast(StrPairs, value))
self._dict = {k: v for k, v in reversed(items)}
self._list = items

Expand Down Expand Up @@ -123,9 +123,10 @@ def __init__(self, value: typing.Union[StrDict, StrPairs] = None) -> None:
if value is None:
value = []
if hasattr(value, "items"):
items = [(k.lower(), str(v)) for k, v in list(value.items())]
items = list(typing.cast(StrDict, value).items())
else:
items = [(k.lower(), str(v)) for k, v in list(value)]
items = list(typing.cast(StrPairs, value))
items = [(k.lower(), str(v)) for k, v in items]
self._dict = {k: v for k, v in reversed(items)}
self._list = items

Expand Down
6 changes: 3 additions & 3 deletions starlette/decorators.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from starlette.request import Request
from starlette.response import Response
from starlette.types import ASGIInstance, Receive, Send, Scope


def asgi_application(func):
def app(scope):
async def awaitable(receive, send):
def app(scope: Scope) -> ASGIInstance:
async def awaitable(receive: Receive, send: Send) -> None:
request = Request(scope, receive)
response = func(request)
assert isinstance(response, Response)
await response(receive, send)

return awaitable
Expand Down
7 changes: 4 additions & 3 deletions starlette/response.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from starlette.datastructures import MutableHeaders
from starlette.types import Receive, Send
import json
import typing

Expand All @@ -25,7 +26,7 @@ def __init__(
self.set_content_type()
self.set_content_length()

async def __call__(self, receive, send):
async def __call__(self, receive: Receive, send: Send) -> None:
await send(
{
"type": "http.response.start",
Expand Down Expand Up @@ -65,7 +66,7 @@ class JSONResponse(Response):
"allow_nan": False,
"indent": None,
"separators": (",", ":"),
}
} # type: typing.Dict[str, typing.Any]

def render(self, content: typing.Any) -> bytes:
return json.dumps(content, **self.options).encode("utf-8")
Expand All @@ -86,7 +87,7 @@ def __init__(
self.media_type = media_type
self.set_content_type()

async def __call__(self, receive, send):
async def __call__(self, receive: Receive, send: Send) -> None:
await send(
{
"type": "http.response.start",
Expand Down
84 changes: 84 additions & 0 deletions starlette/routing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from starlette import Response
from starlette.types import Scope, ASGIApp, ASGIInstance
import re
import typing


class Route:
def matches(self, scope: Scope) -> typing.Tuple[bool, Scope]:
raise NotImplementedError() # pragma: no cover

def __call__(self, scope: Scope) -> ASGIInstance:
raise NotImplementedError() # pragma: no cover


class Path(Route):
def __init__(
self, path: str, app: ASGIApp, methods: typing.Sequence[str] = ()
) -> None:
self.path = path
self.app = app
self.methods = methods
regex = "^" + path + "$"
regex = re.sub("{([a-zA-Z_][a-zA-Z0-9_]*)}", r"(?P<\1>[^/]+)", regex)
self.path_regex = re.compile(regex)

def matches(self, scope: Scope) -> typing.Tuple[bool, Scope]:
match = self.path_regex.match(scope["path"])
if match:
kwargs = dict(scope.get("kwargs", {}))
kwargs.update(match.groupdict())
child_scope = dict(scope)
child_scope["kwargs"] = kwargs
return True, child_scope
return False, {}

def __call__(self, scope: Scope) -> ASGIInstance:
if self.methods and scope["method"] not in self.methods:
return Response("Method not allowed", 406, media_type="text/plain")
return self.app(scope)


class PathPrefix(Route):
def __init__(
self, path: str, app: ASGIApp, methods: typing.Sequence[str] = ()
) -> None:
self.path = path
self.app = app
self.methods = methods
regex = "^" + path
regex = re.sub("{([a-zA-Z_][a-zA-Z0-9_]*)}", r"(?P<\1>[^/]*)", regex)
self.path_regex = re.compile(regex)

def matches(self, scope: Scope) -> typing.Tuple[bool, Scope]:
match = self.path_regex.match(scope["path"])
if match:
kwargs = dict(scope.get("kwargs", {}))
kwargs.update(match.groupdict())
child_scope = dict(scope)
child_scope["kwargs"] = kwargs
child_scope["root_path"] = scope.get("root_path", "") + match.string
child_scope["path"] = scope["path"][match.span()[1] :]
return True, child_scope
return False, {}

def __call__(self, scope: Scope) -> ASGIInstance:
if self.methods and scope["method"] not in self.methods:
return Response("Method not allowed", 406, media_type="text/plain")
return self.app(scope)


class Router:
def __init__(self, routes: typing.List[Route], default: ASGIApp = None) -> None:
self.routes = routes
self.default = self.not_found if default is None else default

def __call__(self, scope: Scope) -> ASGIInstance:
for route in self.routes:
matched, child_scope = route.matches(scope)
if matched:
return route(child_scope)
return self.not_found(scope)

def not_found(self, scope: Scope) -> ASGIInstance:
return Response("Not found", 404, media_type="text/plain")
11 changes: 11 additions & 0 deletions starlette/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import typing


Scope = typing.Mapping[str, typing.Any]
Message = typing.Mapping[str, typing.Any]

Receive = typing.Callable[[], typing.Awaitable[Message]]
Send = typing.Callable[[Message], typing.Awaitable[None]]

ASGIInstance = typing.Callable[[Receive, Send], typing.Awaitable[None]]
ASGIApp = typing.Callable[[Scope], ASGIInstance]
61 changes: 61 additions & 0 deletions tests/test_routing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from starlette import Response, Path, PathPrefix, Router, TestClient


def homepage(scope):
return Response("Hello, world", media_type="text/plain")


def users(scope):
return Response("All users", media_type="text/plain")


def user(scope):
content = "User " + scope["kwargs"]["username"]
return Response(content, media_type="text/plain")


def staticfiles(scope):
return Response("xxxxx", media_type="image/png")


app = Router(
[
Path("/", app=homepage, methods=["GET"]),
PathPrefix(
"/users", app=Router([Path("", app=users), Path("/{username}", app=user)])
),
PathPrefix("/static", app=staticfiles, methods=["GET"]),
]
)


def test_router():
client = TestClient(app)

response = client.get("/")
assert response.status_code == 200
assert response.text == "Hello, world"

response = client.post("/")
assert response.status_code == 406
assert response.text == "Method not allowed"

response = client.get("/foo")
assert response.status_code == 404
assert response.text == "Not found"

response = client.get("/users")
assert response.status_code == 200
assert response.text == "All users"

response = client.get("/users/tomchristie")
assert response.status_code == 200
assert response.text == "User tomchristie"

response = client.get("/static/123")
assert response.status_code == 200
assert response.text == "xxxxx"

response = client.post("/static/123")
assert response.status_code == 406
assert response.text == "Method not allowed"

0 comments on commit 4c621d5

Please sign in to comment.