Skip to content

Commit 23d7d6c

Browse files
authored
feat: Add use to Router for middleware support with pre-handler execution (#1857)
### Description - Add `Router.use()` for registering middleware functions that execute sequentially before the matched request handler, regardless of the request label. ### Issues - Closes: #1742 ### Testing - Add new tests for `Router`.
1 parent 68c96b3 commit 23d7d6c

4 files changed

Lines changed: 188 additions & 2 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import asyncio
2+
import time
3+
4+
from crawlee import Request
5+
from crawlee.crawlers import ParselCrawler, ParselCrawlingContext
6+
from crawlee.router import Router
7+
8+
9+
async def main() -> None:
10+
# Create a custom router instance
11+
router = Router[ParselCrawlingContext]()
12+
13+
# Register a middleware that logs every request before it reaches a handler
14+
@router.use
15+
async def logging_middleware(context: ParselCrawlingContext) -> None:
16+
context.log.info(
17+
f'Processing request: {context.request.url} label={context.request.label}'
18+
)
19+
20+
# Register a middleware that adds a timestamp to the request's user data
21+
@router.use
22+
async def timestamp_middleware(context: ParselCrawlingContext) -> None:
23+
context.request.user_data['start_time'] = time.monotonic()
24+
25+
@router.default_handler
26+
async def default_handler(context: ParselCrawlingContext) -> None:
27+
context.log.info(f'Processing {context.request.url} with default handler')
28+
29+
@router.handler('CATEGORY')
30+
async def category_handler(context: ParselCrawlingContext) -> None:
31+
context.log.info(f'Processing {context.request.url} with category handler')
32+
33+
crawler = ParselCrawler(
34+
request_handler=router,
35+
max_requests_per_crawl=10,
36+
)
37+
38+
await crawler.run(
39+
[
40+
'https://warehouse-theme-metal.myshopify.com/',
41+
Request.from_url(
42+
'https://warehouse-theme-metal.myshopify.com/collections/all',
43+
label='CATEGORY',
44+
),
45+
]
46+
)
47+
48+
49+
if __name__ == '__main__':
50+
asyncio.run(main())

docs/guides/request_router.mdx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
id: request-router
33
title: Request router
4-
description: Learn how to use the Router class to organize request handlers, error handlers, and pre-navigation hooks in Crawlee.
4+
description: Learn how to use the Router class to organize request handlers, middleware, error handlers, and pre-navigation hooks in Crawlee.
55
---
66

77
import ApiLink from '@site/src/components/ApiLink';
@@ -16,6 +16,7 @@ import ErrorHandler from '!!raw-loader!roa-loader!./code_examples/request_router
1616
import FailedRequestHandler from '!!raw-loader!roa-loader!./code_examples/request_router/failed_request_handler.py';
1717
import PlaywrightPreNavigation from '!!raw-loader!roa-loader!./code_examples/request_router/playwright_pre_navigation.py';
1818
import AdaptiveCrawlerHandlers from '!!raw-loader!roa-loader!./code_examples/request_router/adaptive_crawler_handlers.py';
19+
import RouterMiddleware from '!!raw-loader!roa-loader!./code_examples/request_router/router_middleware.py';
1920

2021
The <ApiLink to="class/Router">`Router`</ApiLink> class manages request flow and coordinates the execution of user-defined logic in Crawlee projects. It routes incoming requests to appropriate user-defined handlers based on labels, manages error scenarios, and provides hooks for pre-navigation execution. The <ApiLink to="class/Router">`Router`</ApiLink> serves as the orchestrator for all crawling operations, ensuring that each request is processed by the correct handler according to its type and label.
2122

@@ -57,6 +58,14 @@ More complex crawling projects often require different processing logic for vari
5758
{BasicRequestHandlers}
5859
</RunnableCodeBlock>
5960

61+
## Middleware
62+
63+
Middlewares are functions registered with <ApiLink to="class/Router#use">`router.use()`</ApiLink> that execute before the matched request handler on every request, regardless of the request label. Multiple middlewares can be registered and are executed sequentially in the order they were registered. If a middleware raises an exception, the execution chain is interrupted and the handler is not called.
64+
65+
<RunnableCodeBlock className="language-python" language="python">
66+
{RouterMiddleware}
67+
</RunnableCodeBlock>
68+
6069
## Error handlers
6170

6271
Crawlee provides error handling mechanisms to manage request processing failures. It distinguishes between recoverable errors that may succeed on retry and permanent failures that require alternative handling strategies.
@@ -107,6 +116,6 @@ The <ApiLink to="class/AdaptivePlaywrightCrawler">`AdaptivePlaywrightCrawler`</A
107116

108117
## Conclusion
109118

110-
This guide introduced you to the <ApiLink to="class/Router">`Router`</ApiLink> class and how to organize your crawling logic. You learned how to use built-in and custom routers, implement request handlers with label-based routing, handle errors with error and failed request handlers, and configure pre-navigation hooks for different crawler types.
119+
This guide introduced you to the <ApiLink to="class/Router">`Router`</ApiLink> class and how to organize your crawling logic. You learned how to use built-in and custom routers, implement request handlers with label-based routing, add middleware with <ApiLink to="class/Router#use">`router.use()`</ApiLink>, handle errors with error and failed request handlers, and configure pre-navigation hooks for different crawler types.
111120

112121
If you have questions or need assistance, feel free to reach out on our [GitHub](https://github.com/apify/crawlee-python) or join our [Discord community](https://discord.com/invite/jyEM2PRvMU). Happy scraping!

src/crawlee/router.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ class Router(Generic[TCrawlingContext]):
3232
3333
router = Router[HttpCrawlingContext]()
3434
35+
# Middleware executed for every request before the handlers
36+
@router.use
37+
async def logging_middleware(context: HttpCrawlingContext) -> None:
38+
context.log.info(f'Processing request: {context.request.url} label={context.request.label}')
3539
3640
# Handler for requests without a matching label handler
3741
@router.default_handler
@@ -59,6 +63,7 @@ async def main() -> None:
5963
def __init__(self) -> None:
6064
self._default_handler: RequestHandler[TCrawlingContext] | None = None
6165
self._handlers_by_label = dict[str, RequestHandler[TCrawlingContext]]()
66+
self._middlewares = list[RequestHandler[TCrawlingContext]]()
6267

6368
def default_handler(self: Router, handler: RequestHandler[TCrawlingContext]) -> RequestHandler[TCrawlingContext]:
6469
"""Register a default request handler.
@@ -91,8 +96,19 @@ def wrapper(handler: Callable[[TCrawlingContext], Awaitable]) -> Callable[[TCraw
9196

9297
return wrapper
9398

99+
def use(self, middleware: RequestHandler[TCrawlingContext]) -> RequestHandler[TCrawlingContext]:
100+
"""Register a middleware.
101+
102+
A middleware is a function that is executed before the request handler.
103+
"""
104+
self._middlewares.append(middleware)
105+
return middleware
106+
94107
async def __call__(self, context: TCrawlingContext) -> None:
95108
"""Invoke a request handler that matches the request label (or the default)."""
109+
for middleware in self._middlewares:
110+
await middleware(context)
111+
96112
context.request.state = RequestState.REQUEST_HANDLER
97113
if context.request.label is None or context.request.label not in self._handlers_by_label:
98114
if self._default_handler is None:

tests/unit/test_router.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,114 @@ async def handler(_context: MockContext) -> None:
116116
await router(MockContext(label='B'))
117117
mock_handler.assert_called_with('B')
118118
assert mock_handler.call_count == 2
119+
120+
121+
async def test_router_use_middleware() -> None:
122+
router = Router[MockContext]()
123+
mock_middleware = Mock()
124+
mock_default_handler = Mock()
125+
126+
@router.use
127+
async def middleware_1(_context: MockContext) -> None:
128+
mock_middleware(call='middleware_1')
129+
130+
@router.use
131+
async def middleware_2(_context: MockContext) -> None:
132+
mock_middleware(call='middleware_2')
133+
134+
@router.default_handler
135+
async def default_handler(_context: MockContext) -> None:
136+
mock_default_handler()
137+
138+
await router(MockContext(label=None))
139+
140+
assert mock_middleware.call_count == 2
141+
mock_middleware.assert_any_call(call='middleware_1')
142+
mock_middleware.assert_any_call(call='middleware_2')
143+
mock_default_handler.assert_called_once()
144+
# Check order of middleware execution
145+
assert mock_middleware.call_args_list[0][1] == {'call': 'middleware_1'}
146+
assert mock_middleware.call_args_list[1][1] == {'call': 'middleware_2'}
147+
148+
149+
async def test_router_use_middleware_with_label() -> None:
150+
router = Router[MockContext]()
151+
mock_middleware = Mock()
152+
mock_handler = Mock()
153+
154+
@router.use
155+
async def middleware_1(_context: MockContext) -> None:
156+
mock_middleware(call='middleware_1')
157+
158+
@router.use
159+
async def middleware_2(_context: MockContext) -> None:
160+
mock_middleware(call='middleware_2')
161+
162+
@router.handler('A')
163+
async def handler_a(_context: MockContext) -> None:
164+
mock_handler(call='handler_a')
165+
166+
@router.default_handler
167+
async def default_handler(_context: MockContext) -> None:
168+
mock_handler(call='default_handler')
169+
170+
await router(MockContext(label='A'))
171+
await router(MockContext(label=None))
172+
173+
assert mock_middleware.call_count == 4
174+
assert mock_handler.call_count == 2
175+
176+
assert mock_middleware.call_args_list[0][1] == {'call': 'middleware_1'}
177+
assert mock_middleware.call_args_list[1][1] == {'call': 'middleware_2'}
178+
assert mock_handler.call_args_list[0][1] == {'call': 'handler_a'}
179+
assert mock_middleware.call_args_list[2][1] == {'call': 'middleware_1'}
180+
assert mock_middleware.call_args_list[3][1] == {'call': 'middleware_2'}
181+
assert mock_handler.call_args_list[1][1] == {'call': 'default_handler'}
182+
183+
184+
async def test_router_middleware_order_execution() -> None:
185+
router = Router[MockContext]()
186+
mock_execution_order = Mock()
187+
188+
@router.use
189+
async def middleware_1(_context: MockContext) -> None:
190+
mock_execution_order(call='middleware_1')
191+
192+
@router.use
193+
async def middleware_2(_context: MockContext) -> None:
194+
mock_execution_order(call='middleware_2')
195+
196+
@router.default_handler
197+
async def default_handler(_context: MockContext) -> None:
198+
mock_execution_order(call='default_handler')
199+
200+
await router(MockContext(label=None))
201+
202+
assert mock_execution_order.call_count == 3
203+
assert mock_execution_order.call_args_list[0][1] == {'call': 'middleware_1'}
204+
assert mock_execution_order.call_args_list[1][1] == {'call': 'middleware_2'}
205+
assert mock_execution_order.call_args_list[2][1] == {'call': 'default_handler'}
206+
207+
208+
async def test_router_middleware_exception_interrupts_chain() -> None:
209+
router = Router[MockContext]()
210+
mock_execution_order = Mock()
211+
212+
@router.use
213+
async def middleware_1(_context: MockContext) -> None:
214+
mock_execution_order(call='middleware_1')
215+
raise ValueError('middleware error')
216+
217+
@router.use
218+
async def middleware_2(_context: MockContext) -> None:
219+
mock_execution_order(call='middleware_2')
220+
221+
@router.default_handler
222+
async def default_handler(_context: MockContext) -> None:
223+
mock_execution_order(call='default_handler')
224+
225+
with pytest.raises(ValueError, match='middleware error'):
226+
await router(MockContext(label=None))
227+
228+
assert mock_execution_order.call_count == 1
229+
assert mock_execution_order.call_args_list[0][1] == {'call': 'middleware_1'}

0 commit comments

Comments
 (0)