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.
## 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
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) {
const routes = this._routes.filter(r => r.matches(request.url()));
const nextRoute = async () => {
const routeHandler = routes.shift();
if (!routeHandler) {
await route._finalContinue();
return;
}
const routeHandlers = this._routes.filter(r => r.matches(request.url()));
for (const routeHandler of routeHandlers) {
if (routeHandler.willExpire())
this._routes.splice(this._routes.indexOf(routeHandler), 1);
await new Promise<void>(f => {
routeHandler.handle(route, request, async done => {
if (!done)
await nextRoute();
f();
});
});
};
await nextRoute();
if (!this._routes.length)
this._wrapApiCall(() => this._disableInterception(), true).catch(() => {});
const handled = await routeHandler.handle(route, request);
if (!this._routes.length)
this._wrapApiCall(() => this._disableInterception(), true).catch(() => {});
if (handled)
return;
}
await route._innerContinue({}, true);
}
async _onBinding(bindingCall: BindingCall) {

View file

@ -229,8 +229,7 @@ type OverridesForContinue = {
};
export class Route extends ChannelOwner<channels.RouteChannel> implements api.Route {
private _pendingContinueOverrides: OverridesForContinue | undefined;
private _routeChain: ((done: boolean) => Promise<void>) | null = null;
private _handlingPromise: ManualPromise<boolean> | null = null;
static from(route: channels.RouteChannel): Route {
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>) {
this._routeChain = routeChain;
_startHandling(): Promise<boolean> {
this._handlingPromise = new ManualPromise();
return this._handlingPromise;
}
async fallback() {
this._checkNotHandled();
this._reportHandled(false);
}
async abort(errorCode?: string) {
this._checkNotHandled();
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 } = {}) {
this._checkNotHandled();
await this._wrapApiCall(async () => {
const fallback = await this._innerFulfill(options);
switch (fallback) {
case 'abort': await this.abort(); 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 = {}) {
if (!this._routeChain)
this._checkNotHandled();
await this._innerContinue(options);
this._reportHandled(true);
}
_checkNotHandled() {
if (!this._handlingPromise)
throw new Error('Route is already handled!');
this._pendingContinueOverrides = { ...this._pendingContinueOverrides, ...options };
await this._followChain(false);
}
async _followChain(done: boolean) {
const chain = this._routeChain!;
this._routeChain = null;
await chain(done);
_reportHandled(done: boolean) {
const chain = this._handlingPromise!;
this._handlingPromise = null;
chain.resolve(done);
}
async _finalContinue() {
const options = this._pendingContinueOverrides || {};
async _innerContinue(options: OverridesForContinue = {}, internal = false) {
return await this._wrapApiCall(async () => {
const postDataBuffer = isString(options.postData) ? Buffer.from(options.postData, 'utf8') : options.postData;
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,
postData: postDataBuffer ? postDataBuffer.toString('base64') : undefined,
}));
}, !this._pendingContinueOverrides);
}, !!internal);
}
}
@ -593,12 +603,16 @@ export class RouteHandler {
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;
route._startHandling(routeChain);
const handledPromise = route._startHandling();
// Extract handler into a variable to avoid [RouteHandler.handler] in the stack.
const handler = this.handler;
handler(route, request);
const [handled] = await Promise.all([
handledPromise,
handler(route, request),
]);
return handled;
}
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) {
const routes = this._routes.filter(r => r.matches(request.url()));
const nextRoute = async () => {
const routeHandler = routes.shift();
if (!routeHandler) {
await this._browserContext._onRoute(route, request);
return;
}
const routeHandlers = this._routes.filter(r => r.matches(request.url()));
for (const routeHandler of routeHandlers) {
if (routeHandler.willExpire())
this._routes.splice(this._routes.indexOf(routeHandler), 1);
await new Promise<void>(f => {
routeHandler.handle(route, request, async done => {
if (!done)
await nextRoute();
f();
});
});
};
await nextRoute();
if (!this._routes.length)
this._wrapApiCall(() => this._disableInterception(), true).catch(() => {});
const handled = await routeHandler.handle(route, request);
if (!this._routes.length)
this._wrapApiCall(() => this._disableInterception(), true).catch(() => {});
if (handled)
return;
}
await this._browserContext._onRoute(route, request);
}
async _onBinding(bindingCall: BindingCall) {

View file

@ -14833,6 +14833,35 @@ export interface Route {
url?: string;
}): 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.
*

View file

@ -47,19 +47,19 @@ it('should unroute', async ({ browser, server }) => {
let intercepted = [];
await context.route('**/*', route => {
intercepted.push(1);
route.continue();
route.fallback();
});
await context.route('**/empty.html', route => {
intercepted.push(2);
route.continue();
route.fallback();
});
await context.route('**/empty.html', route => {
intercepted.push(3);
route.continue();
route.fallback();
});
const handler4 = route => {
intercepted.push(4);
route.continue();
route.fallback();
};
await context.route('**/empty.html', handler4);
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('');
});
it('should chain continue', async ({ context, page, server }) => {
it('should chain fallback', async ({ context, page, server }) => {
const intercepted = [];
await context.route('**/empty.html', route => {
intercepted.push(1);
route.continue();
route.fallback();
});
await context.route('**/empty.html', route => {
intercepted.push(2);
route.continue();
route.fallback();
});
await context.route('**/empty.html', route => {
intercepted.push(3);
route.continue();
route.fallback();
});
await page.goto(server.EMPTY_PAGE);
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' });
});
await context.route('**/empty.html', route => {
route.continue();
route.fallback();
});
const response = await page.goto(server.EMPTY_PAGE);
const body = await response.body();
@ -294,38 +294,38 @@ it('should not chain abort', async ({ context, page, server }) => {
route.abort();
});
await context.route('**/empty.html', route => {
route.continue();
route.fallback();
});
const e = await page.goto(server.EMPTY_PAGE).catch(e => e);
expect(e).toBeTruthy();
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 = [];
await context.route('**/empty.html', route => {
intercepted.push(1);
route.continue();
route.fallback();
});
await context.route('**/empty.html', route => {
intercepted.push(2);
route.continue();
route.fallback();
});
await context.route('**/empty.html', route => {
intercepted.push(3);
route.continue();
route.fallback();
});
await page.route('**/empty.html', route => {
intercepted.push(4);
route.continue();
route.fallback();
});
await page.route('**/empty.html', route => {
intercepted.push(5);
route.continue();
route.fallback();
});
await page.route('**/empty.html', route => {
intercepted.push(6);
route.continue();
route.fallback();
});
await page.goto(server.EMPTY_PAGE);
expect(intercepted).toEqual([6, 5, 4, 3, 2, 1]);

View file

@ -43,19 +43,19 @@ it('should unroute', async ({ page, server }) => {
let intercepted = [];
await page.route('**/*', route => {
intercepted.push(1);
route.continue();
route.fallback();
});
await page.route('**/empty.html', route => {
intercepted.push(2);
route.continue();
route.fallback();
});
await page.route('**/empty.html', route => {
intercepted.push(3);
route.continue();
route.fallback();
});
const handler4 = route => {
intercepted.push(4);
route.continue();
route.fallback();
};
await page.route('**/empty.html', handler4);
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();
});
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 }) => {
const routePromise = new Promise<Route>(async resove => {
await page.route('**/*', resove);
const routePromise = new Promise<Route>(async resolve => {
await page.route('**/*', resolve);
});
page.goto(server.PREFIX + '/empty.html').catch(() => {});
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 = [];
await page.route('**/empty.html', route => {
intercepted.push(1);
route.continue();
route.fallback();
});
await page.route('**/empty.html', route => {
intercepted.push(2);
route.continue();
route.fallback();
});
await page.route('**/empty.html', route => {
intercepted.push(3);
route.continue();
route.fallback();
});
await page.goto(server.EMPTY_PAGE);
expect(intercepted).toEqual([3, 2, 1]);
@ -865,7 +865,7 @@ it('should not chain fulfill', async ({ page, server }) => {
route.fulfill({ status: 200, body: 'fulfilled' });
});
await page.route('**/empty.html', route => {
route.continue();
route.fallback();
});
const response = await page.goto(server.EMPTY_PAGE);
const body = await response.body();
@ -882,14 +882,14 @@ it('should not chain abort', async ({ page, server }) => {
route.abort();
});
await page.route('**/empty.html', route => {
route.continue();
route.fallback();
});
const e = await page.goto(server.EMPTY_PAGE).catch(e => e);
expect(e).toBeTruthy();
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 => {
route.continue();
});
@ -897,7 +897,7 @@ it('should continue after exception', async ({ page, server }) => {
try {
await route.fulfill({ har: { path: 'file' }, response: {} as any });
} catch (e) {
route.continue();
route.fallback();
}
});
await page.goto(server.EMPTY_PAGE);
@ -908,7 +908,7 @@ it('should chain once', async ({ page, server }) => {
route.fulfill({ status: 200, body: 'fulfilled one' });
}, { times: 1 });
await page.route('**/empty.html', route => {
route.continue();
route.fallback();
}, { times: 1 });
const response = await page.goto(server.EMPTY_PAGE);
const body = await response.body();