Skip to content

Commit 88ec72f

Browse files
authored
Custom router API (#51)
1 parent 2d79831 commit 88ec72f

11 files changed

+150
-81
lines changed

CHANGELOG.md

+10
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,22 @@ Don't forget to remove deprecated code on each major release!
1919

2020
## [Unreleased]
2121

22+
### Added
23+
24+
- Support for custom routers.
25+
2226
### Changed
2327

2428
- Set maximum ReactPy version to `<2.0.0`.
2529
- Set minimum ReactPy version to `1.1.0`.
2630
- `link` element now calculates URL changes using the client.
2731
- Refactoring related to `reactpy>=1.1.0` changes.
32+
- Changed ReactPy-Router's method of waiting for the initial URL to be deterministic.
33+
- Rename `StarletteResolver` to `ReactPyResolver`.
34+
35+
### Removed
36+
37+
- `StarletteResolver` is removed in favor of `ReactPyResolver`.
2838

2939
### Fixed
3040

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from typing import ClassVar
2+
3+
from reactpy_router.resolvers import ConversionInfo, ReactPyResolver
4+
5+
6+
# Create a custom resolver that uses the following pattern: "{name:type}"
7+
class CustomResolver(ReactPyResolver):
8+
# Match parameters that use the "<name:type>" format
9+
param_pattern: str = r"<(?P<name>\w+)(?P<type>:\w+)?>"
10+
11+
# Enable matching for the following types: int, str, any
12+
converters: ClassVar[dict[str, ConversionInfo]] = {
13+
"int": ConversionInfo(regex=r"\d+", func=int),
14+
"str": ConversionInfo(regex=r"[^/]+", func=str),
15+
"any": ConversionInfo(regex=r".*", func=str),
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from example.resolvers import CustomResolver
2+
3+
from reactpy_router.routers import create_router
4+
5+
# This can be used in any location where `browser_router` was previously used
6+
custom_router = create_router(CustomResolver)

docs/examples/python/example/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from reactpy_router.resolvers import ReactPyResolver
2+
3+
4+
class CustomResolver(ReactPyResolver): ...

docs/mkdocs.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ nav:
66
- Advanced Topics:
77
- Routers, Routes, and Links: learn/routers-routes-and-links.md
88
- Hooks: learn/hooks.md
9-
- Creating a Custom Router 🚧: learn/custom-router.md
9+
- Creating a Custom Router: learn/custom-router.md
1010
- Reference:
1111
- Routers: reference/routers.md
1212
- Components: reference/components.md

docs/src/learn/custom-router.md

+27-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
1-
# Custom Router
1+
Custom routers can be used to define custom routing logic for your application. This is useful when you need to implement a custom routing algorithm or when you need to integrate with an existing URL routing system.
22

3-
Under construction 🚧
3+
---
4+
5+
## Step 1: Creating a custom resolver
6+
7+
You may want to create a custom resolver to allow ReactPy to utilize an existing routing syntax.
8+
9+
To start off, you will need to create a subclass of `#!python ReactPyResolver`. Within this subclass, you have two attributes which you can modify to support your custom routing syntax:
10+
11+
- `#!python param_pattern`: A regular expression pattern that matches the parameters in your URL. This pattern must contain the regex named groups `name` and `type`.
12+
- `#!python converters`: A dictionary that maps a `type` to it's respective `regex` pattern and a converter `func`.
13+
14+
=== "resolver.py"
15+
16+
```python
17+
{% include "../../examples/python/custom_router_easy_resolver.py" %}
18+
```
19+
20+
## Step 2: Creating a custom router
21+
22+
Then, you can use this resolver to create your custom router...
23+
24+
=== "resolver.py"
25+
26+
```python
27+
{% include "../../examples/python/custom_router_easy_router.py" %}
28+
```

src/reactpy_router/resolvers.py

+14-16
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,29 @@
11
from __future__ import annotations
22

33
import re
4-
from typing import TYPE_CHECKING, Any
4+
from typing import TYPE_CHECKING, ClassVar
55

66
from reactpy_router.converters import CONVERTERS
7+
from reactpy_router.types import MatchedRoute
78

89
if TYPE_CHECKING:
910
from reactpy_router.types import ConversionInfo, ConverterMapping, Route
1011

11-
__all__ = ["StarletteResolver"]
12+
__all__ = ["ReactPyResolver"]
1213

1314

14-
class StarletteResolver:
15-
"""URL resolver that matches routes using starlette's URL routing syntax.
15+
class ReactPyResolver:
16+
"""URL resolver that can match a path against any given routes.
1617
17-
However, this resolver adds a few additional parameter types on top of Starlette's syntax."""
18+
URL routing syntax for this resolver is based on Starlette, and supports a mixture of Starlette and Django parameter types."""
1819

19-
def __init__(
20-
self,
21-
route: Route,
22-
param_pattern=r"{(?P<name>\w+)(?P<type>:\w+)?}",
23-
converters: dict[str, ConversionInfo] | None = None,
24-
) -> None:
20+
param_pattern: str = r"{(?P<name>\w+)(?P<type>:\w+)?}"
21+
converters: ClassVar[dict[str, ConversionInfo]] = CONVERTERS
22+
23+
def __init__(self, route: Route) -> None:
2524
self.element = route.element
26-
self.registered_converters = converters or CONVERTERS
2725
self.converter_mapping: ConverterMapping = {}
28-
self.param_regex = re.compile(param_pattern)
26+
self.param_regex = re.compile(self.param_pattern)
2927
self.pattern = self.parse_path(route.path)
3028
self.key = self.pattern.pattern # Unique identifier for ReactPy rendering
3129

@@ -48,7 +46,7 @@ def parse_path(self, path: str) -> re.Pattern[str]:
4846

4947
# Check if a converter exists for the type
5048
try:
51-
conversion_info = self.registered_converters[param_type]
49+
conversion_info = self.converters[param_type]
5250
except KeyError as e:
5351
msg = f"Unknown conversion type {param_type!r} in {path!r}"
5452
raise ValueError(msg) from e
@@ -70,7 +68,7 @@ def parse_path(self, path: str) -> re.Pattern[str]:
7068

7169
return re.compile(pattern)
7270

73-
def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
71+
def resolve(self, path: str) -> MatchedRoute | None:
7472
match = self.pattern.match(path)
7573
if match:
7674
# Convert the matched groups to the correct types
@@ -80,5 +78,5 @@ def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
8078
else parameter_name: self.converter_mapping[parameter_name](value)
8179
for parameter_name, value in match.groupdict().items()
8280
}
83-
return (self.element, params)
81+
return MatchedRoute(self.element, params, path)
8482
return None

src/reactpy_router/routers.py

+25-46
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from dataclasses import replace
66
from logging import getLogger
7-
from typing import TYPE_CHECKING, Any, Literal, cast
7+
from typing import TYPE_CHECKING, Any, Union, cast
88

99
from reactpy import component, use_memo, use_state
1010
from reactpy.backend.types import Connection, Location
@@ -13,14 +13,14 @@
1313

1414
from reactpy_router.components import History
1515
from reactpy_router.hooks import RouteState, _route_state_context
16-
from reactpy_router.resolvers import StarletteResolver
16+
from reactpy_router.resolvers import ReactPyResolver
1717

1818
if TYPE_CHECKING:
1919
from collections.abc import Iterator, Sequence
2020

2121
from reactpy.core.component import Component
2222

23-
from reactpy_router.types import CompiledRoute, Resolver, Route, Router
23+
from reactpy_router.types import CompiledRoute, MatchedRoute, Resolver, Route, Router
2424

2525
__all__ = ["browser_router", "create_router"]
2626
_logger = getLogger(__name__)
@@ -35,7 +35,7 @@ def wrapper(*routes: Route) -> Component:
3535
return wrapper
3636

3737

38-
_starlette_router = create_router(StarletteResolver)
38+
_router = create_router(ReactPyResolver)
3939

4040

4141
def browser_router(*routes: Route) -> Component:
@@ -49,44 +49,35 @@ def browser_router(*routes: Route) -> Component:
4949
Returns:
5050
A router component that renders the given routes.
5151
"""
52-
return _starlette_router(*routes)
52+
return _router(*routes)
5353

5454

5555
@component
5656
def router(
5757
*routes: Route,
5858
resolver: Resolver[Route],
5959
) -> VdomDict | None:
60-
"""A component that renders matching route(s) using the given resolver.
60+
"""A component that renders matching route using the given resolver.
6161
62-
This typically should never be used by a user. Instead, use `create_router` if creating
62+
User notice: This component typically should never be used. Instead, use `create_router` if creating
6363
a custom routing engine."""
6464

65-
old_conn = use_connection()
66-
location, set_location = use_state(old_conn.location)
67-
first_load, set_first_load = use_state(True)
68-
65+
old_connection = use_connection()
66+
location, set_location = use_state(cast(Union[Location, None], None))
6967
resolvers = use_memo(
7068
lambda: tuple(map(resolver, _iter_routes(routes))),
7169
dependencies=(resolver, hash(routes)),
7270
)
73-
74-
match = use_memo(lambda: _match_route(resolvers, location, select="first"))
71+
route_element = None
72+
match = use_memo(lambda: _match_route(resolvers, location or old_connection.location))
7573

7674
if match:
77-
if first_load:
78-
# We need skip rendering the application on 'first_load' to avoid
79-
# rendering it twice. The second render follows the on_history_change event
80-
route_elements = []
81-
set_first_load(False)
82-
else:
83-
route_elements = [
84-
_route_state_context(
85-
element,
86-
value=RouteState(set_location, params),
87-
)
88-
for element, params in match
89-
]
75+
# Skip rendering until ReactPy-Router knows what URL the page is on.
76+
if location:
77+
route_element = _route_state_context(
78+
match.element,
79+
value=RouteState(set_location, match.params),
80+
)
9081

9182
def on_history_change(event: dict[str, Any]) -> None:
9283
"""Callback function used within the JavaScript `History` component."""
@@ -96,8 +87,8 @@ def on_history_change(event: dict[str, Any]) -> None:
9687

9788
return ConnectionContext(
9889
History({"onHistoryChangeCallback": on_history_change}), # type: ignore[return-value]
99-
*route_elements,
100-
value=Connection(old_conn.scope, location, old_conn.carrier),
90+
route_element,
91+
value=Connection(old_connection.scope, location or old_connection.location, old_connection.carrier),
10192
)
10293

10394
return None
@@ -110,9 +101,9 @@ def _iter_routes(routes: Sequence[Route]) -> Iterator[Route]:
110101
yield parent
111102

112103

113-
def _add_route_key(match: tuple[Any, dict[str, Any]], key: str | int) -> Any:
104+
def _add_route_key(match: MatchedRoute, key: str | int) -> Any:
114105
"""Add a key to the VDOM or component on the current route, if it doesn't already have one."""
115-
element, _params = match
106+
element = match.element
116107
if hasattr(element, "render") and not element.key:
117108
element = cast(ComponentType, element)
118109
element.key = key
@@ -125,24 +116,12 @@ def _add_route_key(match: tuple[Any, dict[str, Any]], key: str | int) -> Any:
125116
def _match_route(
126117
compiled_routes: Sequence[CompiledRoute],
127118
location: Location,
128-
select: Literal["first", "all"],
129-
) -> list[tuple[Any, dict[str, Any]]]:
130-
matches = []
131-
119+
) -> MatchedRoute | None:
132120
for resolver in compiled_routes:
133121
match = resolver.resolve(location.pathname)
134122
if match is not None:
135-
if select == "first":
136-
return [_add_route_key(match, resolver.key)]
123+
return _add_route_key(match, resolver.key)
137124

138-
# Matching multiple routes is disabled since `react-router` no longer supports multiple
139-
# matches via the `Route` component. However, it's kept here to support future changes
140-
# or third-party routers.
141-
# TODO: The `resolver.key` value has edge cases where it is not unique enough to use as
142-
# a key here. We can potentially fix this by throwing errors for duplicate identical routes.
143-
matches.append(_add_route_key(match, resolver.key)) # pragma: no cover
125+
_logger.debug("No matching route found for %s", location.pathname)
144126

145-
if not matches:
146-
_logger.debug("No matching route found for %s", location.pathname)
147-
148-
return matches
127+
return None

src/reactpy_router/types.py

+26-10
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ class Route:
2828
A class representing a route that can be matched against a path.
2929
3030
Attributes:
31-
path (str): The path to match against.
32-
element (Any): The element to render if the path matches.
33-
routes (Sequence[Self]): Child routes.
31+
path: The path to match against.
32+
element: The element to render if the path matches.
33+
routes: Child routes.
3434
3535
Methods:
3636
__hash__() -> int: Returns a hash value for the route based on its path, element, and child routes.
@@ -67,11 +67,11 @@ def __call__(self, *routes: RouteType_contra) -> Component:
6767

6868

6969
class Resolver(Protocol[RouteType_contra]):
70-
"""Compile a route into a resolver that can be matched against a given path."""
70+
"""A class, that when instantiated, can match routes against a given path."""
7171

7272
def __call__(self, route: RouteType_contra) -> CompiledRoute:
7373
"""
74-
Compile a route into a resolver that can be matched against a given path.
74+
Compile a route into a resolver that can be match routes against a given path.
7575
7676
Args:
7777
route: The route to compile.
@@ -87,32 +87,48 @@ class CompiledRoute(Protocol):
8787
A protocol for a compiled route that can be matched against a path.
8888
8989
Attributes:
90-
key (Key): A property that uniquely identifies this resolver.
90+
key: A property that uniquely identifies this resolver.
9191
"""
9292

9393
@property
9494
def key(self) -> Key: ...
9595

96-
def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
96+
def resolve(self, path: str) -> MatchedRoute | None:
9797
"""
9898
Return the path's associated element and path parameters or None.
9999
100100
Args:
101-
path (str): The path to resolve.
101+
path: The path to resolve.
102102
103103
Returns:
104104
A tuple containing the associated element and a dictionary of path parameters, or None if the path cannot be resolved.
105105
"""
106106
...
107107

108108

109+
@dataclass(frozen=True)
110+
class MatchedRoute:
111+
"""
112+
Represents a matched route.
113+
114+
Attributes:
115+
element: The element to render.
116+
params: The parameters extracted from the path.
117+
path: The path that was matched.
118+
"""
119+
120+
element: Any
121+
params: dict[str, Any]
122+
path: str
123+
124+
109125
class ConversionInfo(TypedDict):
110126
"""
111127
A TypedDict that holds information about a conversion type.
112128
113129
Attributes:
114-
regex (str): The regex to match the conversion type.
115-
func (ConversionFunc): The function to convert the matched string to the expected type.
130+
regex: The regex to match the conversion type.
131+
func: The function to convert the matched string to the expected type.
116132
"""
117133

118134
regex: str

0 commit comments

Comments
 (0)