Skip to content

Commit ee2d44f

Browse files
authored
Allow user defined routes in ReactPy() (#1265)
1 parent 6de65ef commit ee2d44f

File tree

6 files changed

+311
-23
lines changed

6 files changed

+311
-23
lines changed

pyproject.toml

-12
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,6 @@ exclude_also = [
256256
]
257257

258258
[tool.ruff]
259-
target-version = "py39"
260259
line-length = 88
261260
lint.select = [
262261
"A",
@@ -328,13 +327,6 @@ lint.unfixable = [
328327
[tool.ruff.lint.isort]
329328
known-first-party = ["reactpy"]
330329

331-
[tool.ruff.lint.flake8-tidy-imports]
332-
ban-relative-imports = "all"
333-
334-
[tool.flake8]
335-
select = ["RPY"] # only need to check with reactpy-flake8
336-
exclude = ["**/node_modules/*", ".eggs/*", ".tox/*", "**/venv/*"]
337-
338330
[tool.ruff.lint.per-file-ignores]
339331
# Tests can use magic values, assertions, and relative imports
340332
"**/tests/**/*" = ["PLR2004", "S101", "TID252"]
@@ -350,7 +342,3 @@ exclude = ["**/node_modules/*", ".eggs/*", ".tox/*", "**/venv/*"]
350342
# Allow print
351343
"T201",
352344
]
353-
354-
[tool.black]
355-
target-version = ["py39"]
356-
line-length = 88

src/reactpy/asgi/middleware.py

+29-6
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,21 @@
2121
from reactpy.core.hooks import ConnectionContext
2222
from reactpy.core.layout import Layout
2323
from reactpy.core.serve import serve_layout
24-
from reactpy.types import Connection, Location, ReactPyConfig, RootComponentConstructor
24+
from reactpy.types import (
25+
AsgiApp,
26+
AsgiHttpApp,
27+
AsgiLifespanApp,
28+
AsgiWebsocketApp,
29+
Connection,
30+
Location,
31+
ReactPyConfig,
32+
RootComponentConstructor,
33+
)
2534

2635
_logger = logging.getLogger(__name__)
2736

2837

2938
class ReactPyMiddleware:
30-
_asgi_single_callable: bool = True
3139
root_component: RootComponentConstructor | None = None
3240
root_components: dict[str, RootComponentConstructor]
3341
multiple_root_components: bool = True
@@ -73,8 +81,13 @@ def __init__(
7381
self.js_modules_pattern = re.compile(f"^{self.web_modules_path}.*")
7482
self.static_pattern = re.compile(f"^{self.static_path}.*")
7583

84+
# User defined ASGI apps
85+
self.extra_http_routes: dict[str, AsgiHttpApp] = {}
86+
self.extra_ws_routes: dict[str, AsgiWebsocketApp] = {}
87+
self.extra_lifespan_app: AsgiLifespanApp | None = None
88+
7689
# Component attributes
77-
self.user_app: asgi_types.ASGI3Application = guarantee_single_callable(app) # type: ignore
90+
self.asgi_app: asgi_types.ASGI3Application = guarantee_single_callable(app) # type: ignore
7891
self.root_components = import_components(root_components)
7992

8093
# Directory attributes
@@ -106,8 +119,13 @@ async def __call__(
106119
if scope["type"] == "http" and self.match_web_modules_path(scope):
107120
return await self.web_modules_app(scope, receive, send)
108121

122+
# URL routing for user-defined routes
123+
matched_app = self.match_extra_paths(scope)
124+
if matched_app:
125+
return await matched_app(scope, receive, send) # type: ignore
126+
109127
# Serve the user's application
110-
await self.user_app(scope, receive, send)
128+
await self.asgi_app(scope, receive, send)
111129

112130
def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool:
113131
return bool(re.match(self.dispatcher_pattern, scope["path"]))
@@ -118,6 +136,11 @@ def match_static_path(self, scope: asgi_types.HTTPScope) -> bool:
118136
def match_web_modules_path(self, scope: asgi_types.HTTPScope) -> bool:
119137
return bool(re.match(self.js_modules_pattern, scope["path"]))
120138

139+
def match_extra_paths(self, scope: asgi_types.Scope) -> AsgiApp | None:
140+
# Custom defined routes are unused within middleware to encourage users to handle
141+
# routing within their root ASGI application.
142+
return None
143+
121144

122145
@dataclass
123146
class ComponentDispatchApp:
@@ -223,7 +246,7 @@ async def __call__(
223246
"""ASGI app for ReactPy static files."""
224247
if not self._static_file_server:
225248
self._static_file_server = ServeStaticASGI(
226-
self.parent.user_app,
249+
self.parent.asgi_app,
227250
root=self.parent.static_dir,
228251
prefix=self.parent.static_path,
229252
)
@@ -245,7 +268,7 @@ async def __call__(
245268
"""ASGI app for ReactPy web modules."""
246269
if not self._static_file_server:
247270
self._static_file_server = ServeStaticASGI(
248-
self.parent.user_app,
271+
self.parent.asgi_app,
249272
root=self.parent.web_modules_dir,
250273
prefix=self.parent.web_modules_path,
251274
autorefresh=True,

src/reactpy/asgi/standalone.py

+100-3
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,28 @@
66
from datetime import datetime, timezone
77
from email.utils import formatdate
88
from logging import getLogger
9+
from typing import Callable, Literal, cast, overload
910

1011
from asgiref import typing as asgi_types
1112
from typing_extensions import Unpack
1213

1314
from reactpy import html
1415
from reactpy.asgi.middleware import ReactPyMiddleware
15-
from reactpy.asgi.utils import dict_to_byte_list, http_response, vdom_head_to_html
16-
from reactpy.types import ReactPyConfig, RootComponentConstructor, VdomDict
16+
from reactpy.asgi.utils import (
17+
dict_to_byte_list,
18+
http_response,
19+
import_dotted_path,
20+
vdom_head_to_html,
21+
)
22+
from reactpy.types import (
23+
AsgiApp,
24+
AsgiHttpApp,
25+
AsgiLifespanApp,
26+
AsgiWebsocketApp,
27+
ReactPyConfig,
28+
RootComponentConstructor,
29+
VdomDict,
30+
)
1731
from reactpy.utils import render_mount_template
1832

1933
_logger = getLogger(__name__)
@@ -34,7 +48,7 @@ def __init__(
3448
"""ReactPy's standalone ASGI application.
3549
3650
Parameters:
37-
root_component: The root component to render. This component is assumed to be a single page application.
51+
root_component: The root component to render. This app is typically a single page application.
3852
http_headers: Additional headers to include in the HTTP response for the base HTML document.
3953
html_head: Additional head elements to include in the HTML response.
4054
html_lang: The language of the HTML document.
@@ -51,6 +65,89 @@ def match_dispatch_path(self, scope: asgi_types.WebSocketScope) -> bool:
5165
"""Method override to remove `dotted_path` from the dispatcher URL."""
5266
return str(scope["path"]) == self.dispatcher_path
5367

68+
def match_extra_paths(self, scope: asgi_types.Scope) -> AsgiApp | None:
69+
"""Method override to match user-provided HTTP/Websocket routes."""
70+
if scope["type"] == "lifespan":
71+
return self.extra_lifespan_app
72+
73+
if scope["type"] == "http":
74+
routing_dictionary = self.extra_http_routes.items()
75+
76+
if scope["type"] == "websocket":
77+
routing_dictionary = self.extra_ws_routes.items() # type: ignore
78+
79+
return next(
80+
(
81+
app
82+
for route, app in routing_dictionary
83+
if re.match(route, scope["path"])
84+
),
85+
None,
86+
)
87+
88+
@overload
89+
def route(
90+
self,
91+
path: str,
92+
type: Literal["http"] = "http",
93+
) -> Callable[[AsgiHttpApp | str], AsgiApp]: ...
94+
95+
@overload
96+
def route(
97+
self,
98+
path: str,
99+
type: Literal["websocket"],
100+
) -> Callable[[AsgiWebsocketApp | str], AsgiApp]: ...
101+
102+
def route(
103+
self,
104+
path: str,
105+
type: Literal["http", "websocket"] = "http",
106+
) -> (
107+
Callable[[AsgiHttpApp | str], AsgiApp]
108+
| Callable[[AsgiWebsocketApp | str], AsgiApp]
109+
):
110+
"""Interface that allows user to define their own HTTP/Websocket routes
111+
within the current ReactPy application.
112+
113+
Parameters:
114+
path: The URL route to match, using regex format.
115+
type: The protocol to route for. Can be 'http' or 'websocket'.
116+
"""
117+
118+
def decorator(
119+
app: AsgiApp | str,
120+
) -> AsgiApp:
121+
re_path = path
122+
if not re_path.startswith("^"):
123+
re_path = f"^{re_path}"
124+
if not re_path.endswith("$"):
125+
re_path = f"{re_path}$"
126+
127+
asgi_app: AsgiApp = import_dotted_path(app) if isinstance(app, str) else app
128+
if type == "http":
129+
self.extra_http_routes[re_path] = cast(AsgiHttpApp, asgi_app)
130+
elif type == "websocket":
131+
self.extra_ws_routes[re_path] = cast(AsgiWebsocketApp, asgi_app)
132+
133+
return asgi_app
134+
135+
return decorator
136+
137+
def lifespan(self, app: AsgiLifespanApp | str) -> None:
138+
"""Interface that allows user to define their own lifespan app
139+
within the current ReactPy application.
140+
141+
Parameters:
142+
app: The ASGI application to route to.
143+
"""
144+
if self.extra_lifespan_app:
145+
raise ValueError("Only one lifespan app can be defined.")
146+
147+
self.extra_lifespan_app = (
148+
import_dotted_path(app) if isinstance(app, str) else app
149+
)
150+
54151

55152
@dataclass
56153
class ReactPyApp:

src/reactpy/testing/backend.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ async def __aexit__(
140140
raise LogAssertionError(msg) from logged_errors[0]
141141

142142
await asyncio.wait_for(
143-
self.webserver.shutdown(), timeout=60 if GITHUB_ACTIONS else 5
143+
self.webserver.shutdown(), timeout=90 if GITHUB_ACTIONS else 5
144144
)
145145

146146
async def restart(self) -> None:

src/reactpy/types.py

+72-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import sys
44
from collections import namedtuple
5-
from collections.abc import Mapping, Sequence
5+
from collections.abc import Awaitable, Mapping, Sequence
66
from dataclasses import dataclass
77
from pathlib import Path
88
from types import TracebackType
@@ -15,6 +15,7 @@
1515
NamedTuple,
1616
Protocol,
1717
TypeVar,
18+
Union,
1819
overload,
1920
runtime_checkable,
2021
)
@@ -296,3 +297,73 @@ class ReactPyConfig(TypedDict, total=False):
296297
async_rendering: bool
297298
debug: bool
298299
tests_default_timeout: int
300+
301+
302+
AsgiHttpReceive = Callable[
303+
[],
304+
Awaitable[asgi_types.HTTPRequestEvent | asgi_types.HTTPDisconnectEvent],
305+
]
306+
307+
AsgiHttpSend = Callable[
308+
[
309+
asgi_types.HTTPResponseStartEvent
310+
| asgi_types.HTTPResponseBodyEvent
311+
| asgi_types.HTTPResponseTrailersEvent
312+
| asgi_types.HTTPServerPushEvent
313+
| asgi_types.HTTPDisconnectEvent
314+
],
315+
Awaitable[None],
316+
]
317+
318+
AsgiWebsocketReceive = Callable[
319+
[],
320+
Awaitable[
321+
asgi_types.WebSocketConnectEvent
322+
| asgi_types.WebSocketDisconnectEvent
323+
| asgi_types.WebSocketReceiveEvent
324+
],
325+
]
326+
327+
AsgiWebsocketSend = Callable[
328+
[
329+
asgi_types.WebSocketAcceptEvent
330+
| asgi_types.WebSocketSendEvent
331+
| asgi_types.WebSocketResponseStartEvent
332+
| asgi_types.WebSocketResponseBodyEvent
333+
| asgi_types.WebSocketCloseEvent
334+
],
335+
Awaitable[None],
336+
]
337+
338+
AsgiLifespanReceive = Callable[
339+
[],
340+
Awaitable[asgi_types.LifespanStartupEvent | asgi_types.LifespanShutdownEvent],
341+
]
342+
343+
AsgiLifespanSend = Callable[
344+
[
345+
asgi_types.LifespanStartupCompleteEvent
346+
| asgi_types.LifespanStartupFailedEvent
347+
| asgi_types.LifespanShutdownCompleteEvent
348+
| asgi_types.LifespanShutdownFailedEvent
349+
],
350+
Awaitable[None],
351+
]
352+
353+
AsgiHttpApp = Callable[
354+
[asgi_types.HTTPScope, AsgiHttpReceive, AsgiHttpSend],
355+
Awaitable[None],
356+
]
357+
358+
AsgiWebsocketApp = Callable[
359+
[asgi_types.WebSocketScope, AsgiWebsocketReceive, AsgiWebsocketSend],
360+
Awaitable[None],
361+
]
362+
363+
AsgiLifespanApp = Callable[
364+
[asgi_types.LifespanScope, AsgiLifespanReceive, AsgiLifespanSend],
365+
Awaitable[None],
366+
]
367+
368+
369+
AsgiApp = Union[AsgiHttpApp, AsgiWebsocketApp, AsgiLifespanApp]

0 commit comments

Comments
 (0)