feat(route): explicitly fall back to the next handler (#14834)

This commit is contained in:
Pavel Feldman 2022-06-13 11:30:51 -08:00 committed by GitHub
parent 05c56f5942
commit dcdd3c3cdb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 227 additions and 97 deletions

View file

@ -114,6 +114,120 @@ If set changes the post data of request
If set changes the request HTTP headers. Header values will be converted to a string. If set changes the request HTTP headers. Header values will be converted to a string.
## async method: Route.fallback
Proceeds to the next registered route in the route chain. If no more routes are
registered, continues the request as is. This allows registering multiple routes
with the same mask and falling back from one to another.
```js
// Handle GET requests.
await page.route('**/*', route => {
if (route.request().method() !== 'GET') {
route.fallback();
return;
}
// Handling GET only.
// ...
});
// Handle POST requests.
await page.route('**/*', route => {
if (route.request().method() !== 'POST') {
route.fallback();
return;
}
// Handling POST only.
// ...
});
```
```java
// Handle GET requests.
page.route("**/*", route -> {
if (!route.request().method().equals("GET")) {
route.fallback();
return;
}
// Handling GET only.
// ...
});
// Handle POST requests.
page.route("**/*", route -> {
if (!route.request().method().equals("POST")) {
route.fallback();
return;
}
// Handling POST only.
// ...
});
```
```python async
# Handle GET requests.
def handle_post(route):
if route.request.method != "GET":
route.fallback()
return
# Handling GET only.
# ...
# Handle POST requests.
def handle_post(route):
if route.request.method != "POST":
route.fallback()
return
# Handling POST only.
# ...
await page.route("**/*", handle_get)
await page.route("**/*", handle_post)
```
```python sync
# Handle GET requests.
def handle_post(route):
if route.request.method != "GET":
route.fallback()
return
# Handling GET only.
# ...
# Handle POST requests.
def handle_post(route):
if route.request.method != "POST":
route.fallback()
return
# Handling POST only.
# ...
page.route("**/*", handle_get)
page.route("**/*", handle_post)
```
```csharp
// Handle GET requests.
await page.RouteAsync("**/*", route => {
if (route.Request.Method != "GET") {
await route.FallbackAsync();
return;
}
// Handling GET only.
// ...
});
// Handle POST requests.
await page.RouteAsync("**/*", route => {
if (route.Request.Method != "POST") {
await route.FallbackAsync();
return;
}
// Handling POST only.
// ...
});
```
## async method: Route.fulfill ## async method: Route.fulfill
Fulfills route's request with given response. Fulfills route's request with given response.

View file

@ -144,31 +144,17 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
} }
async _onRoute(route: network.Route, request: network.Request) { async _onRoute(route: network.Route, request: network.Request) {
const routes = this._routes.filter(r => r.matches(request.url())); const routeHandlers = this._routes.filter(r => r.matches(request.url()));
for (const routeHandler of routeHandlers) {
const nextRoute = async () => {
const routeHandler = routes.shift();
if (!routeHandler) {
await route._finalContinue();
return;
}
if (routeHandler.willExpire()) if (routeHandler.willExpire())
this._routes.splice(this._routes.indexOf(routeHandler), 1); this._routes.splice(this._routes.indexOf(routeHandler), 1);
const handled = await routeHandler.handle(route, request);
await new Promise<void>(f => { if (!this._routes.length)
routeHandler.handle(route, request, async done => { this._wrapApiCall(() => this._disableInterception(), true).catch(() => {});
if (!done) if (handled)
await nextRoute(); return;
f(); }
}); await route._innerContinue({}, true);
});
};
await nextRoute();
if (!this._routes.length)
this._wrapApiCall(() => this._disableInterception(), true).catch(() => {});
} }
async _onBinding(bindingCall: BindingCall) { async _onBinding(bindingCall: BindingCall) {

View file

@ -229,8 +229,7 @@ type OverridesForContinue = {
}; };
export class Route extends ChannelOwner<channels.RouteChannel> implements api.Route { export class Route extends ChannelOwner<channels.RouteChannel> implements api.Route {
private _pendingContinueOverrides: OverridesForContinue | undefined; private _handlingPromise: ManualPromise<boolean> | null = null;
private _routeChain: ((done: boolean) => Promise<void>) | null = null;
static from(route: channels.RouteChannel): Route { static from(route: channels.RouteChannel): Route {
return (route as any)._object; return (route as any)._object;
@ -255,22 +254,30 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
]); ]);
} }
_startHandling(routeChain: (done: boolean) => Promise<void>) { _startHandling(): Promise<boolean> {
this._routeChain = routeChain; this._handlingPromise = new ManualPromise();
return this._handlingPromise;
}
async fallback() {
this._checkNotHandled();
this._reportHandled(false);
} }
async abort(errorCode?: string) { async abort(errorCode?: string) {
this._checkNotHandled();
await this._raceWithPageClose(this._channel.abort({ errorCode })); await this._raceWithPageClose(this._channel.abort({ errorCode }));
await this._followChain(true); this._reportHandled(true);
} }
async fulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, path?: string, har?: RouteHAR } = {}) { async fulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, path?: string, har?: RouteHAR } = {}) {
this._checkNotHandled();
await this._wrapApiCall(async () => { await this._wrapApiCall(async () => {
const fallback = await this._innerFulfill(options); const fallback = await this._innerFulfill(options);
switch (fallback) { switch (fallback) {
case 'abort': await this.abort(); break; case 'abort': await this.abort(); break;
case 'continue': await this.continue(); break; case 'continue': await this.continue(); break;
case 'done': await this._followChain(true); break; case 'done': this._reportHandled(true); break;
} }
}); });
} }
@ -354,20 +361,23 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
} }
async continue(options: OverridesForContinue = {}) { async continue(options: OverridesForContinue = {}) {
if (!this._routeChain) this._checkNotHandled();
await this._innerContinue(options);
this._reportHandled(true);
}
_checkNotHandled() {
if (!this._handlingPromise)
throw new Error('Route is already handled!'); throw new Error('Route is already handled!');
this._pendingContinueOverrides = { ...this._pendingContinueOverrides, ...options };
await this._followChain(false);
} }
async _followChain(done: boolean) { _reportHandled(done: boolean) {
const chain = this._routeChain!; const chain = this._handlingPromise!;
this._routeChain = null; this._handlingPromise = null;
await chain(done); chain.resolve(done);
} }
async _finalContinue() { async _innerContinue(options: OverridesForContinue = {}, internal = false) {
const options = this._pendingContinueOverrides || {};
return await this._wrapApiCall(async () => { return await this._wrapApiCall(async () => {
const postDataBuffer = isString(options.postData) ? Buffer.from(options.postData, 'utf8') : options.postData; const postDataBuffer = isString(options.postData) ? Buffer.from(options.postData, 'utf8') : options.postData;
await this._raceWithPageClose(this._channel.continue({ await this._raceWithPageClose(this._channel.continue({
@ -376,7 +386,7 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
headers: options.headers ? headersObjectToArray(options.headers) : undefined, headers: options.headers ? headersObjectToArray(options.headers) : undefined,
postData: postDataBuffer ? postDataBuffer.toString('base64') : undefined, postData: postDataBuffer ? postDataBuffer.toString('base64') : undefined,
})); }));
}, !this._pendingContinueOverrides); }, !!internal);
} }
} }
@ -593,12 +603,16 @@ export class RouteHandler {
return urlMatches(this._baseURL, requestURL, this.url); return urlMatches(this._baseURL, requestURL, this.url);
} }
public handle(route: Route, request: Request, routeChain: (done: boolean) => Promise<void>) { public async handle(route: Route, request: Request): Promise<boolean> {
++this.handledCount; ++this.handledCount;
route._startHandling(routeChain); const handledPromise = route._startHandling();
// Extract handler into a variable to avoid [RouteHandler.handler] in the stack. // Extract handler into a variable to avoid [RouteHandler.handler] in the stack.
const handler = this.handler; const handler = this.handler;
handler(route, request); const [handled] = await Promise.all([
handledPromise,
handler(route, request),
]);
return handled;
} }
public willExpire(): boolean { public willExpire(): boolean {

View file

@ -180,30 +180,17 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
} }
private async _onRoute(route: Route, request: Request) { private async _onRoute(route: Route, request: Request) {
const routes = this._routes.filter(r => r.matches(request.url())); const routeHandlers = this._routes.filter(r => r.matches(request.url()));
for (const routeHandler of routeHandlers) {
const nextRoute = async () => {
const routeHandler = routes.shift();
if (!routeHandler) {
await this._browserContext._onRoute(route, request);
return;
}
if (routeHandler.willExpire()) if (routeHandler.willExpire())
this._routes.splice(this._routes.indexOf(routeHandler), 1); this._routes.splice(this._routes.indexOf(routeHandler), 1);
const handled = await routeHandler.handle(route, request);
await new Promise<void>(f => { if (!this._routes.length)
routeHandler.handle(route, request, async done => { this._wrapApiCall(() => this._disableInterception(), true).catch(() => {});
if (!done) if (handled)
await nextRoute(); return;
f(); }
}); await this._browserContext._onRoute(route, request);
});
};
await nextRoute();
if (!this._routes.length)
this._wrapApiCall(() => this._disableInterception(), true).catch(() => {});
} }
async _onBinding(bindingCall: BindingCall) { async _onBinding(bindingCall: BindingCall) {

View file

@ -14833,6 +14833,35 @@ export interface Route {
url?: string; url?: string;
}): Promise<void>; }): Promise<void>;
/**
* Proceeds to the next registered route in the route chain. If no more routes are registered, continues the request as is.
* This allows registering multiple routes with the same mask and falling back from one to another.
*
* ```js
* // Handle GET requests.
* await page.route('**\/*', route => {
* if (route.request().method() !== 'GET') {
* route.fallback();
* return;
* }
* // Handling GET only.
* // ...
* });
*
* // Handle POST requests.
* await page.route('**\/*', route => {
* if (route.request().method() !== 'POST') {
* route.fallback();
* return;
* }
* // Handling POST only.
* // ...
* });
* ```
*
*/
fallback(): Promise<void>;
/** /**
* Fulfills route's request with given response. * Fulfills route's request with given response.
* *

View file

@ -47,19 +47,19 @@ it('should unroute', async ({ browser, server }) => {
let intercepted = []; let intercepted = [];
await context.route('**/*', route => { await context.route('**/*', route => {
intercepted.push(1); intercepted.push(1);
route.continue(); route.fallback();
}); });
await context.route('**/empty.html', route => { await context.route('**/empty.html', route => {
intercepted.push(2); intercepted.push(2);
route.continue(); route.fallback();
}); });
await context.route('**/empty.html', route => { await context.route('**/empty.html', route => {
intercepted.push(3); intercepted.push(3);
route.continue(); route.fallback();
}); });
const handler4 = route => { const handler4 = route => {
intercepted.push(4); intercepted.push(4);
route.continue(); route.fallback();
}; };
await context.route('**/empty.html', handler4); await context.route('**/empty.html', handler4);
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
@ -250,19 +250,19 @@ it('should overwrite post body with empty string', async ({ context, server, pag
expect(body).toBe(''); expect(body).toBe('');
}); });
it('should chain continue', async ({ context, page, server }) => { it('should chain fallback', async ({ context, page, server }) => {
const intercepted = []; const intercepted = [];
await context.route('**/empty.html', route => { await context.route('**/empty.html', route => {
intercepted.push(1); intercepted.push(1);
route.continue(); route.fallback();
}); });
await context.route('**/empty.html', route => { await context.route('**/empty.html', route => {
intercepted.push(2); intercepted.push(2);
route.continue(); route.fallback();
}); });
await context.route('**/empty.html', route => { await context.route('**/empty.html', route => {
intercepted.push(3); intercepted.push(3);
route.continue(); route.fallback();
}); });
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
expect(intercepted).toEqual([3, 2, 1]); expect(intercepted).toEqual([3, 2, 1]);
@ -277,7 +277,7 @@ it('should not chain fulfill', async ({ context, page, server }) => {
route.fulfill({ status: 200, body: 'fulfilled' }); route.fulfill({ status: 200, body: 'fulfilled' });
}); });
await context.route('**/empty.html', route => { await context.route('**/empty.html', route => {
route.continue(); route.fallback();
}); });
const response = await page.goto(server.EMPTY_PAGE); const response = await page.goto(server.EMPTY_PAGE);
const body = await response.body(); const body = await response.body();
@ -294,38 +294,38 @@ it('should not chain abort', async ({ context, page, server }) => {
route.abort(); route.abort();
}); });
await context.route('**/empty.html', route => { await context.route('**/empty.html', route => {
route.continue(); route.fallback();
}); });
const e = await page.goto(server.EMPTY_PAGE).catch(e => e); const e = await page.goto(server.EMPTY_PAGE).catch(e => e);
expect(e).toBeTruthy(); expect(e).toBeTruthy();
expect(failed).toBeFalsy(); expect(failed).toBeFalsy();
}); });
it('should chain continue into page', async ({ context, page, server }) => { it('should chain fallback into page', async ({ context, page, server }) => {
const intercepted = []; const intercepted = [];
await context.route('**/empty.html', route => { await context.route('**/empty.html', route => {
intercepted.push(1); intercepted.push(1);
route.continue(); route.fallback();
}); });
await context.route('**/empty.html', route => { await context.route('**/empty.html', route => {
intercepted.push(2); intercepted.push(2);
route.continue(); route.fallback();
}); });
await context.route('**/empty.html', route => { await context.route('**/empty.html', route => {
intercepted.push(3); intercepted.push(3);
route.continue(); route.fallback();
}); });
await page.route('**/empty.html', route => { await page.route('**/empty.html', route => {
intercepted.push(4); intercepted.push(4);
route.continue(); route.fallback();
}); });
await page.route('**/empty.html', route => { await page.route('**/empty.html', route => {
intercepted.push(5); intercepted.push(5);
route.continue(); route.fallback();
}); });
await page.route('**/empty.html', route => { await page.route('**/empty.html', route => {
intercepted.push(6); intercepted.push(6);
route.continue(); route.fallback();
}); });
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
expect(intercepted).toEqual([6, 5, 4, 3, 2, 1]); expect(intercepted).toEqual([6, 5, 4, 3, 2, 1]);

View file

@ -43,19 +43,19 @@ it('should unroute', async ({ page, server }) => {
let intercepted = []; let intercepted = [];
await page.route('**/*', route => { await page.route('**/*', route => {
intercepted.push(1); intercepted.push(1);
route.continue(); route.fallback();
}); });
await page.route('**/empty.html', route => { await page.route('**/empty.html', route => {
intercepted.push(2); intercepted.push(2);
route.continue(); route.fallback();
}); });
await page.route('**/empty.html', route => { await page.route('**/empty.html', route => {
intercepted.push(3); intercepted.push(3);
route.continue(); route.fallback();
}); });
const handler4 = route => { const handler4 = route => {
intercepted.push(4); intercepted.push(4);
route.continue(); route.fallback();
}; };
await page.route('**/empty.html', handler4); await page.route('**/empty.html', handler4);
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
@ -825,10 +825,10 @@ it('should contain raw response header after fulfill', async ({ page, server })
expect(headers['content-type']).toBeTruthy(); expect(headers['content-type']).toBeTruthy();
}); });
for (const method of ['fulfill', 'continue', 'abort'] as const) { for (const method of ['fulfill', 'continue', 'fallback', 'abort'] as const) {
it(`route.${method} should throw if called twice`, async ({ page, server }) => { it(`route.${method} should throw if called twice`, async ({ page, server }) => {
const routePromise = new Promise<Route>(async resove => { const routePromise = new Promise<Route>(async resolve => {
await page.route('**/*', resove); await page.route('**/*', resolve);
}); });
page.goto(server.PREFIX + '/empty.html').catch(() => {}); page.goto(server.PREFIX + '/empty.html').catch(() => {});
const route = await routePromise; const route = await routePromise;
@ -838,19 +838,19 @@ for (const method of ['fulfill', 'continue', 'abort'] as const) {
}); });
} }
it('should chain continue', async ({ page, server }) => { it('should fall back', async ({ page, server }) => {
const intercepted = []; const intercepted = [];
await page.route('**/empty.html', route => { await page.route('**/empty.html', route => {
intercepted.push(1); intercepted.push(1);
route.continue(); route.fallback();
}); });
await page.route('**/empty.html', route => { await page.route('**/empty.html', route => {
intercepted.push(2); intercepted.push(2);
route.continue(); route.fallback();
}); });
await page.route('**/empty.html', route => { await page.route('**/empty.html', route => {
intercepted.push(3); intercepted.push(3);
route.continue(); route.fallback();
}); });
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
expect(intercepted).toEqual([3, 2, 1]); expect(intercepted).toEqual([3, 2, 1]);
@ -865,7 +865,7 @@ it('should not chain fulfill', async ({ page, server }) => {
route.fulfill({ status: 200, body: 'fulfilled' }); route.fulfill({ status: 200, body: 'fulfilled' });
}); });
await page.route('**/empty.html', route => { await page.route('**/empty.html', route => {
route.continue(); route.fallback();
}); });
const response = await page.goto(server.EMPTY_PAGE); const response = await page.goto(server.EMPTY_PAGE);
const body = await response.body(); const body = await response.body();
@ -882,14 +882,14 @@ it('should not chain abort', async ({ page, server }) => {
route.abort(); route.abort();
}); });
await page.route('**/empty.html', route => { await page.route('**/empty.html', route => {
route.continue(); route.fallback();
}); });
const e = await page.goto(server.EMPTY_PAGE).catch(e => e); const e = await page.goto(server.EMPTY_PAGE).catch(e => e);
expect(e).toBeTruthy(); expect(e).toBeTruthy();
expect(failed).toBeFalsy(); expect(failed).toBeFalsy();
}); });
it('should continue after exception', async ({ page, server }) => { it('should fall back after exception', async ({ page, server }) => {
await page.route('**/empty.html', route => { await page.route('**/empty.html', route => {
route.continue(); route.continue();
}); });
@ -897,7 +897,7 @@ it('should continue after exception', async ({ page, server }) => {
try { try {
await route.fulfill({ har: { path: 'file' }, response: {} as any }); await route.fulfill({ har: { path: 'file' }, response: {} as any });
} catch (e) { } catch (e) {
route.continue(); route.fallback();
} }
}); });
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
@ -908,7 +908,7 @@ it('should chain once', async ({ page, server }) => {
route.fulfill({ status: 200, body: 'fulfilled one' }); route.fulfill({ status: 200, body: 'fulfilled one' });
}, { times: 1 }); }, { times: 1 });
await page.route('**/empty.html', route => { await page.route('**/empty.html', route => {
route.continue(); route.fallback();
}, { times: 1 }); }, { times: 1 });
const response = await page.goto(server.EMPTY_PAGE); const response = await page.goto(server.EMPTY_PAGE);
const body = await response.body(); const body = await response.body();