feat(api): introduce route.fetch and route.fulfill(json) (#19184)

This commit is contained in:
Pavel Feldman 2022-11-30 17:26:19 -08:00 committed by GitHub
parent 878401ff2b
commit f0e8d8f074
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 319 additions and 54 deletions

View file

@ -74,7 +74,7 @@ async def handle(route, request):
"bar": None # remove "bar" header
}
await route.continue_(headers=headers)
}
await page.route("**/*", handle)
```
@ -87,7 +87,7 @@ def handle(route, request):
"bar": None # remove "bar" header
}
route.continue_(headers=headers)
}
page.route("**/*", handle)
```
@ -110,21 +110,21 @@ If set changes the request URL. New URL must have same protocol as original one.
* since: v1.8
- `method` <[string]>
If set changes the request method (e.g. GET or POST)
If set changes the request method (e.g. GET or POST).
### option: Route.continue.postData
* since: v1.8
* langs: js, python, java
- `postData` <[string]|[Buffer]>
If set changes the post data of request
If set changes the post data of request.
### option: Route.continue.postData
* since: v1.8
* langs: csharp
- `postData` <[Buffer]>
If set changes the post data of request
If set changes the post data of request.
### option: Route.continue.headers
* since: v1.8
@ -349,7 +349,7 @@ async def handle(route, request):
"bar": None # remove "bar" header
}
await route.fallback(headers=headers)
}
await page.route("**/*", handle)
```
@ -362,7 +362,7 @@ def handle(route, request):
"bar": None # remove "bar" header
}
route.fallback(headers=headers)
}
page.route("**/*", handle)
```
@ -386,21 +386,21 @@ affect the route matching, all the routes are matched using the original request
* since: v1.23
- `method` <[string]>
If set changes the request method (e.g. GET or POST)
If set changes the request method (e.g. GET or POST).
### option: Route.fallback.postData
* since: v1.23
* langs: js, python, java
- `postData` <[string]|[Buffer]>
If set changes the post data of request
If set changes the post data of request.
### option: Route.fallback.postData
* since: v1.23
* langs: csharp
- `postData` <[Buffer]>
If set changes the post data of request
If set changes the post data of request.
### option: Route.fallback.headers
* since: v1.23
@ -408,6 +408,93 @@ 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.fetch
* since: v1.29
- returns: <[APIResponse]>
Performs the request and fetches result without fulfilling it, so that the response
could be modified and then fulfilled.
**Usage**
```js
await page.route('https://dog.ceo/api/breeds/list/all', async route => {
const response = await route.fetch();
const json = await response.json();
json.message['big_red_dog'] = [];
await route.fulfill({ response, json });
});
```
```java
page.route("https://dog.ceo/api/breeds/list/all", route -> {
APIResponse response = route.fetch();
JsonObject json = new Gson().fromJson(response.text(), JsonObject.class);
json.set("big_red_dog", new JsonArray());
route.fulfill(new Route.FulfillOptions()
.setResponse(response)
.setBody(json.toString()));
});
```
```python async
async def handle(route):
response = await route.fulfill()
json = await response.json()
json["big_red_dog"] = []
await route.fulfill(response=response, json=json)
await page.route("https://dog.ceo/api/breeds/list/all", handle)
```
```python sync
def handle(route):
response = route.fulfill()
json = response.json()
json["big_red_dog"] = []
route.fulfill(response=response, json=json)
page.route("https://dog.ceo/api/breeds/list/all", handle)
```
```csharp
await page.RouteAsync("https://dog.ceo/api/breeds/list/all", async route =>
{
var response = await route.FetchAsync();
dynamic json = await response.JsonAsync();
json.big_red_dog = new string[] {};
await route.FulfillAsync(new() { Response = response, Json = json });
});
```
### option: Route.fetch.url
* since: v1.29
- `url` <[string]>
If set changes the request URL. New URL must have same protocol as original one.
### option: Route.fetch.method
* since: v1.29
- `method` <[string]>
If set changes the request method (e.g. GET or POST).
### option: Route.fetch.postData = %%-js-python-csharp-fetch-option-data-%%
* since: v1.29
### option: Route.fetch.data
* since: v1.29
* langs: csharp
- `postData` <[Buffer]>
If set changes the post data of request.
### option: Route.fetch.headers
* since: v1.29
- `headers` <[Object]<[string], [string]>>
If set changes the request HTTP headers. Header values will be converted to a string.
## async method: Route.fulfill
* since: v1.8
@ -451,10 +538,12 @@ page.route("**/*", lambda route: route.fulfill(
```
```csharp
await page.RouteAsync("**/*", route => route.FulfillAsync(
status: 404,
contentType: "text/plain",
body: "Not Found!"));
await page.RouteAsync("**/*", route => route.FulfillAsync(new ()
{
Status = 404,
ContentType = "text/plain",
Body = "Not Found!")
});
```
An example of serving static file:
@ -477,7 +566,7 @@ page.route("**/xhr_endpoint", lambda route: route.fulfill(path="mock_data.json")
```
```csharp
await page.RouteAsync("**/xhr_endpoint", route => route.FulfillAsync(new RouteFulfillOptions { Path = "mock_data.json" }));
await page.RouteAsync("**/xhr_endpoint", route => route.FulfillAsync(new() { Path = "mock_data.json" }));
```
### option: Route.fulfill.status
@ -519,6 +608,57 @@ Optional response body as text.
Optional response body as raw bytes.
### option: Route.fulfill.json
* since: v1.29
* langs: js, python
- `json` <[Serializable]>
JSON response. This method will set the content type to `application/json` if not set.
**Usage**
```js
await page.route('https://dog.ceo/api/breeds/list/all', async route => {
const json = {
message: { 'test_breed': [] }
};
await route.fulfill({ json });
});
```
```python async
async def handle(route):
json = { "test_breed": [] }
await route.fulfill(json=json)
await page.route("https://dog.ceo/api/breeds/list/all", handle)
```
```python sync
async def handle(route):
json = { "test_breed": [] }
route.fulfill(json=json)
page.route("https://dog.ceo/api/breeds/list/all", handle)
```
### option: Route.fulfill.json
* since: v1.29
* langs: csharp
- `json` <[JsonElement]>
JSON response. This method will set the content type to `application/json` if not set.
**Usage**
```csharp
await page.RouteAsync("https://dog.ceo/api/breeds/list/all", async route =>
{
var json = /* JsonElement with test payload */;
await route.FulfillAsync(new () { Json: json });
});
```
### option: Route.fulfill.path
* since: v1.8
- `path` <[path]>

View file

@ -345,6 +345,7 @@ var waitForResponseTask = page.WaitForResponseAsync(r => r.Url.Contains(token));
await page.GetByText("Update").ClickAsync();
var response = await waitForResponseTask;
```
## Handle requests
```js
@ -413,7 +414,7 @@ page.goto("https://example.com")
```csharp
await page.RouteAsync("**/api/fetch_data", async route => {
await route.FulfillAsync(status: 200, body: testData);
await route.FulfillAsync(new() { Status = 200, Body = testData });
});
await page.GotoAsync("https://example.com");
```

View file

@ -375,11 +375,12 @@ context.route('**', handle)
await context.RouteAsync("**", async route => {
if (route.request().serviceWorker() != null) {
// NB: calling route.request.frame here would THROW
await route.FulfillAsync(
contentType: "text/plain",
status: 200,
body: "from sw"
);
await route.FulfillAsync(new ()
{
ContentType = "text/plain",
Status = 200,
Body = "from sw"
});
} else {
await route.Continue()Async();
}

View file

@ -25,7 +25,6 @@ import { kBrowserOrContextClosedError } from '../common/errors';
import { assert, headersObjectToArray, isFilePayload, isString, objectToArray } from '../utils';
import { mkdirIfNeeded } from '../utils/fileUtils';
import { ChannelOwner } from './channelOwner';
import * as network from './network';
import { RawHeaders } from './network';
import type { FilePayload, Headers, StorageState } from './types';
import type { Playwright } from './playwright';
@ -142,17 +141,22 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
}
async fetch(urlOrRequest: string | api.Request, options: FetchOptions = {}): Promise<APIResponse> {
const url = isString(urlOrRequest) ? urlOrRequest : undefined;
const request = isString(urlOrRequest) ? undefined : urlOrRequest;
return this._innerFetch({ url, request, ...options });
}
async _innerFetch(options: FetchOptions & { url?: string, request?: api.Request } = {}): Promise<APIResponse> {
return this._wrapApiCall(async () => {
const request: network.Request | undefined = (urlOrRequest instanceof network.Request) ? urlOrRequest as network.Request : undefined;
assert(request || typeof urlOrRequest === 'string', 'First argument must be either URL string or Request');
assert(options.request || typeof options.url === 'string', 'First argument must be either URL string or Request');
assert((options.data === undefined ? 0 : 1) + (options.form === undefined ? 0 : 1) + (options.multipart === undefined ? 0 : 1) <= 1, `Only one of 'data', 'form' or 'multipart' can be specified`);
assert(options.maxRedirects === undefined || options.maxRedirects >= 0, `'maxRedirects' should be greater than or equal to '0'`);
const url = request ? request.url() : urlOrRequest as string;
const url = options.url !== undefined ? options.url : options.request!.url();
const params = objectToArray(options.params);
const method = options.method || request?.method();
const method = options.method || options.request?.method();
const maxRedirects = options.maxRedirects;
// Cannot call allHeaders() here as the request may be paused inside route handler.
const headersObj = options.headers || request?.headers() ;
const headersObj = options.headers || options.request?.headers() ;
const headers = headersObj ? headersObjectToArray(headersObj) : undefined;
let jsonData: any;
let formData: channels.NameValue[] | undefined;
@ -190,7 +194,7 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
}
}
if (postDataBuffer === undefined && jsonData === undefined && formData === undefined && multipartData === undefined)
postDataBuffer = request?.postDataBuffer() || undefined;
postDataBuffer = options.request?.postDataBuffer() || undefined;
const result = await this._channel.fetch({
url,
params,

View file

@ -307,7 +307,14 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
this._reportHandled(true);
}
async fulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, path?: string } = {}) {
async fetch(options: FallbackOverrides = {}) {
return await this._wrapApiCall(async () => {
const context = this.request()._context();
return context.request._innerFetch({ request: this.request(), data: options.postData, ...options });
});
}
async fulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, json?: any, path?: string } = {}) {
this._checkNotHandled();
await this._wrapApiCall(async () => {
await this._innerFulfill(options);
@ -315,10 +322,15 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
});
}
private async _innerFulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, path?: string } = {}): Promise<void> {
private async _innerFulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, json?: any, path?: string } = {}): Promise<void> {
let fetchResponseUid;
let { status: statusOption, headers: headersOption, body } = options;
if (options.json !== undefined) {
assert(options.body === undefined, 'Can specify either body or json parameters');
body = JSON.stringify(options.json);
}
if (options.response instanceof APIResponse) {
statusOption ??= options.response.status();
headersOption ??= options.response.headers();
@ -351,6 +363,8 @@ export class Route extends ChannelOwner<channels.RouteChannel> implements api.Ro
headers[header.toLowerCase()] = String(headersOption![header]);
if (options.contentType)
headers['content-type'] = String(options.contentType);
else if (options.json)
headers['content-type'] = 'application/json';
else if (options.path)
headers['content-type'] = mime.getType(options.path) || 'application/octet-stream';
if (length && !('content-length' in headers))

View file

@ -17021,12 +17021,12 @@ export interface Route {
headers?: { [key: string]: string; };
/**
* If set changes the request method (e.g. GET or POST)
* If set changes the request method (e.g. GET or POST).
*/
method?: string;
/**
* If set changes the post data of request
* If set changes the post data of request.
*/
postData?: string|Buffer;
@ -17108,12 +17108,12 @@ export interface Route {
headers?: { [key: string]: string; };
/**
* If set changes the request method (e.g. GET or POST)
* If set changes the request method (e.g. GET or POST).
*/
method?: string;
/**
* If set changes the post data of request
* If set changes the post data of request.
*/
postData?: string|Buffer;
@ -17124,6 +17124,47 @@ export interface Route {
url?: string;
}): Promise<void>;
/**
* Performs the request and fetches result without fulfilling it, so that the response could be modified and then
* fulfilled.
*
* **Usage**
*
* ```js
* await page.route('https://dog.ceo/api/breeds/list/all', async route => {
* const response = await route.fetch();
* const json = await response.json();
* json.message['big_red_dog'] = [];
* await route.fulfill({ response, json });
* });
* ```
*
* @param options
*/
fetch(options?: {
/**
* Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string
* and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type`
* header will be set to `application/octet-stream` if not explicitly set.
*/
data?: string|Buffer|Serializable;
/**
* If set changes the request HTTP headers. Header values will be converted to a string.
*/
headers?: { [key: string]: string; };
/**
* If set changes the request method (e.g. GET or POST).
*/
method?: string;
/**
* If set changes the request URL. New URL must have same protocol as original one.
*/
url?: string;
}): Promise<APIResponse>;
/**
* Fulfills route's request with given response.
*
@ -17165,6 +17206,23 @@ export interface Route {
*/
headers?: { [key: string]: string; };
/**
* JSON response. This method will set the content type to `application/json` if not set.
*
* **Usage**
*
* ```js
* await page.route('https://dog.ceo/api/breeds/list/all', async route => {
* const json = {
* message: { 'test_breed': [] }
* };
* await route.fulfill({ json });
* });
* ```
*
*/
json?: Serializable;
/**
* File path to respond with. The content type will be inferred from file extension. If `path` is a relative path,
* then it is resolved relative to the current working directory.

View file

@ -797,11 +797,6 @@ it('should dispose when context closes', async function({ context, server }) {
expect(error.message).toContain('Response has been disposed');
});
it('should throw on invalid first argument', async function({ context }) {
const error = await context.request.get({} as any).catch(e => e);
expect(error.message).toContain('First argument must be either URL string or Request');
});
it('should override request parameters', async function({ context, page, server }) {
const [pageReq] = await Promise.all([
page.waitForRequest('**/*'),

View file

@ -380,3 +380,18 @@ it('should fulfill preload link requests', async ({ page, server, browserName })
expect(color).toBe('rgb(0, 128, 0)');
});
it('should fulfill json', async ({ page, server }) => {
await page.route('**/*', route => {
route.fulfill({
status: 201,
headers: {
foo: 'bar'
},
json: { bar: 'baz' },
});
});
const response = await page.goto(server.EMPTY_PAGE);
expect(response.status()).toBe(201);
expect(response.headers()['content-type']).toBe('application/json');
expect(await page.evaluate(() => document.body.textContent)).toBe(JSON.stringify({ bar: 'baz' }));
});

View file

@ -20,20 +20,18 @@ import { expect, test as base } from './pageTest';
import fs from 'fs';
import path from 'path';
const it = base.extend<{
// We access test servers at 10.0.2.2 from inside the browser on Android,
// which is actually forwarded to the desktop localhost.
// To use request such an url with apiRequestContext on the desktop, we need to change it back to localhost.
rewriteAndroidLoopbackURL(url: string): string
}>({
rewriteAndroidLoopbackURL: ({ isAndroid }, use) => use(givenURL => {
if (!isAndroid)
return givenURL;
const requestURL = new URL(givenURL);
requestURL.hostname = 'localhost';
return requestURL.toString();
})
});
// We access test servers at 10.0.2.2 from inside the browser on Android,
// which is actually forwarded to the desktop localhost.
// To use request such an url with apiRequestContext on the desktop, we need to change it back to localhost.
const it = base.extend<{ rewriteAndroidLoopbackURL(url: string): string }>({
rewriteAndroidLoopbackURL: ({ isAndroid }, use) => use(givenURL => {
if (!isAndroid)
return givenURL;
const requestURL = new URL(givenURL);
requestURL.hostname = 'localhost';
return requestURL.toString();
})
});
it('should fulfill intercepted response', async ({ page, server, isElectron, isAndroid }) => {
it.fixme(isElectron, 'error: Browser context management is not supported.');
@ -194,4 +192,43 @@ it('should intercept multipart/form-data request body', async ({ page, server, a
]);
expect(request.method()).toBe('POST');
expect(request.postData()).toContain(fs.readFileSync(filePath, 'utf8'));
});
});
it('should fulfill intercepted response using alias', async ({ page, server, isElectron, isAndroid }) => {
it.fixme(isElectron, 'error: Browser context management is not supported.');
it.skip(isAndroid, 'The internal Android localhost (10.0.0.2) != the localhost on the host');
await page.route('**/*', async route => {
const response = await route.fetch();
await route.fulfill({ response });
});
const response = await page.goto(server.PREFIX + '/empty.html');
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('text/html');
});
it('should intercept with url override', async ({ page, server, isElectron, isAndroid }) => {
it.fixme(isElectron, 'error: Browser context management is not supported.');
it.skip(isAndroid, 'The internal Android localhost (10.0.0.2) != the localhost on the host');
await page.route('**/*.html', async route => {
const response = await route.fetch({ url: server.PREFIX + '/one-style.html' });
await route.fulfill({ response });
});
const response = await page.goto(server.PREFIX + '/empty.html');
expect(response.status()).toBe(200);
expect((await response.body()).toString()).toContain('one-style.css');
});
it('should intercept with post data override', async ({ page, server, isElectron, isAndroid }) => {
it.fixme(isElectron, 'error: Browser context management is not supported.');
it.skip(isAndroid, 'The internal Android localhost (10.0.0.2) != the localhost on the host');
const requestPromise = server.waitForRequest('/empty.html');
await page.route('**/*.html', async route => {
const response = await route.fetch({
data: { 'foo': 'bar' },
});
await route.fulfill({ response });
});
await page.goto(server.PREFIX + '/empty.html');
const request = await requestPromise;
expect((await request.postBody).toString()).toBe(JSON.stringify({ 'foo': 'bar' }));
});