Skip to content

Commit

Permalink
Custom router API (#51)
Browse files Browse the repository at this point in the history
  • Loading branch information
Archmonger authored Jan 12, 2025
1 parent 2d79831 commit 88ec72f
Show file tree
Hide file tree
Showing 11 changed files with 150 additions and 81 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,22 @@ Don't forget to remove deprecated code on each major release!

## [Unreleased]

### Added

- Support for custom routers.

### Changed

- Set maximum ReactPy version to `<2.0.0`.
- Set minimum ReactPy version to `1.1.0`.
- `link` element now calculates URL changes using the client.
- Refactoring related to `reactpy>=1.1.0` changes.
- Changed ReactPy-Router's method of waiting for the initial URL to be deterministic.
- Rename `StarletteResolver` to `ReactPyResolver`.

### Removed

- `StarletteResolver` is removed in favor of `ReactPyResolver`.

### Fixed

Expand Down
16 changes: 16 additions & 0 deletions docs/examples/python/custom_router_easy_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from typing import ClassVar

from reactpy_router.resolvers import ConversionInfo, ReactPyResolver


# Create a custom resolver that uses the following pattern: "{name:type}"
class CustomResolver(ReactPyResolver):
# Match parameters that use the "<name:type>" format
param_pattern: str = r"<(?P<name>\w+)(?P<type>:\w+)?>"

# Enable matching for the following types: int, str, any
converters: ClassVar[dict[str, ConversionInfo]] = {
"int": ConversionInfo(regex=r"\d+", func=int),
"str": ConversionInfo(regex=r"[^/]+", func=str),
"any": ConversionInfo(regex=r".*", func=str),
}
6 changes: 6 additions & 0 deletions docs/examples/python/custom_router_easy_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from example.resolvers import CustomResolver

from reactpy_router.routers import create_router

# This can be used in any location where `browser_router` was previously used
custom_router = create_router(CustomResolver)
Empty file.
4 changes: 4 additions & 0 deletions docs/examples/python/example/resolvers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from reactpy_router.resolvers import ReactPyResolver


class CustomResolver(ReactPyResolver): ...
2 changes: 1 addition & 1 deletion docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ nav:
- Advanced Topics:
- Routers, Routes, and Links: learn/routers-routes-and-links.md
- Hooks: learn/hooks.md
- Creating a Custom Router 🚧: learn/custom-router.md
- Creating a Custom Router: learn/custom-router.md
- Reference:
- Routers: reference/routers.md
- Components: reference/components.md
Expand Down
29 changes: 27 additions & 2 deletions docs/src/learn/custom-router.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,28 @@
# Custom Router
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.

Under construction 🚧
---

## Step 1: Creating a custom resolver

You may want to create a custom resolver to allow ReactPy to utilize an existing routing syntax.

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:

- `#!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`.
- `#!python converters`: A dictionary that maps a `type` to it's respective `regex` pattern and a converter `func`.

=== "resolver.py"

```python
{% include "../../examples/python/custom_router_easy_resolver.py" %}
```

## Step 2: Creating a custom router

Then, you can use this resolver to create your custom router...

=== "resolver.py"

```python
{% include "../../examples/python/custom_router_easy_router.py" %}
```
30 changes: 14 additions & 16 deletions src/reactpy_router/resolvers.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,29 @@
from __future__ import annotations

import re
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, ClassVar

from reactpy_router.converters import CONVERTERS
from reactpy_router.types import MatchedRoute

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

__all__ = ["StarletteResolver"]
__all__ = ["ReactPyResolver"]


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

def __init__(
self,
route: Route,
param_pattern=r"{(?P<name>\w+)(?P<type>:\w+)?}",
converters: dict[str, ConversionInfo] | None = None,
) -> None:
param_pattern: str = r"{(?P<name>\w+)(?P<type>:\w+)?}"
converters: ClassVar[dict[str, ConversionInfo]] = CONVERTERS

def __init__(self, route: Route) -> None:
self.element = route.element
self.registered_converters = converters or CONVERTERS
self.converter_mapping: ConverterMapping = {}
self.param_regex = re.compile(param_pattern)
self.param_regex = re.compile(self.param_pattern)
self.pattern = self.parse_path(route.path)
self.key = self.pattern.pattern # Unique identifier for ReactPy rendering

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

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

return re.compile(pattern)

def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
def resolve(self, path: str) -> MatchedRoute | None:
match = self.pattern.match(path)
if match:
# Convert the matched groups to the correct types
Expand All @@ -80,5 +78,5 @@ def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
else parameter_name: self.converter_mapping[parameter_name](value)
for parameter_name, value in match.groupdict().items()
}
return (self.element, params)
return MatchedRoute(self.element, params, path)
return None
71 changes: 25 additions & 46 deletions src/reactpy_router/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from dataclasses import replace
from logging import getLogger
from typing import TYPE_CHECKING, Any, Literal, cast
from typing import TYPE_CHECKING, Any, Union, cast

from reactpy import component, use_memo, use_state
from reactpy.backend.types import Connection, Location
Expand All @@ -13,14 +13,14 @@

from reactpy_router.components import History
from reactpy_router.hooks import RouteState, _route_state_context
from reactpy_router.resolvers import StarletteResolver
from reactpy_router.resolvers import ReactPyResolver

if TYPE_CHECKING:
from collections.abc import Iterator, Sequence

from reactpy.core.component import Component

from reactpy_router.types import CompiledRoute, Resolver, Route, Router
from reactpy_router.types import CompiledRoute, MatchedRoute, Resolver, Route, Router

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


_starlette_router = create_router(StarletteResolver)
_router = create_router(ReactPyResolver)


def browser_router(*routes: Route) -> Component:
Expand All @@ -49,44 +49,35 @@ def browser_router(*routes: Route) -> Component:
Returns:
A router component that renders the given routes.
"""
return _starlette_router(*routes)
return _router(*routes)


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

old_conn = use_connection()
location, set_location = use_state(old_conn.location)
first_load, set_first_load = use_state(True)

old_connection = use_connection()
location, set_location = use_state(cast(Union[Location, None], None))
resolvers = use_memo(
lambda: tuple(map(resolver, _iter_routes(routes))),
dependencies=(resolver, hash(routes)),
)

match = use_memo(lambda: _match_route(resolvers, location, select="first"))
route_element = None
match = use_memo(lambda: _match_route(resolvers, location or old_connection.location))

if match:
if first_load:
# We need skip rendering the application on 'first_load' to avoid
# rendering it twice. The second render follows the on_history_change event
route_elements = []
set_first_load(False)
else:
route_elements = [
_route_state_context(
element,
value=RouteState(set_location, params),
)
for element, params in match
]
# Skip rendering until ReactPy-Router knows what URL the page is on.
if location:
route_element = _route_state_context(
match.element,
value=RouteState(set_location, match.params),
)

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

return ConnectionContext(
History({"onHistoryChangeCallback": on_history_change}), # type: ignore[return-value]
*route_elements,
value=Connection(old_conn.scope, location, old_conn.carrier),
route_element,
value=Connection(old_connection.scope, location or old_connection.location, old_connection.carrier),
)

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


def _add_route_key(match: tuple[Any, dict[str, Any]], key: str | int) -> Any:
def _add_route_key(match: MatchedRoute, key: str | int) -> Any:
"""Add a key to the VDOM or component on the current route, if it doesn't already have one."""
element, _params = match
element = match.element
if hasattr(element, "render") and not element.key:
element = cast(ComponentType, element)
element.key = key
Expand All @@ -125,24 +116,12 @@ def _add_route_key(match: tuple[Any, dict[str, Any]], key: str | int) -> Any:
def _match_route(
compiled_routes: Sequence[CompiledRoute],
location: Location,
select: Literal["first", "all"],
) -> list[tuple[Any, dict[str, Any]]]:
matches = []

) -> MatchedRoute | None:
for resolver in compiled_routes:
match = resolver.resolve(location.pathname)
if match is not None:
if select == "first":
return [_add_route_key(match, resolver.key)]
return _add_route_key(match, resolver.key)

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

if not matches:
_logger.debug("No matching route found for %s", location.pathname)

return matches
return None
36 changes: 26 additions & 10 deletions src/reactpy_router/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ class Route:
A class representing a route that can be matched against a path.
Attributes:
path (str): The path to match against.
element (Any): The element to render if the path matches.
routes (Sequence[Self]): Child routes.
path: The path to match against.
element: The element to render if the path matches.
routes: Child routes.
Methods:
__hash__() -> int: Returns a hash value for the route based on its path, element, and child routes.
Expand Down Expand Up @@ -67,11 +67,11 @@ def __call__(self, *routes: RouteType_contra) -> Component:


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

def __call__(self, route: RouteType_contra) -> CompiledRoute:
"""
Compile a route into a resolver that can be matched against a given path.
Compile a route into a resolver that can be match routes against a given path.
Args:
route: The route to compile.
Expand All @@ -87,32 +87,48 @@ class CompiledRoute(Protocol):
A protocol for a compiled route that can be matched against a path.
Attributes:
key (Key): A property that uniquely identifies this resolver.
key: A property that uniquely identifies this resolver.
"""

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

def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
def resolve(self, path: str) -> MatchedRoute | None:
"""
Return the path's associated element and path parameters or None.
Args:
path (str): The path to resolve.
path: The path to resolve.
Returns:
A tuple containing the associated element and a dictionary of path parameters, or None if the path cannot be resolved.
"""
...


@dataclass(frozen=True)
class MatchedRoute:
"""
Represents a matched route.
Attributes:
element: The element to render.
params: The parameters extracted from the path.
path: The path that was matched.
"""

element: Any
params: dict[str, Any]
path: str


class ConversionInfo(TypedDict):
"""
A TypedDict that holds information about a conversion type.
Attributes:
regex (str): The regex to match the conversion type.
func (ConversionFunc): The function to convert the matched string to the expected type.
regex: The regex to match the conversion type.
func: The function to convert the matched string to the expected type.
"""

regex: str
Expand Down
Loading

0 comments on commit 88ec72f

Please sign in to comment.