Skip to content

Commit 0ccf947

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

File tree

2 files changed

+82
-1
lines changed

2 files changed

+82
-1
lines changed

channels/routing.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 ValidURLRouter(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 requests, responds with 404.
146+
- For WebSocket connections, 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: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from channels.consumer import AsyncConsumer
88
from channels.generic.websocket import WebsocketConsumer
9-
from channels.routing import URLRouter
9+
from channels.routing import URLRouter, ValidURLRouter
1010
from channels.testing import HttpCommunicator, WebsocketCommunicator
1111

1212

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

0 commit comments

Comments
 (0)