router.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. from __future__ import annotations
  2. import http
  3. import ssl as ssl_module
  4. import urllib.parse
  5. from typing import Any, Awaitable, Callable, Literal
  6. import trio
  7. from ..http11 import Request, Response
  8. from .server import Server, ServerConnection, serve
  9. __all__ = ["route", "Router"]
  10. try:
  11. from werkzeug.exceptions import NotFound
  12. from werkzeug.routing import Map, RequestRedirect
  13. except ImportError:
  14. async def route(
  15. url_map: Map,
  16. *args: Any,
  17. server_name: str | None = None,
  18. ssl: ssl_module.SSLContext | Literal[True] | None = None,
  19. create_router: type[Router] | None = None,
  20. task_status: trio.TaskStatus[Server] = trio.TASK_STATUS_IGNORED,
  21. **kwargs: Any,
  22. ) -> None:
  23. raise ImportError("route() requires werkzeug")
  24. else:
  25. async def route(
  26. url_map: Map,
  27. *args: Any,
  28. server_name: str | None = None,
  29. ssl: ssl_module.SSLContext | Literal[True] | None = None,
  30. create_router: type[Router] | None = None,
  31. task_status: trio.TaskStatus[Server] = trio.TASK_STATUS_IGNORED,
  32. **kwargs: Any,
  33. ) -> None:
  34. """
  35. Create a WebSocket server dispatching connections to different handlers.
  36. This feature requires the third-party library `werkzeug`_:
  37. .. code-block:: console
  38. $ pip install werkzeug
  39. .. _werkzeug: https://werkzeug.palletsprojects.com/
  40. :func:`route` accepts the same arguments as
  41. :func:`~websockets.trio.server.serve`, except as described below.
  42. The first argument is a :class:`werkzeug.routing.Map` that maps URL patterns
  43. to connection handlers. In addition to the connection, handlers receive
  44. parameters captured in the URL as keyword arguments.
  45. Here's an example::
  46. from websockets.trio.router import route
  47. from werkzeug.routing import Map, Rule
  48. async def channel_handler(websocket, channel_id):
  49. ...
  50. url_map = Map([
  51. Rule("/channel/<uuid:channel_id>", endpoint=channel_handler),
  52. ...
  53. ])
  54. # set this event to exit the server
  55. stop = trio.Event()
  56. with trio.open_nursery() as nursery:
  57. server = await nursery.start(route, url_map, ...)
  58. async with server:
  59. await stop.wait()
  60. Refer to the documentation of :mod:`werkzeug.routing` for details.
  61. If you define redirects with ``Rule(..., redirect_to=...)`` in the URL map,
  62. when the server runs behind a reverse proxy that modifies the ``Host``
  63. header or terminates TLS, you need additional configuration:
  64. * Set ``server_name`` to the name of the server as seen by clients. When
  65. not provided, websockets uses the value of the ``Host`` header.
  66. * Set ``ssl=True`` to generate ``wss://`` URIs without enabling TLS.
  67. Under the hood, this bind the URL map with a ``url_scheme`` of
  68. ``wss://`` instead of ``ws://``.
  69. There is no need to specify ``websocket=True`` in each rule. It is added
  70. automatically.
  71. Args:
  72. url_map: Mapping of URL patterns to connection handlers.
  73. server_name: Name of the server as seen by clients. If :obj:`None`,
  74. websockets uses the value of the ``Host`` header.
  75. ssl: Configuration for enabling TLS on the connection. Set it to
  76. :obj:`True` if a reverse proxy terminates TLS connections.
  77. create_router: Factory for the :class:`Router` dispatching requests to
  78. handlers. Set it to a wrapper or a subclass to customize routing.
  79. task_status: For compatibility with :meth:`nursery.start
  80. <trio.Nursery.start>`.
  81. """
  82. url_scheme = "ws" if ssl is None else "wss"
  83. if ssl is not True and ssl is not None:
  84. kwargs["ssl"] = ssl
  85. if create_router is None:
  86. create_router = Router
  87. router = create_router(url_map, server_name, url_scheme)
  88. _process_request: (
  89. Callable[
  90. [ServerConnection, Request],
  91. Awaitable[Response | None] | Response | None,
  92. ]
  93. | None
  94. ) = kwargs.pop("process_request", None)
  95. if _process_request is None:
  96. process_request: Callable[
  97. [ServerConnection, Request],
  98. Awaitable[Response | None] | Response | None,
  99. ] = router.route_request
  100. else:
  101. async def process_request(
  102. connection: ServerConnection,
  103. request: Request,
  104. ) -> Response | None:
  105. response = _process_request(connection, request)
  106. if isinstance(response, Awaitable):
  107. response = await response
  108. if response is not None:
  109. return response
  110. return router.route_request(connection, request)
  111. return await serve(
  112. router.handler,
  113. *args,
  114. process_request=process_request,
  115. task_status=task_status,
  116. **kwargs,
  117. )
  118. class Router:
  119. """WebSocket router supporting :func:`route`."""
  120. def __init__(
  121. self,
  122. url_map: Map,
  123. server_name: str | None = None,
  124. url_scheme: str = "ws",
  125. ) -> None:
  126. self.url_map = url_map
  127. self.server_name = server_name
  128. self.url_scheme = url_scheme
  129. for rule in self.url_map.iter_rules():
  130. rule.websocket = True
  131. def get_server_name(self, connection: ServerConnection, request: Request) -> str:
  132. if self.server_name is None:
  133. return request.headers["Host"]
  134. else:
  135. return self.server_name
  136. def redirect(self, connection: ServerConnection, url: str) -> Response:
  137. response = connection.respond(http.HTTPStatus.FOUND, f"Found at {url}")
  138. response.headers["Location"] = url
  139. return response
  140. def not_found(self, connection: ServerConnection) -> Response:
  141. return connection.respond(http.HTTPStatus.NOT_FOUND, "Not Found")
  142. def route_request(
  143. self, connection: ServerConnection, request: Request
  144. ) -> Response | None:
  145. """Route incoming request."""
  146. url_map_adapter = self.url_map.bind(
  147. server_name=self.get_server_name(connection, request),
  148. url_scheme=self.url_scheme,
  149. )
  150. try:
  151. parsed = urllib.parse.urlparse(request.path)
  152. handler, kwargs = url_map_adapter.match(
  153. path_info=parsed.path,
  154. query_args=parsed.query,
  155. )
  156. except RequestRedirect as redirect:
  157. return self.redirect(connection, redirect.new_url)
  158. except NotFound:
  159. return self.not_found(connection)
  160. connection.handler, connection.handler_kwargs = handler, kwargs
  161. return None
  162. async def handler(self, connection: ServerConnection) -> None:
  163. """Handle a connection."""
  164. return await connection.handler(connection, **connection.handler_kwargs)