feat(fetch): support FormData as multipart parameter

Reference https://github.com/microsoft/playwright/issues/28070
This commit is contained in:
Yury Semikhatsky 2024-04-23 11:38:08 -07:00
parent b52cd7e4dc
commit 716b70e50b
6 changed files with 144 additions and 21 deletions

View file

@ -566,7 +566,7 @@ api_request_context.post("https://example.com/api/createBook", data=data)
```csharp
var data = new Dictionary<string, object>() {
{ "firstNam", "John" },
{ "firstName", "John" },
{ "lastName", "Doe" }
};
await request.PostAsync("https://example.com/api/createBook", new() { DataObject = data });

View file

@ -14,6 +14,77 @@ FormData form = FormData.create()
page.request().post("http://localhost/submit", RequestOptions.create().setForm(form));
```
## method: FormData.append
* since: v1.43
- 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 set() and append() is that if the specified key already exists,
set() will overwrite all existing values with the new one, whereas 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.43
- `name` <[string]>
Field name.
### param: FormData.append.value
* since: v1.43
- `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.43
* 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 });
```

View file

@ -405,7 +405,7 @@ An instance of [FormData] can be created via [`method: APIRequestContext.createF
## js-python-fetch-option-multipart
* langs: js, python
- `multipart` <[Object]<[string], [string]|[float]|[boolean]|[ReadStream]|[Object]>>
- `multipart` <[FormData]|[Object]<[string], [string]|[float]|[boolean]|[ReadStream]|[Object]>>
- `name` <[string]> File name
- `mimeType` <[string]> File type
- `buffer` <[Buffer]> File content

View file

@ -187,18 +187,24 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
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 (options.multipart instanceof FormData) {
const form = options.multipart;
for (const [name, value] of form.entries()) {
if (isString(value)) {
multipartData.push({ name, value });
} else {
const file: ServerFilePayload = {
name: value.name,
mimeType: value.type,
buffer: Buffer.from(await value.arrayBuffer()),
};
multipartData.push({ name, file });
}
}
} else {
// Convert file-like values to ServerFilePayload structs.
for (const [name, value] of Object.entries(options.multipart))
multipartData.push(await toFormField(name, value));
}
}
if (postDataBuffer === undefined && jsonData === undefined && formData === undefined && multipartData === undefined)
@ -235,6 +241,19 @@ export class APIRequestContext extends ChannelOwner<channels.APIRequestContextCh
}
}
async function toFormField(name: string, value: string|number|boolean|fs.ReadStream|FilePayload): Promise<channels.FormField> {
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;

View file

@ -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
*/

View file

@ -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,37 @@ 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 postBodyPromise = new Promise<string>(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.File(['var x = 10;\r\n;console.log(x);'], 'f1.js', { type: 'text/javascript' }));
formData.append('file', new buffer.File(['hello'], 'f2.txt', { type: 'text/plain' }), 'custom_f2.txt');
formData.append('file', new buffer.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',