/** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import fs from 'fs'; import path from 'path'; import * as util from 'util'; import type { Serializable } from '../../types/structs'; import type * as api from '../../types/types'; import type { HeadersArray, NameValue } from '../common/types'; import type * as channels from '@protocol/channels'; import { kBrowserOrContextClosedError } from '../common/errors'; import { assert, headersObjectToArray, isString } from '../utils'; import { mkdirIfNeeded } from '../utils/fileUtils'; import { ChannelOwner } from './channelOwner'; import { RawHeaders } from './network'; import type { FilePayload, Headers, StorageState } from './types'; import type { Playwright } from './playwright'; import { createInstrumentation } from './clientInstrumentation'; import { Tracing } from './tracing'; export type FetchOptions = { params?: { [key: string]: string; }, method?: string, headers?: Headers, data?: string | Buffer | Serializable, form?: { [key: string]: string|number|boolean; }; multipart?: { [key: string]: string|number|boolean|fs.ReadStream|FilePayload; }; timeout?: number, failOnStatusCode?: boolean, ignoreHTTPSErrors?: boolean, maxRedirects?: number, }; type NewContextOptions = Omit & { extraHTTPHeaders?: Headers, storageState?: string | StorageState, }; type RequestWithBodyOptions = Omit; export class APIRequest implements api.APIRequest { private _playwright: Playwright; readonly _contexts = new Set(); // Instrumentation. _onDidCreateContext?: (context: APIRequestContext) => Promise; _onWillCloseContext?: (context: APIRequestContext) => Promise; constructor(playwright: Playwright) { this._playwright = playwright; } async newContext(options: NewContextOptions = {}): Promise { const storageState = typeof options.storageState === 'string' ? JSON.parse(await fs.promises.readFile(options.storageState, 'utf8')) : options.storageState; const context = APIRequestContext.from((await this._playwright._channel.newRequest({ ...options, extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined, storageState, })).request); this._contexts.add(context); context._request = this; await this._onDidCreateContext?.(context); return context; } } export class APIRequestContext extends ChannelOwner implements api.APIRequestContext { _request?: APIRequest; readonly _tracing: Tracing; static from(channel: channels.APIRequestContextChannel): APIRequestContext { return (channel as any)._object; } constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.APIRequestContextInitializer) { super(parent, type, guid, initializer, createInstrumentation()); this._tracing = Tracing.from(initializer.tracing); } async dispose(): Promise { await this._request?._onWillCloseContext?.(this); await this._channel.dispose(); this._request?._contexts.delete(this); } async delete(url: string, options?: RequestWithBodyOptions): Promise { return this.fetch(url, { ...options, method: 'DELETE', }); } async head(url: string, options?: RequestWithBodyOptions): Promise { return this.fetch(url, { ...options, method: 'HEAD', }); } async get(url: string, options?: RequestWithBodyOptions): Promise { return this.fetch(url, { ...options, method: 'GET', }); } async patch(url: string, options?: RequestWithBodyOptions): Promise { return this.fetch(url, { ...options, method: 'PATCH', }); } async post(url: string, options?: RequestWithBodyOptions): Promise { return this.fetch(url, { ...options, method: 'POST', }); } async put(url: string, options?: RequestWithBodyOptions): Promise { return this.fetch(url, { ...options, method: 'PUT', }); } async fetch(urlOrRequest: string | api.Request, options: FetchOptions = {}): Promise { 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 () => { 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 = options.url !== undefined ? options.url : options.request!.url(); const params = objectToArray(options.params); 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 || options.request?.headers() ; const headers = headersObj ? headersObjectToArray(headersObj) : undefined; let jsonData: any; let formData: channels.NameValue[] | undefined; let multipartData: channels.FormField[] | undefined; let postDataBuffer: Buffer | undefined; if (options.data !== undefined) { if (isString(options.data)) { if (isJsonContentType(headers)) jsonData = options.data; else postDataBuffer = Buffer.from(options.data, 'utf8'); } else if (Buffer.isBuffer(options.data)) { postDataBuffer = options.data; } else if (typeof options.data === 'object' || typeof options.data === 'number' || typeof options.data === 'boolean') { jsonData = options.data; } else { throw new Error(`Unexpected 'data' type`); } } else if (options.form) { formData = objectToArray(options.form); } else if (options.multipart) { multipartData = []; // Convert file-like values to ServerFilePayload structs. for (const [name, value] of Object.entries(options.multipart)) { if (isFilePayload(value)) { const payload = value as FilePayload; if (!Buffer.isBuffer(payload.buffer)) throw new Error(`Unexpected buffer type of 'data.${name}'`); multipartData.push({ name, file: filePayloadToJson(payload) }); } else if (value instanceof fs.ReadStream) { multipartData.push({ name, file: await readStreamToJson(value as fs.ReadStream) }); } else { multipartData.push({ name, value: String(value) }); } } } if (postDataBuffer === undefined && jsonData === undefined && formData === undefined && multipartData === undefined) postDataBuffer = options.request?.postDataBuffer() || undefined; const fixtures = { __testHookLookup: (options as any).__testHookLookup }; const result = await this._channel.fetch({ url, params, method, headers, postData: postDataBuffer, jsonData, formData, multipartData, timeout: options.timeout, failOnStatusCode: options.failOnStatusCode, ignoreHTTPSErrors: options.ignoreHTTPSErrors, maxRedirects: maxRedirects, ...fixtures }); return new APIResponse(this, result.response); }); } async storageState(options: { path?: string } = {}): Promise { const state = await this._channel.storageState(); if (options.path) { await mkdirIfNeeded(options.path); await fs.promises.writeFile(options.path, JSON.stringify(state, undefined, 2), 'utf8'); } return state; } } export class APIResponse implements api.APIResponse { private readonly _initializer: channels.APIResponse; private readonly _headers: RawHeaders; readonly _request: APIRequestContext; constructor(context: APIRequestContext, initializer: channels.APIResponse) { this._request = context; this._initializer = initializer; this._headers = new RawHeaders(this._initializer.headers); } ok(): boolean { return this._initializer.status >= 200 && this._initializer.status <= 299; } url(): string { return this._initializer.url; } status(): number { return this._initializer.status; } statusText(): string { return this._initializer.statusText; } headers(): Headers { return this._headers.headers(); } headersArray(): HeadersArray { return this._headers.headersArray(); } async body(): Promise { try { const result = await this._request._channel.fetchResponseBody({ fetchUid: this._fetchUid() }); if (result.binary === undefined) throw new Error('Response has been disposed'); return result.binary; } catch (e) { if (e.message.includes(kBrowserOrContextClosedError)) throw new Error('Response has been disposed'); throw e; } } async text(): Promise { const content = await this.body(); return content.toString('utf8'); } async json(): Promise { const content = await this.text(); return JSON.parse(content); } async dispose(): Promise { await this._request._channel.disposeAPIResponse({ fetchUid: this._fetchUid() }); } [util.inspect.custom]() { const headers = this.headersArray().map(({ name, value }) => ` ${name}: ${value}`); return `APIResponse: ${this.status()} ${this.statusText()}\n${headers.join('\n')}`; } _fetchUid(): string { return this._initializer.fetchUid; } async _fetchLog(): Promise { const { log } = await this._request._channel.fetchLog({ fetchUid: this._fetchUid() }); return log; } } type ServerFilePayload = NonNullable; function filePayloadToJson(payload: FilePayload): ServerFilePayload { return { name: payload.name, mimeType: payload.mimeType, buffer: payload.buffer, }; } async function readStreamToJson(stream: fs.ReadStream): Promise { const buffer = await new Promise((resolve, reject) => { const chunks: Buffer[] = []; stream.on('data', chunk => chunks.push(chunk as Buffer)); stream.on('end', () => resolve(Buffer.concat(chunks))); stream.on('error', err => reject(err)); }); const streamPath: string = Buffer.isBuffer(stream.path) ? stream.path.toString('utf8') : stream.path; return { name: path.basename(streamPath), buffer, }; } function isJsonContentType(headers?: HeadersArray): boolean { if (!headers) return false; for (const { name, value } of headers) { if (name.toLocaleLowerCase() === 'content-type') return value === 'application/json'; } return false; } function objectToArray(map?: { [key: string]: any }): NameValue[] | undefined { if (!map) return undefined; const result = []; for (const [name, value] of Object.entries(map)) result.push({ name, value: String(value) }); return result; } function isFilePayload(value: any): boolean { return typeof value === 'object' && value['name'] && value['mimeType'] && value['buffer']; }