From fbf1449916f12eb86a6a99cb28d0a6e6630f0b92 Mon Sep 17 00:00:00 2001 From: DigitalBrainJS Date: Sat, 14 May 2022 02:05:45 +0300 Subject: [PATCH] Fixed `toFormData` regression bug (unreleased) with Array-like objects serialization; Added `toURLEncodedForm` helper; Added automatic payload serialization to `application/x-www-form-urlencoded` to have parity with `multipart/form-data`; Added test of handling `application/x-www-form-urlencoded` body by express.js; Updated README.md; Added missed param in JSDoc; Fixed hrefs in README.md; --- README.md | 104 +++++++++++++----- lib/defaults/index.js | 29 +++-- lib/helpers/buildURL.js | 1 + lib/helpers/toFormData.js | 42 +++---- lib/helpers/toURLEncodedForm.js | 18 +++ .../browser/classes/URLSearchParams.js | 38 +++++++ lib/platform/browser/index.js | 8 ++ lib/platform/index.js | 3 + lib/platform/node/classes/URLSearchParams.js | 5 + lib/platform/node/index.js | 8 ++ package-lock.json | 10 +- package.json | 4 +- test/unit/adapters/http.js | 40 ++++++- 13 files changed, 245 insertions(+), 65 deletions(-) create mode 100644 lib/helpers/toURLEncodedForm.js create mode 100644 lib/platform/browser/classes/URLSearchParams.js create mode 100644 lib/platform/browser/index.js create mode 100644 lib/platform/index.js create mode 100644 lib/platform/node/classes/URLSearchParams.js create mode 100644 lib/platform/node/index.js diff --git a/README.md b/README.md index 94511703c1..2badb8525a 100755 --- a/README.md +++ b/README.md @@ -39,12 +39,12 @@ Promise based HTTP client for the browser and node.js - [AbortController](#abortcontroller) - [CancelToken 👎](#canceltoken-deprecated) - [Using application/x-www-form-urlencoded format](#using-applicationx-www-form-urlencoded-format) - - [Browser](#browser) - - [Node.js](#nodejs) - - [Query string](#query-string) - - [Form data](#form-data) - - [Automatic serialization](#-automatic-serialization) - - [Manual FormData passing](#manual-formdata-passing) + - [URLSearchParams](#urlsearchparams) + - [Query string](#query-string-older-browsers) + - [🆕 Automatic serialization](#-automatic-serialization-to-urlsearchparams) + - [Using multipart/form-data format](#using-multipartform-data-format) + - [FormData](#formdata) + - [🆕 Automatic serialization](#-automatic-serialization-to-formdata) - [Semver](#semver) - [Promises](#promises) - [TypeScript](#typescript) @@ -61,6 +61,7 @@ Promise based HTTP client for the browser and node.js - Transform request and response data - Cancel requests - Automatic transforms for JSON data +- 🆕 Automatic data object serialization to `multipart/form-data` and `x-www-form-urlencoded` body encodings - Client side support for protecting against [XSRF](https://en.wikipedia.org/wiki/Cross-site_request_forgery) ## Browser Support @@ -814,7 +815,9 @@ cancel(); > During the transition period, you can use both cancellation APIs, even for the same request: -## Using application/x-www-form-urlencoded format +## Using `application/x-www-form-urlencoded` format + +### URLSearchParams By default, axios serializes JavaScript objects to `JSON`. To send data in the [`application/x-www-form-urlencoded` format](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST) instead, you can use the [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) API, which is [supported](http://www.caniuse.com/#feat=urlsearchparams) in the vast majority of browsers, [and Node](https://nodejs.org/api/url.html#url_class_urlsearchparams) starting with v10 (released in 2018). @@ -824,7 +827,7 @@ params.append('extraparam', 'value'); axios.post('/foo', params); ``` -### Older browsers +### Query string (Older browsers) For compatibility with very old browsers, there is a [polyfill](https://github.com/WebReflection/url-search-params) available (make sure to polyfill the global environment). @@ -863,9 +866,73 @@ You can also use the [`qs`](https://github.com/ljharb/qs) library. > NOTE: > The `qs` library is preferable if you need to stringify nested objects, as the `querystring` method has [known issues](https://github.com/nodejs/node-v0.x-archive/issues/1665) with that use case. -#### Form data +### 🆕 Automatic serialization to URLSearchParams + +Axios will automatically serialize the data object to urlencoded format if the content-type header is set to "application/x-www-form-urlencoded". + +``` +const data = { + x: 1, + arr: [1, 2, 3], + arr2: [1, [2], 3], + users: [{name: 'Peter', surname: 'Griffin'}, {name: 'Thomas', surname: 'Anderson'}], +}; + +await axios.postForm('https://postman-echo.com/post', data, + {headers: {'content-type': 'application/x-www-form-urlencoded'}} +); +``` + +The server will handle it as + +```js + { + x: '1', + 'arr[]': [ '1', '2', '3' ], + 'arr2[0]': '1', + 'arr2[1][0]': '2', + 'arr2[2]': '3', + 'arr3[]': [ '1', '2', '3' ], + 'users[0][name]': 'Peter', + 'users[0][surname]': 'griffin', + 'users[1][name]': 'Thomas', + 'users[1][surname]': 'Anderson' + } +```` + +If your backend body-parser (like `body-parser` of `express.js`) supports nested objects decoding, you will get the same object on the server-side automatically + +```js + var app = express(); + + app.use(bodyParser.urlencoded({ extended: true })); // support encoded bodies + + app.post('/', function (req, res, next) { + // echo body as JSON + res.send(JSON.stringify(req.body)); + }); + + server = app.listen(3000); +``` + +## Using `multipart/form-data` format + +### FormData + +In node.js, you can use the [`form-data`](https://github.com/form-data/form-data) library as follows: + +```js +const FormData = require('form-data'); + +const form = new FormData(); +form.append('my_field', 'my value'); +form.append('my_buffer', new Buffer(10)); +form.append('my_file', fs.createReadStream('/foo/bar.jpg')); + +axios.post('https://example.com', form) +``` -##### 🆕 Automatic serialization +### 🆕 Automatic serialization to FormData Starting from `v0.27.0`, Axios supports automatic object serialization to a FormData object if the request `Content-Type` header is set to `multipart/form-data`. @@ -904,7 +971,7 @@ Axios FormData serializer supports some special endings to perform the following - `[]` - 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 +> unwrap/expand operation will be used by default on arrays and FileList objects FormData serializer supports additional options via `config.formSerializer: object` property to handle rare cases: @@ -976,21 +1043,6 @@ await axios.postForm('https://httpbin.org/post', document.querySelector('#fileIn All files will be sent with the same field names: `files[]`; -##### Manual FormData passing - -In node.js, you can use the [`form-data`](https://github.com/form-data/form-data) library as follows: - -```js -const FormData = require('form-data'); - -const form = new FormData(); -form.append('my_field', 'my value'); -form.append('my_buffer', new Buffer(10)); -form.append('my_file', fs.createReadStream('/foo/bar.jpg')); - -axios.post('https://example.com', form) -``` - ## Semver Until axios reaches a `1.0` release, breaking changes will be released with a new minor version. For example `0.5.1`, and `0.5.4` will have the same API, but `0.6.0` will have breaking changes. diff --git a/lib/defaults/index.js b/lib/defaults/index.js index 1be97ffd22..31a68579ec 100644 --- a/lib/defaults/index.js +++ b/lib/defaults/index.js @@ -5,6 +5,7 @@ var normalizeHeaderName = require('../helpers/normalizeHeaderName'); var AxiosError = require('../core/AxiosError'); var transitionalDefaults = require('./transitional'); var toFormData = require('../helpers/toFormData'); +var toURLEncodedForm = require('../helpers/toURLEncodedForm'); var DEFAULT_CONTENT_TYPE = { 'Content-Type': 'application/x-www-form-urlencoded' @@ -71,18 +72,26 @@ var defaults = { } var isObjectPayload = utils.isObject(data); - var contentType = headers && headers['Content-Type']; - + var contentType = headers && headers['Content-Type'] || ''; var isFileList; - 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(), - this.formSerializer - ); - } else if (isObjectPayload || contentType === 'application/json') { + if (isObjectPayload) { + if (contentType.indexOf('application/x-www-form-urlencoded') !== -1) { + return toURLEncodedForm(data).toString(); + } + + if ((isFileList = utils.isFileList(data)) || contentType.indexOf('multipart/form-data') !== -1) { + var _FormData = this.env && this.env.FormData; + + return toFormData( + isFileList ? {'files[]': data} : data, + _FormData && new _FormData(), + this.formSerializer + ); + } + } + + if (isObjectPayload || contentType.indexOf('application/json') !== -1) { setContentTypeIfUnset(headers, 'application/json'); return stringifySafely(data); } diff --git a/lib/helpers/buildURL.js b/lib/helpers/buildURL.js index 31595c33a8..4afa237bb6 100644 --- a/lib/helpers/buildURL.js +++ b/lib/helpers/buildURL.js @@ -17,6 +17,7 @@ function encode(val) { * * @param {string} url The base of the url (e.g., http://www.google.com) * @param {object} [params] The params to be appended + * @param {?object} paramsSerializer * @returns {string} The formatted url */ module.exports = function buildURL(url, params, paramsSerializer) { diff --git a/lib/helpers/toFormData.js b/lib/helpers/toFormData.js index 9cfdbd76a6..071b0c9203 100644 --- a/lib/helpers/toFormData.js +++ b/lib/helpers/toFormData.js @@ -20,6 +20,20 @@ function renderKey(path, key, dots) { }).join(dots ? '.' : ''); } +function convertValue(value) { + if (value === null) return ''; + + if (utils.isDate(value)) { + return value.toISOString(); + } + + if (utils.isArrayBuffer(value) || utils.isTypedArray(value)) { + return typeof Blob === 'function' ? new Blob([value]) : Buffer.from(value); + } + + return value; +} + function isFlatArray(arr) { return utils.isArray(arr) && !arr.some(isVisitable); } @@ -64,21 +78,6 @@ function toFormData(obj, formData, options) { throw new TypeError('visitor must be a function'); } - function convertValue(value) { - if (value === null) return ''; - - if (utils.isDate(value)) { - return value.toISOString(); - } - - if (utils.isArrayBuffer(value) || utils.isTypedArray(value)) { - return typeof Blob === 'function' ? new Blob([value]) : Buffer.from(value); - } - - return value; - } - - /** * * @param {*} value @@ -88,7 +87,7 @@ function toFormData(obj, formData, options) { * @returns {boolean} return true to visit the each prop of the value recursively */ function defaultVisitor(value, key, path) { - var arr; + var arr = value; if (value && !path && typeof value === 'object') { if (utils.endsWith(key, '{}')) { @@ -96,7 +95,10 @@ function toFormData(obj, formData, options) { 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)) { + } else if ( + (utils.isArray(value) && isFlatArray(value)) || + (utils.isFileList(value) || utils.endsWith(key, '[]') && (arr = utils.toArray(value)) + )) { // eslint-disable-next-line no-param-reassign key = removeBrackets(key); @@ -138,7 +140,7 @@ function toFormData(obj, formData, options) { stack.push(value); utils.forEach(value, function each(el, key) { - var result = !utils.isUndefined(el) && defaultVisitor.call( + var result = !utils.isUndefined(el) && visitor.call( formData, el, utils.isString(key) ? key.trim() : key, path, exposedHelpers ); @@ -150,8 +152,8 @@ function toFormData(obj, formData, options) { stack.pop(); } - if (!utils.isPlainObject(obj)) { - throw new TypeError('data must be a plain object'); + if (!utils.isObject(obj)) { + throw new TypeError('data must be an object'); } build(obj); diff --git a/lib/helpers/toURLEncodedForm.js b/lib/helpers/toURLEncodedForm.js new file mode 100644 index 0000000000..8c8afce7c2 --- /dev/null +++ b/lib/helpers/toURLEncodedForm.js @@ -0,0 +1,18 @@ +'use strict'; + +var utils = require('../utils'); +var toFormData = require('./toFormData'); +var platform = require('../platform/'); + +module.exports = function toURLEncodedForm(data) { + return toFormData(data, new platform.classes.URLSearchParams(), { + visitor: function(value, key, path, helpers) { + if (platform.isNode && utils.isBuffer(value)) { + this.append(key, value.toString('base64')); + return false; + } + + return helpers.defaultVisitor.apply(this, arguments); + } + }); +}; diff --git a/lib/platform/browser/classes/URLSearchParams.js b/lib/platform/browser/classes/URLSearchParams.js new file mode 100644 index 0000000000..75ceefac5d --- /dev/null +++ b/lib/platform/browser/classes/URLSearchParams.js @@ -0,0 +1,38 @@ +'use strict'; + +module.exports = (function getURLSearchParams(nativeURLSearchParams) { + if (typeof nativeURLSearchParams === 'function') return nativeURLSearchParams; + + function encode(str) { + var charMap = { + '!': '%21', + "'": '%27', + '(': '%28', + ')': '%29', + '~': '%7E', + '%20': '+', + '%00': '\x00' + }; + return encodeURIComponent(str).replace(/[!'\(\)~]|%20|%00/g, function replacer(match) { + return charMap[match]; + }); + } + + function URLSearchParams() { + this.pairs = []; + } + + var prototype = URLSearchParams.prototype; + + prototype.append = function append(name, value) { + this.pairs.push([name, value]); + }; + + prototype.toString = function toString() { + return this.pairs.map(function each(pair) { + return pair[0] + '=' + encode(pair[1]); + }, '').join('&'); + }; + + return URLSearchParams; +})(URLSearchParams); diff --git a/lib/platform/browser/index.js b/lib/platform/browser/index.js new file mode 100644 index 0000000000..dea2b3f91a --- /dev/null +++ b/lib/platform/browser/index.js @@ -0,0 +1,8 @@ +'use strict'; + +module.exports = { + isBrowser: true, + classes: { + URLSearchParams: require('./classes/URLSearchParams') + } +}; diff --git a/lib/platform/index.js b/lib/platform/index.js new file mode 100644 index 0000000000..8560532753 --- /dev/null +++ b/lib/platform/index.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./node/'); diff --git a/lib/platform/node/classes/URLSearchParams.js b/lib/platform/node/classes/URLSearchParams.js new file mode 100644 index 0000000000..1ae3fc58df --- /dev/null +++ b/lib/platform/node/classes/URLSearchParams.js @@ -0,0 +1,5 @@ +'use strict'; + +var url = require('url'); + +module.exports = url.URLSearchParams; diff --git a/lib/platform/node/index.js b/lib/platform/node/index.js new file mode 100644 index 0000000000..d9699ae8b7 --- /dev/null +++ b/lib/platform/node/index.js @@ -0,0 +1,8 @@ +'use strict'; + +module.exports = { + isNode: true, + classes: { + URLSearchParams: require('./classes/URLSearchParams') + } +}; diff --git a/package-lock.json b/package-lock.json index e537d68521..48385e24f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "dependencies": { "follow-redirects": "^1.15.0", - "form-data": "^4.0.0" + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" }, "devDependencies": { "@rollup/plugin-babel": "^5.3.0", @@ -19,6 +20,7 @@ "@rollup/plugin-multi-entry": "^4.0.0", "@rollup/plugin-node-resolve": "^9.0.0", "abortcontroller-polyfill": "^1.7.3", + "body-parser": "^1.20.0", "coveralls": "^3.1.1", "dtslint": "^4.2.1", "es6-promise": "^4.2.8", @@ -11989,8 +11991,7 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/prr": { "version": "1.0.1", @@ -26801,8 +26802,7 @@ "proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "prr": { "version": "1.0.1", diff --git a/package.json b/package.json index c262de8feb..7b5feb61db 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@rollup/plugin-multi-entry": "^4.0.0", "@rollup/plugin-node-resolve": "^9.0.0", "abortcontroller-polyfill": "^1.7.3", + "body-parser": "^1.20.0", "coveralls": "^3.1.1", "dtslint": "^4.2.1", "es6-promise": "^4.2.8", @@ -79,7 +80,8 @@ }, "browser": { "./lib/adapters/http.js": "./lib/adapters/xhr.js", - "./lib/defaults/env/FormData.js": "./lib/helpers/null.js" + "./lib/defaults/env/FormData.js": "./lib/helpers/null.js", + "./lib/platform/node/index.js": "./lib/platform/browser/index.js" }, "jsdelivr": "dist/axios.min.js", "unpkg": "dist/axios.min.js", diff --git a/test/unit/adapters/http.js b/test/unit/adapters/http.js index c92e0afd2d..01af375336 100644 --- a/test/unit/adapters/http.js +++ b/test/unit/adapters/http.js @@ -12,8 +12,9 @@ var server, proxy; var AxiosError = require('../../../lib/core/AxiosError'); var FormData = require('form-data'); var formidable = require('formidable'); -const express = require('express'); -const multer = require('multer'); +var express = require('express'); +var multer = require('multer'); +var bodyParser = require('body-parser'); describe('supports http with nodejs', function () { @@ -1287,9 +1288,11 @@ describe('supports http with nodejs', function () { }).catch(done); }); }); + describe('toFormData helper', function () { it('should properly serialize nested objects for parsing with multer.js (express.js)', function (done) { - const app = express(); + var app = express(); + var obj = { arr1: ['1', '2', '3'], arr2: ['1', ['2'], '3'], @@ -1324,4 +1327,35 @@ describe('supports http with nodejs', function () { }); }); }); + + describe('URLEncoded Form', function () { + it('should post object data as url-encoded form if content-type is application/x-www-form-urlencoded', function (done) { + var app = express(); + + var obj = { + arr1: ['1', '2', '3'], + arr2: ['1', ['2'], '3'], + obj: {x: '1', y: {z: '1'}}, + users: [{name: 'Peter', surname: 'griffin'}, {name: 'Thomas', surname: 'Anderson'}] + }; + + app.use(bodyParser.urlencoded({ extended: true })); + + app.post('/', function (req, res, next) { + res.send(JSON.stringify(req.body)); + }); + + server = app.listen(3001, function () { + return axios.post('http://localhost:3001/', obj, { + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + }) + .then(function (res) { + assert.deepStrictEqual(res.data, obj); + done(); + }, done); + }); + }); + }); });