Skip to content

Commit e67a235

Browse files
committed
Add a router based on werkzeug.routing.
Fix #311.
1 parent 7a2f8f4 commit e67a235

17 files changed

+865
-16
lines changed

docs/conf.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@
8282
assert PythonDomain.object_types["data"].roles == ("data", "obj")
8383
PythonDomain.object_types["data"].roles = ("data", "class", "obj")
8484

85-
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
85+
intersphinx_mapping = {
86+
"python": ("https://docs.python.org/3", None),
87+
"werkzeug": ("https://werkzeug.palletsprojects.com/en/stable/", None),
88+
}
8689

8790
spelling_show_suggestions = True
8891

docs/project/changelog.rst

+6
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ Backwards-incompatible changes
5656

5757
See :doc:`keepalive and latency <../topics/keepalive>` for details.
5858

59+
New features
60+
............
61+
62+
* Added :func:`~asyncio.router.route` to dispatch connections to different
63+
handlers depending on the URL.
64+
5965
Improvements
6066
............
6167

docs/reference/asyncio/server.rst

+17-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@ Creating a server
1212
.. autofunction:: unix_serve
1313
:async:
1414

15+
Routing connections
16+
-------------------
17+
18+
.. automodule:: websockets.asyncio.router
19+
20+
.. autofunction:: route
21+
:async:
22+
23+
.. autofunction:: unix_route
24+
:async:
25+
26+
.. autoclass:: Router
27+
28+
.. currentmodule:: websockets.asyncio.server
29+
1530
Running a server
1631
----------------
1732

@@ -89,12 +104,12 @@ Using a connection
89104
Broadcast
90105
---------
91106

92-
.. autofunction:: websockets.asyncio.server.broadcast
107+
.. autofunction:: broadcast
93108

94109
HTTP Basic Authentication
95110
-------------------------
96111

97112
websockets supports HTTP Basic Authentication according to
98113
:rfc:`7235` and :rfc:`7617`.
99114

100-
.. autofunction:: websockets.asyncio.server.basic_auth
115+
.. autofunction:: basic_auth

docs/reference/features.rst

+2
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ Server
127127
+------------------------------------+--------+--------+--------+--------+
128128
| Perform HTTP Digest Authentication |||||
129129
+------------------------------------+--------+--------+--------+--------+
130+
| Dispatch connections to handlers |||||
131+
+------------------------------------+--------+--------+--------+--------+
130132

131133
Client
132134
------

docs/reference/sync/server.rst

+28-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,33 @@ Creating a server
1010

1111
.. autofunction:: unix_serve
1212

13+
Routing connections
14+
-------------------
15+
16+
.. automodule:: websockets.sync.router
17+
18+
.. autofunction:: route
19+
:async:
20+
21+
.. autofunction:: unix_route
22+
:async:
23+
24+
.. autoclass:: Router
25+
26+
.. currentmodule:: websockets.sync.server
27+
28+
Routing connections
29+
-------------------
30+
31+
.. autofunction:: route
32+
:async:
33+
34+
.. autofunction:: unix_route
35+
:async:
36+
37+
.. autoclass:: Server
38+
39+
1340
Running a server
1441
----------------
1542

@@ -78,4 +105,4 @@ HTTP Basic Authentication
78105
websockets supports HTTP Basic Authentication according to
79106
:rfc:`7235` and :rfc:`7617`.
80107

81-
.. autofunction:: websockets.sync.server.basic_auth
108+
.. autofunction:: basic_auth

docs/requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ sphinx-inline-tabs
66
sphinxcontrib-spelling
77
sphinxcontrib-trio
88
sphinxext-opengraph
9+
werkzeug

src/websockets/__init__.py

+9
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
"connect",
1313
"unix_connect",
1414
"ClientConnection",
15+
# .asyncio.router
16+
"route",
17+
"unix_route",
18+
"Router",
1519
# .asyncio.server
1620
"basic_auth",
1721
"broadcast",
@@ -79,6 +83,7 @@
7983
# When type checking, import non-deprecated aliases eagerly. Else, import on demand.
8084
if TYPE_CHECKING:
8185
from .asyncio.client import ClientConnection, connect, unix_connect
86+
from .asyncio.router import Router, route, unix_route
8287
from .asyncio.server import (
8388
Server,
8489
ServerConnection,
@@ -138,6 +143,10 @@
138143
"connect": ".asyncio.client",
139144
"unix_connect": ".asyncio.client",
140145
"ClientConnection": ".asyncio.client",
146+
# .asyncio.router
147+
"route": ".asyncio.router",
148+
"unix_route": ".asyncio.router",
149+
"Router": ".asyncio.router",
141150
# .asyncio.server
142151
"basic_auth": ".asyncio.server",
143152
"broadcast": ".asyncio.server",

src/websockets/asyncio/router.py

+186
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
from __future__ import annotations
2+
3+
import http
4+
import ssl as ssl_module
5+
import urllib.parse
6+
from typing import Any, Awaitable, Callable, Literal
7+
8+
from werkzeug.exceptions import NotFound
9+
from werkzeug.routing import Map, RequestRedirect
10+
11+
from ..http11 import Request, Response
12+
from .server import Server, ServerConnection, serve
13+
14+
15+
__all__ = ["route", "unix_route", "Router"]
16+
17+
18+
class Router:
19+
"""WebSocket router supporting :func:`route`."""
20+
21+
def __init__(
22+
self,
23+
url_map: Map,
24+
server_name: str | None = None,
25+
url_scheme: str = "ws",
26+
) -> None:
27+
self.url_map = url_map
28+
self.server_name = server_name
29+
self.url_scheme = url_scheme
30+
for rule in self.url_map.iter_rules():
31+
rule.websocket = True
32+
33+
def get_server_name(self, connection: ServerConnection, request: Request) -> str:
34+
if self.server_name is None:
35+
return request.headers["Host"]
36+
else:
37+
return self.server_name
38+
39+
def redirect(self, connection: ServerConnection, url: str) -> Response:
40+
response = connection.respond(http.HTTPStatus.FOUND, f"Found at {url}")
41+
response.headers["Location"] = url
42+
return response
43+
44+
def not_found(self, connection: ServerConnection) -> Response:
45+
return connection.respond(http.HTTPStatus.NOT_FOUND, "Not Found")
46+
47+
def route_request(
48+
self, connection: ServerConnection, request: Request
49+
) -> Response | None:
50+
"""Route incoming request."""
51+
url_map_adapter = self.url_map.bind(
52+
server_name=self.get_server_name(connection, request),
53+
url_scheme=self.url_scheme,
54+
)
55+
try:
56+
parsed = urllib.parse.urlparse(request.path)
57+
handler, kwargs = url_map_adapter.match(
58+
path_info=parsed.path,
59+
query_args=parsed.query,
60+
)
61+
except RequestRedirect as redirect:
62+
return self.redirect(connection, redirect.new_url)
63+
except NotFound:
64+
return self.not_found(connection)
65+
connection.handler, connection.handler_kwargs = handler, kwargs
66+
return None
67+
68+
async def handler(self, connection: ServerConnection) -> None:
69+
"""Handle a connection."""
70+
return await connection.handler(connection, **connection.handler_kwargs)
71+
72+
73+
def route(
74+
url_map: Map,
75+
*args: Any,
76+
server_name: str | None = None,
77+
ssl: ssl_module.SSLContext | Literal[True] | None = None,
78+
create_router: type[Router] | None = None,
79+
**kwargs: Any,
80+
) -> Awaitable[Server]:
81+
"""
82+
Create a WebSocket server with several handlers.
83+
84+
Except for the differences described below, this function accepts the same
85+
arguments as :func:`~websockets.sync.server.serve`.
86+
87+
The first argument is a :class:`werkzeug.routing.Map` mapping URL patterns
88+
to connection handlers, instead of a single connection handler::
89+
90+
from websockets.sync.router import route
91+
from werkzeug.routing import Map, Rule
92+
93+
url_map = Map([
94+
Rule("/", endpoint=default_handler),
95+
...
96+
])
97+
98+
with router(url_map, ...) as server:
99+
server.serve_forever()
100+
101+
Handlers are called with the connection and any keyword arguments captured
102+
in the URL.
103+
104+
There is no need to specify ``websocket=True`` in ``url_map``. It is added
105+
to each rule automatically.
106+
107+
This feature requires the third-party library `werkzeug`_::
108+
109+
$ pip install werkzeug
110+
111+
.. _werkzeug: https://werkzeug.palletsprojects.com/
112+
113+
If you define redirects with ``Rule(..., redirect_to=...)`` in the URL map
114+
and the server runs behind a reverse proxy that modifies the ``Host`` header
115+
or terminates TLS, you need the following configuration:
116+
117+
* Set ``server_name`` to the name of the server as seen by clients. When
118+
not provided, websockets uses the value of the ``Host`` header.
119+
120+
* Set ``ssl=True`` to generate ``wss://`` URIs without actually enabling
121+
TLS. Under the hood, this bind the URL map with a ``url_scheme`` of
122+
``wss://`` instead of ``ws://``.
123+
124+
Args:
125+
url_map: Mapping of URL patterns to connection handlers.
126+
server_name: Name of the server as seen by clients. If :obj:`None`,
127+
websockets uses the value of the ``Host`` header.
128+
ssl: Configuration for enabling TLS on the connection. Set it to
129+
:obj:`True` if a reverse proxy terminates TLS connections.
130+
create_router: Factory for the :class:`Router` dispatching requests to
131+
handlers. Set it to a wrapper or a subclass to customize routing.
132+
133+
"""
134+
url_scheme = "ws" if ssl is None else "wss"
135+
if ssl is not True and ssl is not None:
136+
kwargs["ssl"] = ssl
137+
138+
if create_router is None:
139+
create_router = Router
140+
141+
router = create_router(url_map, server_name, url_scheme)
142+
143+
_process_request: (
144+
Callable[
145+
[ServerConnection, Request],
146+
Awaitable[Response | None] | Response | None,
147+
]
148+
| None
149+
) = kwargs.pop("process_request", None)
150+
if _process_request is None:
151+
process_request: Callable[
152+
[ServerConnection, Request],
153+
Awaitable[Response | None] | Response | None,
154+
] = router.route_request
155+
else:
156+
157+
async def process_request(
158+
connection: ServerConnection, request: Request
159+
) -> Response | None:
160+
response = _process_request(connection, request)
161+
if isinstance(response, Awaitable):
162+
response = await response
163+
if response is not None:
164+
return response
165+
return router.route_request(connection, request)
166+
167+
return serve(router.handler, *args, process_request=process_request, **kwargs)
168+
169+
170+
def unix_route(
171+
url_map: Map,
172+
path: str | None = None,
173+
**kwargs: Any,
174+
) -> Awaitable[Server]:
175+
"""
176+
Create a WebSocket Unix server with several handlers.
177+
178+
This function combines behaviors of :func:`~websockets.sync.router.route`
179+
and :func:`~websockets.sync.server.unix_serve`.
180+
181+
Args:
182+
url_map: Mapping of URL patterns to connection handlers.
183+
path: File system path to the Unix socket.
184+
185+
"""
186+
return route(url_map, unix=True, path=path, **kwargs)

src/websockets/asyncio/server.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import sys
1010
from collections.abc import Awaitable, Generator, Iterable, Sequence
1111
from types import TracebackType
12-
from typing import Any, Callable, cast
12+
from typing import Any, Callable, Mapping, cast
1313

1414
from ..exceptions import InvalidHeader
1515
from ..extensions.base import ServerExtensionFactory
@@ -87,6 +87,8 @@ def __init__(
8787
self.server = server
8888
self.request_rcvd: asyncio.Future[None] = self.loop.create_future()
8989
self.username: str # see basic_auth()
90+
self.handler: Callable[[ServerConnection], Awaitable[None]] # see route()
91+
self.handler_kwargs: Mapping[str, Any] # see route()
9092

9193
def respond(self, status: StatusLike, text: str) -> Response:
9294
"""

0 commit comments

Comments
 (0)