From f0e8d8f0744b4ba431a599abaa3999aff760cd85 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 30 Nov 2022 17:26:19 -0800 Subject: [PATCH] feat(api): introduce route.fetch and route.fulfill(json) (#19184) --- docs/src/api/class-route.md | 170 ++++++++++++++++-- docs/src/network.md | 3 +- ...-workers-experimental-network-events-js.md | 11 +- packages/playwright-core/src/client/fetch.ts | 18 +- .../playwright-core/src/client/network.ts | 18 +- packages/playwright-core/types/types.d.ts | 66 ++++++- tests/library/browsercontext-fetch.spec.ts | 5 - tests/page/page-request-fulfill.spec.ts | 15 ++ tests/page/page-request-intercept.spec.ts | 67 +++++-- 9 files changed, 319 insertions(+), 54 deletions(-) diff --git a/docs/src/api/class-route.md b/docs/src/api/class-route.md index 52ff8229eb..37293c0a2f 100644 --- a/docs/src/api/class-route.md +++ b/docs/src/api/class-route.md @@ -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]> diff --git a/docs/src/network.md b/docs/src/network.md index 9ef45b7005..a704fa49d4 100644 --- a/docs/src/network.md +++ b/docs/src/network.md @@ -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"); ``` diff --git a/docs/src/service-workers-experimental-network-events-js.md b/docs/src/service-workers-experimental-network-events-js.md index 35c1fdcb1a..2846d0d84e 100644 --- a/docs/src/service-workers-experimental-network-events-js.md +++ b/docs/src/service-workers-experimental-network-events-js.md @@ -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(); } diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index 8083eb5fbf..7a48c6ede0 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -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 { + 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 { 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 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 implements api.Ro }); } - private async _innerFulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, path?: string } = {}): Promise { + private async _innerFulfill(options: { response?: api.APIResponse, status?: number, headers?: Headers, contentType?: string, body?: string | Buffer, json?: any, path?: string } = {}): Promise { 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 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)) diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 70bb70c722..024e48d0df 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -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; + /** + * 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; + /** * 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. diff --git a/tests/library/browsercontext-fetch.spec.ts b/tests/library/browsercontext-fetch.spec.ts index ac8f85ae64..eaf8833e56 100644 --- a/tests/library/browsercontext-fetch.spec.ts +++ b/tests/library/browsercontext-fetch.spec.ts @@ -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('**/*'), diff --git a/tests/page/page-request-fulfill.spec.ts b/tests/page/page-request-fulfill.spec.ts index 682f208c76..6359aa4fdd 100644 --- a/tests/page/page-request-fulfill.spec.ts +++ b/tests/page/page-request-fulfill.spec.ts @@ -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' })); +}); diff --git a/tests/page/page-request-intercept.spec.ts b/tests/page/page-request-intercept.spec.ts index 5d46a10da0..5c686d9d05 100644 --- a/tests/page/page-request-intercept.spec.ts +++ b/tests/page/page-request-intercept.spec.ts @@ -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')); -}); \ No newline at end of file +}); + +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' })); +});