Skip to content

Custom router API #51

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jan 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading