diff --git a/channels/routing.py b/channels/routing.py index f48c4d33..66f5fd41 100644 --- a/channels/routing.py +++ b/channels/routing.py @@ -1,9 +1,10 @@ import importlib +import re from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.urls.exceptions import Resolver404 -from django.urls.resolvers import RegexPattern, RoutePattern, URLResolver +from django.urls.resolvers import RegexPattern, RoutePattern, URLPattern, URLResolver """ All Routing instances inside this file are also valid ASGI applications - with @@ -52,6 +53,71 @@ async def __call__(self, scope, receive, send): ) +def _parse_resolver(child_url_pattern, parent_resolver, parent_regex, routes): + """ + Parse resolver (returned by `include`) recurrsively + + Parameters + ---------- + child_url_pattern : URLResolver | Any + The child url pattern + parent_resolver : URLResolver + The parent resolver + parent_regex : Pattern + The parent regex pattern + routes : list[URLPattern] + The URLPattern's list that stores the routes + + Returns + ------- + list[URLPattern] + The URLPattern's list that stores the routes + """ + if isinstance(child_url_pattern, URLResolver): + # parse the urls resolved by django's `include` function + for url_pattern in child_url_pattern.url_patterns: + # call _parse_resolver recurrsively to parse nested URLResolver + routes.extend( + _parse_resolver( + url_pattern, + child_url_pattern, + parent_resolver.pattern.regex, + routes, + ) + ) + else: + # concatenate parent's url (route) and child's url (url_pattern) + regex = "".join( + x.pattern + for x in [ + parent_regex, + parent_resolver.pattern.regex, + child_url_pattern.pattern.regex, + ] + ) + + # Remove the redundant caret ^ which is appended by `path` function + regex = re.sub(r"(?`__. +You can use [include](https://docs.djangoproject.com/en/5.1/ref/urls/#include) +function for nested routings. This is similar as Django's URL routing system. + +Here's an example for nested routings. When you configure the routings in parent ``routings.py``; + +.. code-block:: python + + urlpatterns = [ + path("app1/", include("app1.routings"), name="app1"), + ] + +and in child ``app1/routings.py``; + +.. code-block:: python + + app_name = 'app1' + + urlpatterns = [ + re_path(r"chats/(\d+)/$", test_app, name="chats"), + ] + +This would resolve to a path such as ``/app1/chats/5/``. ChannelNameRouter ----------------- diff --git a/tests/conftest.py b/tests/conftest.py index 94c9803a..27f84603 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +import sys + import pytest from django.conf import settings @@ -38,3 +40,14 @@ def samesite(request, settings): def samesite_invalid(settings): """Set samesite flag to strict.""" settings.SESSION_COOKIE_SAMESITE = "Hello" + + +@pytest.fixture(autouse=True) +def mock_modules(): + """Save original modules for each test and clear a cache""" + original_modules = sys.modules.copy() + yield + sys.modules = original_modules + from django.urls.base import _get_cached_resolver + + _get_cached_resolver.cache_clear() diff --git a/tests/test_routing.py b/tests/test_routing.py index 99c76790..422f1363 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -1,5 +1,4 @@ import pytest -from django.core.exceptions import ImproperlyConfigured from django.urls import path, re_path from channels.routing import ChannelNameRouter, ProtocolTypeRouter, URLRouter @@ -302,13 +301,308 @@ async def test_path_remaining(): ) -def test_invalid_routes(): +@pytest.mark.asyncio +async def test_url_router_nesting_by_include(): """ - Test URLRouter route validation + Tests that nested URLRouters is constructed by include function. """ + import sys + from django.urls import include + from django.urls import reverse as django_reverse + + root_urlconf = "__src.routings" + + test_app = MockApplication(return_value=1) - with pytest.raises(ImproperlyConfigured) as exc: - URLRouter([path("", include([]))]) + # mocking the universe module following the directory structure; + # __src + # ├── universe + # │ └── routings.py + # └── routings.py (root) + + # in __src/universe/routings.py + # ====================== + # ... + # urlpatterns = [ + # re_path(r"book/(?P[\w\-]+)/page/(?P\d+)/$", test_app), + # re_path(r"test/(\d+)/$", test_app), + # path("/home/", test_app), + # ] + # ====================== + + universe_routings = type(sys)("routings") + universe_routings.app_name = "universe" + universe_routings.urlpatterns = [ + re_path(r"book/(?P[\w\-]+)/page/(?P\d+)/$", test_app, name="book"), + re_path(r"test/(\d+)/$", test_app, name="test"), + path("home/", test_app, name="home"), + ] + universe = type(sys)("universe") + universe.routings = universe_routings + sys.modules["__src.universe"] = universe + sys.modules["__src.universe.routings"] = universe.routings + + # in __src/routings.py (root) + # ====================== + # ... + # urlpatterns = [ + # path("universe/", include("__src.universe.routings"), name="universe"), + # path("moon/", test_app, name="moon"), + # re_path(r"mars/(\d+)/$", test_app, name="mars"), + # ] + # + # outer_router = URLRouter(urlpatterns) + # ====================== + urlpatterns = [ + path("universe/", include("__src.universe.routings"), name="universe"), + path("moon/", test_app, name="moon"), + re_path(r"mars/(\d+)/$", test_app, name="mars"), + ] + outer_router = URLRouter(urlpatterns) + + src = type(sys)("__src") + src.routings = type(sys)("routings") + src.routings.urlpatterns = urlpatterns + src.routings.outer_router = outer_router + sys.modules["__src"] = src + sys.modules["__src.routings"] = src.routings + + assert ( + await outer_router( + { + "type": "http", + "path": "/moon/", + }, + None, + None, + ) + == 1 + ) + assert django_reverse("moon", urlconf=root_urlconf) == "/moon/" - assert "include() is not supported in URLRouter." in str(exc) + assert ( + await outer_router( + { + "type": "http", + "path": "/mars/5/", + }, + None, + None, + ) + == 1 + ) + assert django_reverse("mars", urlconf=root_urlconf, args=(5,)) == "/mars/5/" + + assert ( + await outer_router( + { + "type": "http", + "path": "/universe/book/channels-guide/page/10/", + }, + None, + None, + ) + == 1 + ) + assert ( + django_reverse( + "universe:book", + urlconf=root_urlconf, + kwargs=dict(book="channels-guide", page=10), + ) + == "/universe/book/channels-guide/page/10/" + ) + + assert ( + await outer_router( + { + "type": "http", + "path": "/universe/test/10/", + }, + None, + None, + ) + == 1 + ) + assert ( + django_reverse("universe:test", urlconf=root_urlconf, args=(10,)) + == "/universe/test/10/" + ) + + assert ( + await outer_router( + { + "type": "http", + "path": "/universe/home/", + }, + None, + None, + ) + == 1 + ) + assert django_reverse("universe:home", urlconf=root_urlconf) == "/universe/home/" + + +@pytest.mark.asyncio +async def test_url_router_deep_nesting_by_include(): + """ + Tests that deep nested URLRouters is constructed by include function. + """ + import sys + + from django.urls import include + from django.urls import reverse as django_reverse + + root_urlconf = "__src.routings" + + test_app = MockApplication(return_value=1) + + # mocking the universe module following the directory structure; + # __src + # ├── universe + # │ ├── routings.py (use include) + # │ └── earth + # │ └── routings.py + # └── routings.py (root; use include) + + # in __src/universe/earth/routings.py + # ====================== + # ... + # app_name = "earth" + # urlpatterns = [ + # re_path(r"book/(?P[\w\-]+)/page/(?P\d+)/$", test_app), + # re_path(r"test/(\d+)/$", test_app), + # path("/home/", test_app), + # ] + # ====================== + earth_routings = type(sys)("routings") + earth_routings.app_name = "earth" + earth_routings.urlpatterns = [ + re_path(r"book/(?P[\w\-]+)/page/(?P\d+)/$", test_app, name="book"), + re_path(r"test/(\d+)/$", test_app, name="test"), + path("home/", test_app, name="home"), + ] + earth = type(sys)("earth") + earth.routings = earth_routings + sys.modules["__src.universe.earth"] = earth + sys.modules["__src.universe.earth.routings"] = earth.routings + + # in __src/universe/routings.py + # ====================== + # ... + # app_name = "earth" + # urlpatterns = [ + # path("earth/", include("__src.universe.earth.routings"), name="earth"), + # ] + # ====================== + universe_routings = type(sys)("routings") + universe_routings.app_name = "universe" + universe_routings.urlpatterns = [ + path("earth/", include("__src.universe.earth.routings"), name="earth"), + ] + universe = type(sys)("universe") + universe.routings = universe_routings + sys.modules["__src.universe"] = universe + sys.modules["__src.universe.routings"] = universe.routings + + # in __src/routings.py (root) + # ====================== + # ... + # urlpatterns = [ + # path("universe/", include("__src.universe.routings"), name="universe"), + # path("moon/", test_app, name="moon"), + # re_path(r"mars/(\d+)/$", test_app, name="mars"), + # ] + # outer_router = URLRouter(urlpatterns) + # ====================== + urlpatterns = [ + path("universe/", include("__src.universe.routings"), name="universe"), + path("moon/", test_app, name="moon"), + re_path(r"mars/(\d+)/$", test_app, name="mars"), + ] + outer_router = URLRouter(urlpatterns) + src = type(sys)("__src") + src.routings = type(sys)("routings") + src.routings.urlpatterns = urlpatterns + src.routings.outer_router = outer_router + sys.modules["__src"] = src + sys.modules["__src.routings"] = src.routings + + assert ( + await outer_router( + { + "type": "http", + "path": "/moon/", + }, + None, + None, + ) + == 1 + ) + assert django_reverse("moon", urlconf=root_urlconf) == "/moon/" + + assert ( + await outer_router( + { + "type": "http", + "path": "/mars/5/", + }, + None, + None, + ) + == 1 + ) + assert django_reverse("mars", urlconf=root_urlconf, args=(5,)) == "/mars/5/" + + assert ( + await outer_router( + { + "type": "http", + "path": "/universe/earth/book/channels-guide/page/10/", + }, + None, + None, + ) + == 1 + ) + assert ( + django_reverse( + "universe:earth:book", + urlconf=root_urlconf, + kwargs=dict(book="channels-guide", page=10), + ) + == "/universe/earth/book/channels-guide/page/10/" + ) + + assert ( + await outer_router( + { + "type": "http", + "path": "/universe/earth/test/10/", + }, + None, + None, + ) + == 1 + ) + assert ( + django_reverse("universe:earth:test", urlconf=root_urlconf, args=(10,)) + == "/universe/earth/test/10/" + ) + + assert ( + await outer_router( + { + "type": "http", + "path": "/universe/earth/home/", + }, + None, + None, + ) + == 1 + ) + assert ( + django_reverse("universe:earth:home", urlconf=root_urlconf) + == "/universe/earth/home/" + )