Skip to content

Commit de08c46

Browse files
committed
Add ValidURLRouter to handle unmatched routes (#2147)
1 parent 8bf5c7d commit de08c46

File tree

2 files changed

+85
-1
lines changed

2 files changed

+85
-1
lines changed

channels/routing.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ async def __call__(self, scope, receive, send):
5252
)
5353

5454

55-
class URLRouter:
55+
class _URLRouter:
5656
"""
5757
Routes to different applications/consumers based on the URL path.
5858
@@ -136,6 +136,46 @@ async def __call__(self, scope, receive, send):
136136
raise ValueError("No route found for path %r." % path)
137137

138138

139+
class URLRouter(_URLRouter):
140+
"""
141+
URLRouter variant that returns 404 or closes WebSocket on invalid routes.
142+
143+
Catches ValueError and Resolver404 from URL resolution.
144+
145+
- For HTTP, responds with 404.
146+
- For WebSocket, closes with code 1008 before handshake (resulting in 403).
147+
- Other scope types propagate the exception.
148+
"""
149+
150+
async def __call__(self, scope, receive, send):
151+
try:
152+
return await super().__call__(scope, receive, send)
153+
except (ValueError, Resolver404):
154+
if scope["type"] == "http":
155+
await send(
156+
{
157+
"type": "http.response.start",
158+
"status": 404,
159+
"headers": [(b"content-type", b"text/plain")],
160+
}
161+
)
162+
await send(
163+
{
164+
"type": "http.response.body",
165+
"body": b"404 Not Found",
166+
}
167+
)
168+
elif scope["type"] == "websocket":
169+
await send(
170+
{
171+
"type": "websocket.close",
172+
"code": 1008,
173+
}
174+
)
175+
else:
176+
raise
177+
178+
139179
class ChannelNameRouter:
140180
"""
141181
Maps to different applications based on a "channel" key in the scope

tests/test_testing.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
from channels.testing import HttpCommunicator, WebsocketCommunicator
1111

1212

13+
ValidURLRouter = URLRouter
14+
15+
1316
class SimpleHttpApp(AsyncConsumer):
1417
"""
1518
Barebones HTTP ASGI app for testing.
@@ -194,3 +197,44 @@ async def test_connection_scope(path):
194197
connected, _ = await communicator.connect()
195198
assert connected
196199
await communicator.disconnect()
200+
201+
202+
@pytest.mark.skip
203+
@pytest.mark.asyncio
204+
async def test_route_validator_http():
205+
"""
206+
Ensures ValidURLRouter returns 404 when route can't be matched.
207+
"""
208+
router = ValidURLRouter([path("test/", SimpleHttpApp())])
209+
communicator = HttpCommunicator(router, "GET", "/test/?foo=bar")
210+
response = await communicator.get_response()
211+
assert response["body"] == b"test response"
212+
assert response["status"] == 200
213+
214+
communicator = HttpCommunicator(router, "GET", "/not-test/")
215+
response = await communicator.get_response()
216+
assert response["body"] == b"404 Not Found"
217+
assert response["status"] == 404
218+
219+
220+
@pytest.mark.skip
221+
@pytest.mark.asyncio
222+
async def test_route_validator_websocket():
223+
"""
224+
Ensures WebSocket connections are closed on unmatched routes.
225+
226+
Forces ValidURLRouter to return 403 for unmatched routes during the handshake.
227+
WebSocket clients will receive a 1008 close code.
228+
229+
Ideally this should result in a 404, but that is not achievable in this context.
230+
"""
231+
router = ValidURLRouter([path("testws/", SimpleWebsocketApp())])
232+
communicator = WebsocketCommunicator(router, "/testws/")
233+
connected, subprotocol = await communicator.connect()
234+
assert connected
235+
assert subprotocol is None
236+
237+
communicator = WebsocketCommunicator(router, "/not-testws/")
238+
connected, subprotocol = await communicator.connect()
239+
assert connected is False
240+
assert subprotocol == 1008

0 commit comments

Comments
 (0)