diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index dacd5dd343..fea3b70a7e 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -159,7 +159,10 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.delete.form = %%-csharp-fetch-option-form-%% * since: v1.17 -### option: APIRequestContext.delete.multipart = %%-js-python-fetch-option-multipart-%% +### option: APIRequestContext.delete.multipart = %%-js-fetch-option-multipart-%% +* since: v1.17 + +### option: APIRequestContext.delete.multipart = %%-python-fetch-option-multipart-%% * since: v1.17 ### option: APIRequestContext.delete.multipart = %%-csharp-fetch-option-multipart-%% @@ -324,7 +327,10 @@ If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/ ### option: APIRequestContext.fetch.form = %%-csharp-fetch-option-form-%% * since: v1.16 -### option: APIRequestContext.fetch.multipart = %%-js-python-fetch-option-multipart-%% +### option: APIRequestContext.fetch.multipart = %%-js-fetch-option-multipart-%% +* since: v1.16 + +### option: APIRequestContext.fetch.multipart = %%-python-fetch-option-multipart-%% * since: v1.16 ### option: APIRequestContext.fetch.multipart = %%-csharp-fetch-option-multipart-%% @@ -410,7 +416,10 @@ await request.GetAsync("https://example.com/api/getText", new() { Params = query ### option: APIRequestContext.get.form = %%-csharp-fetch-option-form-%% * since: v1.26 -### option: APIRequestContext.get.multipart = %%-js-python-fetch-option-multipart-%% +### option: APIRequestContext.get.multipart = %%-js-fetch-option-multipart-%% +* since: v1.26 + +### option: APIRequestContext.get.multipart = %%-python-fetch-option-multipart-%% * since: v1.26 ### option: APIRequestContext.get.multipart = %%-csharp-fetch-option-multipart-%% @@ -460,7 +469,10 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.head.form = %%-csharp-fetch-option-form-%% * since: v1.26 -### option: APIRequestContext.head.multipart = %%-js-python-fetch-option-multipart-%% +### option: APIRequestContext.head.multipart = %%-js-fetch-option-multipart-%% +* since: v1.26 + +### option: APIRequestContext.head.multipart = %%-python-fetch-option-multipart-%% * since: v1.26 ### option: APIRequestContext.head.multipart = %%-csharp-fetch-option-multipart-%% @@ -510,7 +522,10 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.patch.form = %%-csharp-fetch-option-form-%% * since: v1.16 -### option: APIRequestContext.patch.multipart = %%-js-python-fetch-option-multipart-%% +### option: APIRequestContext.patch.multipart = %%-js-fetch-option-multipart-%% +* since: v1.16 + +### option: APIRequestContext.patch.multipart = %%-python-fetch-option-multipart-%% * since: v1.16 ### option: APIRequestContext.patch.multipart = %%-csharp-fetch-option-multipart-%% @@ -566,7 +581,7 @@ api_request_context.post("https://example.com/api/createBook", data=data) ```csharp var data = new Dictionary() { - { "firstNam", "John" }, + { "firstName", "John" }, { "lastName", "Doe" } }; await request.PostAsync("https://example.com/api/createBook", new() { DataObject = data }); @@ -690,7 +705,10 @@ await request.PostAsync("https://example.com/api/uploadScript", new() { Multipar ### option: APIRequestContext.post.form = %%-csharp-fetch-option-form-%% * since: v1.16 -### option: APIRequestContext.post.multipart = %%-js-python-fetch-option-multipart-%% +### option: APIRequestContext.post.multipart = %%-js-fetch-option-multipart-%% +* since: v1.16 + +### option: APIRequestContext.post.multipart = %%-python-fetch-option-multipart-%% * since: v1.16 ### option: APIRequestContext.post.multipart = %%-csharp-fetch-option-multipart-%% @@ -740,7 +758,10 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.put.form = %%-csharp-fetch-option-form-%% * since: v1.16 -### option: APIRequestContext.put.multipart = %%-js-python-fetch-option-multipart-%% +### option: APIRequestContext.put.multipart = %%-js-fetch-option-multipart-%% +* since: v1.16 + +### option: APIRequestContext.put.multipart = %%-python-fetch-option-multipart-%% * since: v1.16 ### option: APIRequestContext.put.multipart = %%-csharp-fetch-option-multipart-%% diff --git a/docs/src/api/class-formdata.md b/docs/src/api/class-formdata.md index c578021653..5ca85b361d 100644 --- a/docs/src/api/class-formdata.md +++ b/docs/src/api/class-formdata.md @@ -14,6 +14,77 @@ FormData form = FormData.create() page.request().post("http://localhost/submit", RequestOptions.create().setForm(form)); ``` +## method: FormData.append +* since: v1.44 +- returns: <[FormData]> + +Appends a new value onto an existing key inside a FormData object, or adds the key if it +does not already exist. File values can be passed either as `Path` or as `FilePayload`. +Multiple fields with the same name can be added. + +The difference between [`method: FormData.set`] and [`method: FormData.append`] is that if the specified key already exists, +[`method: FormData.set`] will overwrite all existing values with the new one, whereas [`method: FormData.append`] will append +the new value onto the end of the existing set of values. + +```java +import com.microsoft.playwright.options.FormData; +... +FormData form = FormData.create() + // Only name and value are set. + .append("firstName", "John") + // Name and value are set, filename and Content-Type are inferred from the file path. + .append("attachment", Paths.get("pic.jpg")) + // Name, value, filename and Content-Type are set. + .append("attachment", new FilePayload("table.csv", "text/csv", Files.readAllBytes(Paths.get("my-tble.csv")))); +page.request().post("http://localhost/submit", RequestOptions.create().setForm(form)); +``` + +```csharp +var multipart = Context.APIRequest.CreateFormData(); +// Only name and value are set. +multipart.Append("firstName", "John"); +// Name, value, filename and Content-Type are set. +multipart.Append("attachment", new FilePayload() +{ + Name = "pic.jpg", + MimeType = "image/jpeg", + Buffer = File.ReadAllBytes("john.jpg") +}); +// Name, value, filename and Content-Type are set. +multipart.Append("attachment", new FilePayload() +{ + Name = "table.csv", + MimeType = "text/csv", + Buffer = File.ReadAllBytes("my-tble.csv") +}); +await Page.APIRequest.PostAsync("https://localhost/submit", new() { Multipart = multipart }); +``` + +### param: FormData.append.name +* since: v1.44 +- `name` <[string]> + +Field name. + +### param: FormData.append.value +* since: v1.44 +- `value` <[string]|[boolean]|[int]|[Path]|[Object]> + - `name` <[string]> File name + - `mimeType` <[string]> File type + - `buffer` <[Buffer]> File content + +Field value. + +### param: FormData.append.value +* since: v1.44 +* langs: csharp +- `value` <[string]|[boolean]|[int]|[Object]> + - `name` <[string]> File name + - `mimeType` <[string]> File type + - `buffer` <[Buffer]> File content + +Field value. + ## method: FormData.create * since: v1.18 * langs: java @@ -36,7 +107,7 @@ FormData form = FormData.create() // Name and value are set, filename and Content-Type are inferred from the file path. .set("profilePicture1", Paths.get("john.jpg")) // Name, value, filename and Content-Type are set. - .set("profilePicture2", new FilePayload("john.jpg", "image/jpeg", Files.readAllBytes(Paths.get("john.jpg")))); + .set("profilePicture2", new FilePayload("john.jpg", "image/jpeg", Files.readAllBytes(Paths.get("john.jpg")))) .set("age", 30); page.request().post("http://localhost/submit", RequestOptions.create().setForm(form)); ``` @@ -52,6 +123,7 @@ multipart.Set("profilePicture", new FilePayload() MimeType = "image/jpeg", Buffer = File.ReadAllBytes("john.jpg") }); +multipart.Set("age", 30); await Page.APIRequest.PostAsync("https://localhost/submit", new() { Multipart = multipart }); ``` diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 287fbeef9c..e105f2fd14 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -403,9 +403,9 @@ unless explicitly provided. An instance of [FormData] can be created via [`method: APIRequestContext.createFormData`]. -## js-python-fetch-option-multipart -* langs: js, python -- `multipart` <[Object]<[string], [string]|[float]|[boolean]|[ReadStream]|[Object]>> +## js-fetch-option-multipart +* langs: js +- `multipart` <[FormData]|[Object]<[string], [string]|[float]|[boolean]|[ReadStream]|[Object]>> - `name` <[string]> File name - `mimeType` <[string]> File type - `buffer` <[Buffer]> File content @@ -415,14 +415,24 @@ this request body. If this parameter is specified `content-type` header will be unless explicitly provided. File values can be passed either as [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) or as file-like object containing file name, mime-type and its content. +## python-fetch-option-multipart +* langs: python +- `multipart` <[Object]<[string], [string]|[float]|[boolean]|[ReadStream]|[Object]>> + - `name` <[string]> File name + - `mimeType` <[string]> File type + - `buffer` <[Buffer]> File content + +Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as +this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` +unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. + ## csharp-fetch-option-multipart * langs: csharp - `multipart` <[FormData]> Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` -unless explicitly provided. File values can be passed either as [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) -or as file-like object containing file name, mime-type and its content. +unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. An instance of [FormData] can be created via [`method: APIRequestContext.createFormData`]. diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index 13a0c0e643..afe622bb6d 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -187,18 +187,24 @@ export class APIRequestContext extends ChannelOwner { + if (isFilePayload(value)) { + const payload = value as FilePayload; + if (!Buffer.isBuffer(payload.buffer)) + throw new Error(`Unexpected buffer type of 'data.${name}'`); + return { name, file: filePayloadToJson(payload) }; + } else if (value instanceof fs.ReadStream) { + return { name, file: await readStreamToJson(value as fs.ReadStream) }; + } else { + return { name, value: String(value) }; + } +} + function isJsonParsable(value: any) { if (typeof value !== 'string') return false; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 6f89fdcd63..fe42f0ae39 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -15742,7 +15742,7 @@ export interface APIRequestContext { * [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) or as file-like object containing file * name, mime-type and its content. */ - multipart?: { [key: string]: string|number|boolean|ReadStream|{ + multipart?: FormData|{ [key: string]: string|number|boolean|ReadStream|{ /** * File name */ @@ -15876,7 +15876,7 @@ export interface APIRequestContext { * [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) or as file-like object containing file * name, mime-type and its content. */ - multipart?: { [key: string]: string|number|boolean|ReadStream|{ + multipart?: FormData|{ [key: string]: string|number|boolean|ReadStream|{ /** * File name */ @@ -15970,7 +15970,7 @@ export interface APIRequestContext { * [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) or as file-like object containing file * name, mime-type and its content. */ - multipart?: { [key: string]: string|number|boolean|ReadStream|{ + multipart?: FormData|{ [key: string]: string|number|boolean|ReadStream|{ /** * File name */ @@ -16050,7 +16050,7 @@ export interface APIRequestContext { * [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) or as file-like object containing file * name, mime-type and its content. */ - multipart?: { [key: string]: string|number|boolean|ReadStream|{ + multipart?: FormData|{ [key: string]: string|number|boolean|ReadStream|{ /** * File name */ @@ -16130,7 +16130,7 @@ export interface APIRequestContext { * [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) or as file-like object containing file * name, mime-type and its content. */ - multipart?: { [key: string]: string|number|boolean|ReadStream|{ + multipart?: FormData|{ [key: string]: string|number|boolean|ReadStream|{ /** * File name */ @@ -16261,7 +16261,7 @@ export interface APIRequestContext { * [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) or as file-like object containing file * name, mime-type and its content. */ - multipart?: { [key: string]: string|number|boolean|ReadStream|{ + multipart?: FormData|{ [key: string]: string|number|boolean|ReadStream|{ /** * File name */ @@ -16341,7 +16341,7 @@ export interface APIRequestContext { * [`fs.ReadStream`](https://nodejs.org/api/fs.html#fs_class_fs_readstream) or as file-like object containing file * name, mime-type and its content. */ - multipart?: { [key: string]: string|number|boolean|ReadStream|{ + multipart?: FormData|{ [key: string]: string|number|boolean|ReadStream|{ /** * File name */ diff --git a/tests/library/browsercontext-fetch.spec.ts b/tests/library/browsercontext-fetch.spec.ts index bb0bb15362..70699b3f34 100644 --- a/tests/library/browsercontext-fetch.spec.ts +++ b/tests/library/browsercontext-fetch.spec.ts @@ -23,6 +23,7 @@ import zlib from 'zlib'; import { contextTest as it, expect } from '../config/browserTest'; import { suppressCertificateWarning } from '../config/utils'; import { kTargetClosedErrorMessage } from 'tests/config/errors'; +import * as buffer from 'buffer'; it.skip(({ mode }) => mode !== 'default'); @@ -983,6 +984,39 @@ it('should support multipart/form-data and keep the order', async function({ con expect(response.status()).toBe(200); }); +it('should support repeating names in multipart/form-data', async function({ context, server }) { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/28070' }); + const nodeVersion = +process.versions.node.split('.')[0]; + it.skip(nodeVersion < 18, 'FormData is not available in Node.js < 18'); + const postBodyPromise = new Promise(resolve => { + server.setRoute('/empty.html', async (req, res) => { + resolve((await req.postBody).toString('utf-8')); + res.writeHead(200, { + 'content-type': 'text/plain', + }); + res.end('OK.'); + }); + }); + const formData = new FormData(); + formData.set('name', 'John'); + formData.append('name', 'Doe'); + formData.append('file', new (buffer as any).File(['var x = 10;\r\n;console.log(x);'], 'f1.js', { type: 'text/javascript' })); + formData.append('file', new (buffer as any).File(['hello'], 'f2.txt', { type: 'text/plain' }), 'custom_f2.txt'); + formData.append('file', new (buffer as any).Blob(['boo'], { type: 'text/plain' })); + const [postBody, response] = await Promise.all([ + postBodyPromise, + context.request.post(server.EMPTY_PAGE, { + multipart: formData + }) + ]); + expect(postBody).toContain(`content-disposition: form-data; name="name"\r\n\r\nJohn`); + expect(postBody).toContain(`content-disposition: form-data; name="name"\r\n\r\nDoe`); + expect(postBody).toContain(`content-disposition: form-data; name="file"; filename="f1.js"\r\ncontent-type: text/javascript\r\n\r\nvar x = 10;\r\n;console.log(x);`); + expect(postBody).toContain(`content-disposition: form-data; name="file"; filename="custom_f2.txt"\r\ncontent-type: text/plain\r\n\r\nhello`); + expect(postBody).toContain(`content-disposition: form-data; name="file"; filename="blob"\r\ncontent-type: text/plain\r\n\r\nboo`); + expect(response.status()).toBe(200); +}); + it('should serialize data to json regardless of content-type', async function({ context, server }) { const data = { firstName: 'John',