[REF-2643] Throw Errors for duplicate Routes (#3155)

This commit is contained in:
Elijah Ahianyo 2024-05-03 12:15:40 -07:00 committed by GitHub
parent 24d15acae6
commit 9c7dbdbc72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 169 additions and 19 deletions

View File

@ -63,9 +63,8 @@ from reflex.page import (
DECORATED_PAGES, DECORATED_PAGES,
) )
from reflex.route import ( from reflex.route import (
catchall_in_route,
catchall_prefix,
get_route_args, get_route_args,
replace_brackets_with_keywords,
verify_route_validity, verify_route_validity,
) )
from reflex.state import ( from reflex.state import (
@ -456,6 +455,9 @@ class App(Base):
on_load: The event handler(s) that will be called each time the page load. on_load: The event handler(s) that will be called each time the page load.
meta: The metadata of the page. meta: The metadata of the page.
script_tags: List of script tags to be added to component script_tags: List of script tags to be added to component
Raises:
ValueError: When the specified route name already exists.
""" """
# If the route is not set, get it from the callable. # If the route is not set, get it from the callable.
if route is None: if route is None:
@ -470,6 +472,23 @@ class App(Base):
# Check if the route given is valid # Check if the route given is valid
verify_route_validity(route) verify_route_validity(route)
if route in self.pages and os.getenv(constants.RELOAD_CONFIG):
# when the app is reloaded(typically for app harness tests), we should maintain
# the latest render function of a route.This applies typically to decorated pages
# since they are only added when app._compile is called.
self.pages.pop(route)
if route in self.pages:
route_name = (
f"`{route}` or `/`"
if route == constants.PageNames.INDEX_ROUTE
else f"`{route}`"
)
raise ValueError(
f"Duplicate page route {route_name} already exists. Make sure you do not have two"
f" pages with the same route"
)
# Setup dynamic args for the route. # Setup dynamic args for the route.
# this state assignment is only required for tests using the deprecated state kwarg for App # this state assignment is only required for tests using the deprecated state kwarg for App
state = self.state if self.state else State state = self.state if self.state else State
@ -561,27 +580,31 @@ class App(Base):
Args: Args:
new_route: the route being newly added. new_route: the route being newly added.
""" """
newroute_catchall = catchall_in_route(new_route) if "[" not in new_route:
if not newroute_catchall:
return return
segments = (
constants.RouteRegex.SINGLE_SEGMENT,
constants.RouteRegex.DOUBLE_SEGMENT,
constants.RouteRegex.SINGLE_CATCHALL_SEGMENT,
constants.RouteRegex.DOUBLE_CATCHALL_SEGMENT,
)
for route in self.pages: for route in self.pages:
route = "" if route == "index" else route replaced_route = replace_brackets_with_keywords(route)
for rw, r, nr in zip(
if new_route.startswith(f"{route}/[[..."): replaced_route.split("/"), route.split("/"), new_route.split("/")
raise ValueError(
f"You cannot define a route with the same specificity as a optional catch-all route ('{route}' and '{new_route}')"
)
route_catchall = catchall_in_route(route)
if (
route_catchall
and newroute_catchall
and catchall_prefix(route) == catchall_prefix(new_route)
): ):
raise ValueError( if rw in segments and r != nr:
f"You cannot use multiple catchall for the same dynamic route ({route} !== {new_route})" # If the slugs in the segments of both routes are not the same, then the route is invalid
) raise ValueError(
f"You cannot use different slug names for the same dynamic path in {route} and {new_route} ('{r}' != '{nr}')"
)
elif rw not in segments and r != nr:
# if the section being compared in both routes is not a dynamic segment(i.e not wrapped in brackets)
# then we are guaranteed that the route is valid and there's no need checking the rest.
# eg. /posts/[id]/info/[slug1] and /posts/[id]/info1/[slug1] is always going to be valid since
# info1 will break away into its own tree.
break
def add_custom_404_page( def add_custom_404_page(
self, self,

View File

@ -44,6 +44,10 @@ class RouteRegex(SimpleNamespace):
STRICT_CATCHALL = re.compile(r"\[\.{3}([a-zA-Z_][\w]*)\]") STRICT_CATCHALL = re.compile(r"\[\.{3}([a-zA-Z_][\w]*)\]")
# group return the arg name (i.e. "slug") (optional arg can be empty) # group return the arg name (i.e. "slug") (optional arg can be empty)
OPT_CATCHALL = re.compile(r"\[\[\.{3}([a-zA-Z_][\w]*)\]\]") OPT_CATCHALL = re.compile(r"\[\[\.{3}([a-zA-Z_][\w]*)\]\]")
SINGLE_SEGMENT = "__SINGLE_SEGMENT__"
DOUBLE_SEGMENT = "__DOUBLE_SEGMENT__"
SINGLE_CATCHALL_SEGMENT = "__SINGLE_CATCHALL_SEGMENT__"
DOUBLE_CATCHALL_SEGMENT = "__DOUBLE_CATCHALL_SEGMENT__"
class DefaultPage(SimpleNamespace): class DefaultPage(SimpleNamespace):

View File

@ -101,3 +101,42 @@ def catchall_prefix(route: str) -> str:
""" """
pattern = catchall_in_route(route) pattern = catchall_in_route(route)
return route.replace(pattern, "") if pattern else "" return route.replace(pattern, "") if pattern else ""
def replace_brackets_with_keywords(input_string):
"""Replace brackets and everything inside it in a string with a keyword.
Args:
input_string: String to replace.
Returns:
new string containing keywords.
"""
# /posts -> /post
# /posts/[slug] -> /posts/__SINGLE_SEGMENT__
# /posts/[slug]/comments -> /posts/__SINGLE_SEGMENT__/comments
# /posts/[[slug]] -> /posts/__DOUBLE_SEGMENT__
# / posts/[[...slug2]]-> /posts/__DOUBLE_CATCHALL_SEGMENT__
# /posts/[...slug3]-> /posts/__SINGLE_CATCHALL_SEGMENT__
# Replace [[...<slug>]] with __DOUBLE_CATCHALL_SEGMENT__
output_string = re.sub(
r"\[\[\.\.\..+?\]\]",
constants.RouteRegex.DOUBLE_CATCHALL_SEGMENT,
input_string,
)
# Replace [...<slug>] with __SINGLE_CATCHALL_SEGMENT__
output_string = re.sub(
r"\[\.\.\..+?\]",
constants.RouteRegex.SINGLE_CATCHALL_SEGMENT,
output_string,
)
# Replace [[<slug>]] with __DOUBLE_SEGMENT__
output_string = re.sub(
r"\[\[.+?\]\]", constants.RouteRegex.DOUBLE_SEGMENT, output_string
)
# Replace [<slug>] with __SINGLE_SEGMENT__
output_string = re.sub(
r"\[.+?\]", constants.RouteRegex.SINGLE_SEGMENT, output_string
)
return output_string

View File

@ -310,6 +310,39 @@ def test_add_page_invalid_api_route(app: App, index_page):
app.add_page(index_page, route="/foo/api") app.add_page(index_page, route="/foo/api")
def page1():
return rx.fragment()
def page2():
return rx.fragment()
def index():
return rx.fragment()
@pytest.mark.parametrize(
"first_page,second_page, route",
[
(lambda: rx.fragment(), lambda: rx.fragment(rx.text("second")), "/"),
(rx.fragment(rx.text("first")), rx.fragment(rx.text("second")), "/page1"),
(
lambda: rx.fragment(rx.text("first")),
rx.fragment(rx.text("second")),
"page3",
),
(page1, page2, "page1"),
(index, index, None),
(page1, page1, None),
],
)
def test_add_duplicate_page_route_error(app, first_page, second_page, route):
app.add_page(first_page, route=route)
with pytest.raises(ValueError):
app.add_page(second_page, route="/" + route.strip("/") if route else None)
def test_initialize_with_admin_dashboard(test_model): def test_initialize_with_admin_dashboard(test_model):
"""Test setting the admin dashboard of an app. """Test setting the admin dashboard of an app.

View File

@ -1,6 +1,7 @@
import pytest import pytest
from reflex import constants from reflex import constants
from reflex.app import App
from reflex.route import catchall_in_route, get_route_args, verify_route_validity from reflex.route import catchall_in_route, get_route_args, verify_route_validity
@ -69,3 +70,53 @@ def test_verify_valid_routes(route_name):
def test_verify_invalid_routes(route_name): def test_verify_invalid_routes(route_name):
with pytest.raises(ValueError): with pytest.raises(ValueError):
verify_route_validity(route_name) verify_route_validity(route_name)
@pytest.fixture()
def app():
return App()
@pytest.mark.parametrize(
"route1,route2",
[
("/posts/[slug]", "/posts/[slug1]"),
("/posts/[slug]/info", "/posts/[slug1]/info1"),
("/posts/[slug]/info/[[slug1]]", "/posts/[slug1]/info1/[[slug2]]"),
("/posts/[slug]/info/[[slug1]]", "/posts/[slug]/info/[[slug2]]"),
("/posts/[slug]/info/[[...slug1]]", "/posts/[slug1]/info/[[...slug2]]"),
("/posts/[slug]/info/[[...slug1]]", "/posts/[slug]/info/[[...slug2]]"),
],
)
def test_check_routes_conflict_invalid(mocker, app, route1, route2):
mocker.patch.object(app, "pages", {route1: []})
with pytest.raises(ValueError):
app._check_routes_conflict(route2)
@pytest.mark.parametrize(
"route1,route2",
[
("/posts/[slug]", "/post/[slug1]"),
("/posts/[slug]", "/post/[slug]"),
("/posts/[slug]/info", "/posts/[slug]/info1"),
("/posts/[slug]/info/[[slug1]]", "/posts/[slug]/info1/[[slug1]]"),
("/posts/[slug]/info/[[slug1]]", "/posts/[slug]/info1/[[slug2]]"),
(
"/posts/[slug]/info/[slug2]/[[slug1]]",
"/posts/[slug]/info1/[slug2]/[[slug1]]",
),
(
"/posts/[slug]/info/[slug1]/random1/[slug2]/x",
"/posts/[slug]/info/[slug1]/random/[slug4]/x1",
),
("/posts/[slug]/info/[[...slug1]]", "/posts/[slug]/info1/[[...slug1]]"),
("/posts/[slug]/info/[[...slug1]]", "/posts/[slug]/info1/[[...slug2]]"),
("/posts/[slug]/info/[...slug1]", "/posts/[slug]/info1/[...slug1]"),
("/posts/[slug]/info/[...slug1]", "/posts/[slug]/info1/[...slug2]"),
],
)
def test_check_routes_conflict_valid(mocker, app, route1, route2):
mocker.patch.object(app, "pages", {route1: []})
# test that running this does not throw an error.
app._check_routes_conflict(route2)