66from datetime import datetime , timezone
77from email .utils import formatdate
88from logging import getLogger
9+ from typing import Callable , Literal , cast , overload
910
1011from asgiref import typing as asgi_types
1112from typing_extensions import Unpack
1213
1314from reactpy import html
1415from 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+ )
1731from 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
56153class ReactPyApp :
0 commit comments