Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added enhanced toFormData implementation with additional options #4704

Merged
merged 2 commits into from May 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
55 changes: 52 additions & 3 deletions README.md
Expand Up @@ -901,7 +901,56 @@ axios.post('https://httpbin.org/post', {x: 1, buf: new Buffer(10)}, {
Axios FormData serializer supports some special endings to perform the following operations:

- `{}` - serialize the value with JSON.stringify
- `[]` - unwrap the array like object as separate fields with the same key
- `[]` - unwrap the array-like object as separate fields with the same key

> NOTE:
> unwrap/expand operation will be used by default on array-like objects

FormData serializer supports additional options via `config.formSerializer: object` property to handle rare cases:

- `visitor: Function` - user-defined visitor function that will be called recursively to serialize the data object
to a `FormData` object by following custom rules.

- `dots: boolean = false` - use dot notation instead of brackets to serialize arrays and objects;

- `metaTokens: boolean = true` - add the special ending (e.g `user{}: '{"name": "John"}'`) in the FormData key.
The back-end body-parser could potentially use this meta-information to automatically parse the value as JSON.

- `indexes: null|false|true = false` - controls how indexes will be added to unwrapped keys of `flat` array-like objects

- `null` - don't add brackets (`arr: 1`, `arr: 2`, `arr: 3`)
- `false`(default) - add empty brackets (`arr[]: 1`, `arr[]: 2`, `arr[]: 3`)
- `true` - add brackets with indexes (`arr[0]: 1`, `arr[1]: 2`, `arr[2]: 3`)

Let's say we have an object like this one:

```js
const obj = {
x: 1,
arr: [1, 2, 3],
arr2: [1, [2], 3],
users: [{name: 'Peter', surname: 'Griffin'}, {name: 'Thomas', surname: 'Anderson'}],
'obj2{}': [{x:1}]
};
```

The following steps will be executed by the Axios serializer internally:

```js
const formData= new FormData();
formData.append('x', '1');
formData.append('arr[]', '1');
formData.append('arr[]', '2');
formData.append('arr[]', '3');
formData.append('arr2[0]', '1');
formData.append('arr2[1][0]', '2');
formData.append('arr2[2]', '3');
formData.append('users[0][name]', 'Peter');
formData.append('users[0][surname]', 'Griffin');
formData.append('users[1][name]', 'Thomas');
formData.append('users[1][surname]', 'Anderson');
formData.append('obj2{}', '[{"x":1}]');
```

```js
const axios= require('axios');
Expand All @@ -917,9 +966,9 @@ axios.post('https://httpbin.org/post', {
```

Axios supports the following shortcut methods: `postForm`, `putForm`, `patchForm`
which are just the corresponding http methods with a header preset: `Content-Type`: `multipart/form-data`.
which are just the corresponding http methods with the content-type header preset to `multipart/form-data`.

FileList object can be passed directly:
`FileList` object can be passed directly:

```js
await axios.postForm('https://httpbin.org/post', document.querySelector('#fileInput').files)
Expand Down
20 changes: 19 additions & 1 deletion index.d.ts
Expand Up @@ -79,6 +79,23 @@ export interface GenericAbortSignal {
removeEventListener: (...args: any) => any;
}

export interface FormDataVisitorHelpers {
defaultVisitor: FormDataVisitor;
convertValue: (value: any) => any;
isVisitable: (value: any) => boolean;
}

export interface FormDataVisitor {
(value: any, key: string | number, path: null | Array<string | number>, helpers: FormDataVisitorHelpers): boolean;
}

export interface FormSerializerOptions {
visitor?: FormDataVisitor;
dots?: boolean;
metaTokens?: boolean;
indexes?: boolean;
}

export interface AxiosRequestConfig<D = any> {
url?: string;
method?: Method | string;
Expand Down Expand Up @@ -117,6 +134,7 @@ export interface AxiosRequestConfig<D = any> {
env?: {
FormData?: new (...args: any[]) => object;
};
formSerializer?: FormSerializerOptions;
}

export interface HeadersDefaults {
Expand Down Expand Up @@ -268,7 +286,7 @@ export interface AxiosStatic extends AxiosInstance {
all<T>(values: Array<T | Promise<T>>): Promise<T[]>;
spread<T, R>(callback: (...args: T[]) => R): (array: T[]) => R;
isAxiosError(payload: any): payload is AxiosError;
toFormData(sourceObj: object, targetFormData?: GenericFormData): GenericFormData;
toFormData(sourceObj: object, targetFormData?: GenericFormData, options?: FormSerializerOptions): GenericFormData;
}

declare const axios: AxiosStatic;
Expand Down
6 changes: 5 additions & 1 deletion lib/defaults/index.js
Expand Up @@ -77,7 +77,11 @@ var defaults = {

if ((isFileList = utils.isFileList(data)) || (isObjectPayload && contentType === 'multipart/form-data')) {
var _FormData = this.env && this.env.FormData;
return toFormData(isFileList ? {'files[]': data} : data, _FormData && new _FormData());
return toFormData(
isFileList ? {'files[]': data} : data,
_FormData && new _FormData(),
this.formSerializer
);
} else if (isObjectPayload || contentType === 'application/json') {
setContentTypeIfUnset(headers, 'application/json');
return stringifySafely(data);
Expand Down
156 changes: 123 additions & 33 deletions lib/helpers/toFormData.js
@@ -1,19 +1,68 @@
'use strict';

var utils = require('../utils');
var envFormData = require('../defaults/env/FormData');

function isVisitable(thing) {
return utils.isPlainObject(thing) || utils.isArray(thing);
}

function removeBrackets(key) {
return utils.endsWith(key, '[]') ? key.slice(0, -2) : key;
}

function renderKey(path, key, dots) {
if (!path) return key;
return path.concat(key).map(function each(token, i) {
// eslint-disable-next-line no-param-reassign
token = removeBrackets(token);
return !dots && i ? '[' + token + ']' : token;
}).join(dots ? '.' : '');
}

function isFlatArray(arr) {
return utils.isArray(arr) && !arr.some(isVisitable);
}

var predicates = utils.toFlatObject(utils, {}, null, function filter(prop) {
return /^is[A-Z]/.test(prop);
});

/**
* Convert a data object to FormData
* @param {Object} obj
* @param {?Object} [formData]
* @param {?Object} [options]
* @param {Function} [options.visitor]
* @param {Boolean} [options.metaTokens = true]
* @param {Boolean} [options.dots = false]
* @param {?Boolean} [options.indexes = false]
* @returns {Object}
**/

function toFormData(obj, formData) {
function toFormData(obj, formData, options) {
// eslint-disable-next-line no-param-reassign
formData = formData || new FormData();
formData = formData || new (envFormData || FormData)();

var stack = [];
// eslint-disable-next-line no-param-reassign
options = utils.toFlatObject(options, {
metaTokens: true,
dots: false,
indexes: false
}, false, function defined(option, source) {
// eslint-disable-next-line no-eq-null,eqeqeq
return !utils.isUndefined(source[option]);
});

var metaTokens = options.metaTokens;
// eslint-disable-next-line no-use-before-define
var visitor = options.visitor || defaultVisitor;
var dots = options.dots;
var indexes = options.indexes;

if (!utils.isFunction(visitor)) {
throw new TypeError('visitor must be a function');
}

function convertValue(value) {
if (value === null) return '';
Expand All @@ -29,39 +78,80 @@ function toFormData(obj, formData) {
return value;
}

function build(data, parentKey) {
if (utils.isPlainObject(data) || utils.isArray(data)) {
if (stack.indexOf(data) !== -1) {
throw Error('Circular reference detected in ' + parentKey);

/**
*
* @param {*} value
* @param {String|Number} key
* @param {Array<String|Number>} path
* @this {FormData}
* @returns {boolean} return true to visit the each prop of the value recursively
*/
function defaultVisitor(value, key, path) {
var arr;

if (value && !path && typeof value === 'object') {
if (utils.endsWith(key, '{}')) {
// eslint-disable-next-line no-param-reassign
key = metaTokens ? key : key.slice(0, -2);
// eslint-disable-next-line no-param-reassign
value = JSON.stringify(value);
} else if (!utils.isPlainObject(value) && (arr = utils.toArray(value)) && isFlatArray(arr)) {
// eslint-disable-next-line no-param-reassign
key = removeBrackets(key);

arr.forEach(function each(el, index) {
!utils.isUndefined(el) && formData.append(
// eslint-disable-next-line no-nested-ternary
indexes === true ? renderKey([key], index, dots) : (indexes === null ? key : key + '[]'),
convertValue(el)
);
});
return false;
}
}

if (isVisitable(value)) {
return true;
}

formData.append(renderKey(path, key, dots), convertValue(value));

return false;
}

var stack = [];

var exposedHelpers = Object.assign(predicates, {
defaultVisitor: defaultVisitor,
convertValue: convertValue,
isVisitable: isVisitable
});

function build(value, path) {
if (utils.isUndefined(value)) return;

stack.push(data);

utils.forEach(data, function each(value, key) {
if (utils.isUndefined(value)) return;
var fullKey = parentKey ? parentKey + '.' + key : key;
var arr;

if (value && !parentKey && typeof value === 'object') {
if (utils.endsWith(key, '{}')) {
// eslint-disable-next-line no-param-reassign
value = JSON.stringify(value);
} else if (utils.endsWith(key, '[]') && (arr = utils.toArray(value))) {
// eslint-disable-next-line func-names
arr.forEach(function(el) {
!utils.isUndefined(el) && formData.append(fullKey, convertValue(el));
});
return;
}
}

build(value, fullKey);
});

stack.pop();
} else {
formData.append(parentKey, convertValue(data));
if (stack.indexOf(value) !== -1) {
throw Error('Circular reference detected in ' + path.join('.'));
}

stack.push(value);

utils.forEach(value, function each(el, key) {
var result = !utils.isUndefined(el) && defaultVisitor.call(
formData, el, utils.isString(key) ? key.trim() : key, path, exposedHelpers
);

if (result === true) {
build(el, path ? path.concat(key) : [key]);
}
});

stack.pop();
}

if (!utils.isPlainObject(obj)) {
throw new TypeError('data must be a plain object');
}

build(obj);
Expand Down
18 changes: 11 additions & 7 deletions lib/utils.js
Expand Up @@ -366,29 +366,32 @@ function inherits(constructor, superConstructor, props, descriptors) {
* Resolve object with deep prototype chain to a flat object
* @param {Object} sourceObj source object
* @param {Object} [destObj]
* @param {Function} [filter]
* @param {Function|Boolean} [filter]
* @param {Function} [propFilter]
* @returns {Object}
*/

function toFlatObject(sourceObj, destObj, filter) {
function toFlatObject(sourceObj, destObj, filter, propFilter) {
var props;
var i;
var prop;
var merged = {};

destObj = destObj || {};
// eslint-disable-next-line no-eq-null,eqeqeq
if (sourceObj == null) return destObj;

do {
props = Object.getOwnPropertyNames(sourceObj);
i = props.length;
while (i-- > 0) {
prop = props[i];
if (!merged[prop]) {
if ((!propFilter || propFilter(prop, sourceObj, destObj)) && !merged[prop]) {
destObj[prop] = sourceObj[prop];
merged[prop] = true;
}
}
sourceObj = Object.getPrototypeOf(sourceObj);
sourceObj = filter !== false && Object.getPrototypeOf(sourceObj);
} while (sourceObj && (!filter || filter(sourceObj, destObj)) && sourceObj !== Object.prototype);

return destObj;
Expand All @@ -413,14 +416,15 @@ function endsWith(str, searchString, position) {


/**
* Returns new array from array like object
* Returns new array from array like object or null if failed
* @param {*} [thing]
* @returns {Array}
* @returns {?Array}
*/
function toArray(thing) {
if (!thing) return null;
if (isArray(thing)) return thing;
var i = thing.length;
if (isUndefined(i)) return null;
if (!isNumber(i)) return null;
var arr = new Array(i);
while (i-- > 0) {
arr[i] = thing[i];
Expand Down