Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a router based on werkzeug.routing. #1589

Merged
merged 1 commit into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@
assert PythonDomain.object_types["data"].roles == ("data", "obj")
PythonDomain.object_types["data"].roles = ("data", "class", "obj")

intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"werkzeug": ("https://werkzeug.palletsprojects.com/en/stable/", None),
}

spelling_show_suggestions = True

Expand Down
2 changes: 1 addition & 1 deletion docs/faq/client.rst
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ The connection is closed when exiting the context manager.
How do I reconnect when the connection drops?
---------------------------------------------

Use :func:`~websockets.asyncio.client.connect` as an asynchronous iterator::
Use :func:`connect` as an asynchronous iterator::

from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed
Expand Down
4 changes: 3 additions & 1 deletion docs/faq/server.rst
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Record all connections in a global variable::
finally:
CONNECTIONS.remove(websocket)

Then, call :func:`~websockets.asyncio.server.broadcast`::
Then, call :func:`broadcast`::

from websockets.asyncio.server import broadcast

Expand Down Expand Up @@ -219,6 +219,8 @@ You may route a connection to different handlers depending on the request path::
# No handler for this path; close the connection.
return

For more complex routing, you may use :func:`~websockets.asyncio.router.route`.

You may also route the connection based on the first message received from the
client, as shown in the :doc:`tutorial <../intro/tutorial2>`. When you want to
authenticate the connection before routing it, this is usually more convenient.
Expand Down
6 changes: 6 additions & 0 deletions docs/project/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ Backwards-incompatible changes

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

New features
............

* Added :func:`~asyncio.router.route` and :func:`~asyncio.router.unix_route` to
dispatch connections to different handlers depending on the URL.

Improvements
............

Expand Down
19 changes: 17 additions & 2 deletions docs/reference/asyncio/server.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ Creating a server
.. autofunction:: unix_serve
:async:

Routing connections
-------------------

.. automodule:: websockets.asyncio.router

.. autofunction:: route
:async:

.. autofunction:: unix_route
:async:

.. autoclass:: Router

.. currentmodule:: websockets.asyncio.server

Running a server
----------------

Expand Down Expand Up @@ -89,12 +104,12 @@ Using a connection
Broadcast
---------

.. autofunction:: websockets.asyncio.server.broadcast
.. autofunction:: broadcast

HTTP Basic Authentication
-------------------------

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

.. autofunction:: websockets.asyncio.server.basic_auth
.. autofunction:: basic_auth
2 changes: 2 additions & 0 deletions docs/reference/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ Server
+------------------------------------+--------+--------+--------+--------+
| Perform HTTP Digest Authentication | ❌ | ❌ | ❌ | ❌ |
+------------------------------------+--------+--------+--------+--------+
| Dispatch connections to handlers | ✅ | ✅ | — | ❌ |
+------------------------------------+--------+--------+--------+--------+

Client
------
Expand Down
27 changes: 26 additions & 1 deletion docs/reference/sync/server.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@ Creating a server

.. autofunction:: unix_serve

Routing connections
-------------------

.. automodule:: websockets.sync.router

.. autofunction:: route

.. autofunction:: unix_route

.. autoclass:: Router

.. currentmodule:: websockets.sync.server

Routing connections
-------------------

.. autofunction:: route
:async:

.. autofunction:: unix_route
:async:

.. autoclass:: Server


Running a server
----------------

Expand Down Expand Up @@ -78,4 +103,4 @@ HTTP Basic Authentication
websockets supports HTTP Basic Authentication according to
:rfc:`7235` and :rfc:`7617`.

.. autofunction:: websockets.sync.server.basic_auth
.. autofunction:: basic_auth
1 change: 1 addition & 0 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ sphinx-inline-tabs
sphinxcontrib-spelling
sphinxcontrib-trio
sphinxext-opengraph
werkzeug
9 changes: 9 additions & 0 deletions src/websockets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
"connect",
"unix_connect",
"ClientConnection",
# .asyncio.router
"route",
"unix_route",
"Router",
# .asyncio.server
"basic_auth",
"broadcast",
Expand Down Expand Up @@ -79,6 +83,7 @@
# When type checking, import non-deprecated aliases eagerly. Else, import on demand.
if TYPE_CHECKING:
from .asyncio.client import ClientConnection, connect, unix_connect
from .asyncio.router import Router, route, unix_route
from .asyncio.server import (
Server,
ServerConnection,
Expand Down Expand Up @@ -138,6 +143,10 @@
"connect": ".asyncio.client",
"unix_connect": ".asyncio.client",
"ClientConnection": ".asyncio.client",
# .asyncio.router
"route": ".asyncio.router",
"unix_route": ".asyncio.router",
"Router": ".asyncio.router",
# .asyncio.server
"basic_auth": ".asyncio.server",
"broadcast": ".asyncio.server",
Expand Down
196 changes: 196 additions & 0 deletions src/websockets/asyncio/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
from __future__ import annotations

import http
import ssl as ssl_module
import urllib.parse
from typing import Any, Awaitable, Callable, Literal

from werkzeug.exceptions import NotFound
from werkzeug.routing import Map, RequestRedirect

from ..http11 import Request, Response
from .server import Server, ServerConnection, serve


__all__ = ["route", "unix_route", "Router"]


class Router:
"""WebSocket router supporting :func:`route`."""

def __init__(
self,
url_map: Map,
server_name: str | None = None,
url_scheme: str = "ws",
) -> None:
self.url_map = url_map
self.server_name = server_name
self.url_scheme = url_scheme
for rule in self.url_map.iter_rules():
rule.websocket = True

def get_server_name(self, connection: ServerConnection, request: Request) -> str:
if self.server_name is None:
return request.headers["Host"]
else:
return self.server_name

def redirect(self, connection: ServerConnection, url: str) -> Response:
response = connection.respond(http.HTTPStatus.FOUND, f"Found at {url}")
response.headers["Location"] = url
return response

def not_found(self, connection: ServerConnection) -> Response:
return connection.respond(http.HTTPStatus.NOT_FOUND, "Not Found")

def route_request(
self, connection: ServerConnection, request: Request
) -> Response | None:
"""Route incoming request."""
url_map_adapter = self.url_map.bind(
server_name=self.get_server_name(connection, request),
url_scheme=self.url_scheme,
)
try:
parsed = urllib.parse.urlparse(request.path)
handler, kwargs = url_map_adapter.match(
path_info=parsed.path,
query_args=parsed.query,
)
except RequestRedirect as redirect:
return self.redirect(connection, redirect.new_url)
except NotFound:
return self.not_found(connection)
connection.handler, connection.handler_kwargs = handler, kwargs
return None

async def handler(self, connection: ServerConnection) -> None:
"""Handle a connection."""
return await connection.handler(connection, **connection.handler_kwargs)


def route(
url_map: Map,
*args: Any,
server_name: str | None = None,
ssl: ssl_module.SSLContext | Literal[True] | None = None,
create_router: type[Router] | None = None,
**kwargs: Any,
) -> Awaitable[Server]:
"""
Create a WebSocket server dispatching connections to different handlers.

This feature requires the third-party library `werkzeug`_::

$ pip install werkzeug

.. _werkzeug: https://werkzeug.palletsprojects.com/

:func:`route` accepts the same arguments as
:func:`~websockets.sync.server.serve`, except as described below.

The first argument is a :class:`werkzeug.routing.Map` that maps URL patterns
to connection handlers. In addition to the connection, handlers receive
parameters captured in the URL as keyword arguments.

Here's an example::


from websockets.asyncio.router import route
from werkzeug.routing import Map, Rule

async def channel_handler(websocket, channel_id):
...

url_map = Map([
Rule("/channel/<uuid:channel_id>", endpoint=channel_handler),
...
])

# set this future to exit the server
stop = asyncio.get_running_loop().create_future()

async with route(url_map, ...) as server:
await stop


Refer to the documentation of :mod:`werkzeug.routing` for details.

If you define redirects with ``Rule(..., redirect_to=...)`` in the URL map,
when the server runs behind a reverse proxy that modifies the ``Host``
header or terminates TLS, you need additional configuration:

* Set ``server_name`` to the name of the server as seen by clients. When not
provided, websockets uses the value of the ``Host`` header.

* Set ``ssl=True`` to generate ``wss://`` URIs without actually enabling
TLS. Under the hood, this bind the URL map with a ``url_scheme`` of
``wss://`` instead of ``ws://``.

There is no need to specify ``websocket=True`` in each rule. It is added
automatically.

Args:
url_map: Mapping of URL patterns to connection handlers.
server_name: Name of the server as seen by clients. If :obj:`None`,
websockets uses the value of the ``Host`` header.
ssl: Configuration for enabling TLS on the connection. Set it to
:obj:`True` if a reverse proxy terminates TLS connections.
create_router: Factory for the :class:`Router` dispatching requests to
handlers. Set it to a wrapper or a subclass to customize routing.

"""
url_scheme = "ws" if ssl is None else "wss"
if ssl is not True and ssl is not None:
kwargs["ssl"] = ssl

if create_router is None:
create_router = Router

router = create_router(url_map, server_name, url_scheme)

_process_request: (
Callable[
[ServerConnection, Request],
Awaitable[Response | None] | Response | None,
]
| None
) = kwargs.pop("process_request", None)
if _process_request is None:
process_request: Callable[
[ServerConnection, Request],
Awaitable[Response | None] | Response | None,
] = router.route_request
else:

async def process_request(
connection: ServerConnection, request: Request
) -> Response | None:
response = _process_request(connection, request)
if isinstance(response, Awaitable):
response = await response
if response is not None:
return response
return router.route_request(connection, request)

return serve(router.handler, *args, process_request=process_request, **kwargs)


def unix_route(
url_map: Map,
path: str | None = None,
**kwargs: Any,
) -> Awaitable[Server]:
"""
Create a WebSocket Unix server dispatching connections to different handlers.

:func:`unix_route` combines the behaviors of :func:`route` and
:func:`~websockets.asyncio.server.unix_serve`.

Args:
url_map: Mapping of URL patterns to connection handlers.
path: File system path to the Unix socket.

"""
return route(url_map, unix=True, path=path, **kwargs)
4 changes: 3 additions & 1 deletion src/websockets/asyncio/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import sys
from collections.abc import Awaitable, Generator, Iterable, Sequence
from types import TracebackType
from typing import Any, Callable, cast
from typing import Any, Callable, Mapping, cast

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

def respond(self, status: StatusLike, text: str) -> Response:
"""
Expand Down
Loading