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 support for spec-compliant FormData & Blob types; #5316

Merged
merged 7 commits into from
Jan 30, 2023
Merged
4 changes: 2 additions & 2 deletions .eslintrc.cjs
@@ -1,12 +1,12 @@
module.exports = {
"env": {
"browser": true,
"es2017": true,
"es2018": true,
"node": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2017,
"ecmaVersion": 2018,
"sourceType": "module"
},
"rules": {
Expand Down
26 changes: 23 additions & 3 deletions lib/adapters/http.js
Expand Up @@ -19,6 +19,8 @@ import stream from 'stream';
import AxiosHeaders from '../core/AxiosHeaders.js';
import AxiosTransformStream from '../helpers/AxiosTransformStream.js';
import EventEmitter from 'events';
import formDataToStream from "../helpers/formDataToStream.js";
import readBlob from "../helpers/readBlob.js";
import ZlibHeaderTransformStream from '../helpers/ZlibHeaderTransformStream.js';

const zlibOptions = {
Expand Down Expand Up @@ -243,9 +245,27 @@ export default isHttpAdapterSupported && function httpAdapter(config) {
let maxUploadRate = undefined;
let maxDownloadRate = undefined;

// support for https://www.npmjs.com/package/form-data api
if (utils.isFormData(data) && utils.isFunction(data.getHeaders)) {
// support for spec compliant FormData objects
if (utils.isSpecCompliantForm(data)) {
const userBoundary = headers.getContentType(/boundary=([-_\w\d]{10,70})/i);

data = formDataToStream(data, (formHeaders) => {
headers.set(formHeaders);
}, {
tag: `axios-${VERSION}-boundary`,
boundary: userBoundary && userBoundary[1] || undefined
});
// support for https://www.npmjs.com/package/form-data api
} else if (utils.isFormData(data) && utils.isFunction(data.getHeaders)) {
headers.set(data.getHeaders());
if (utils.isFunction(data.getLengthSync)) { // check if the undocumented API exists
const knownLength = data.getLengthSync();
!utils.isUndefined(knownLength) && headers.setContentLength(knownLength, false);
}
} else if (utils.isBlob(data)) {
data.size && headers.setContentType(data.type || 'application/octet-stream');
headers.setContentLength(data.size || 0);
data = stream.Readable.from(readBlob(data));
} else if (data && !utils.isStream(data)) {
if (Buffer.isBuffer(data)) {
// Nothing to do...
Expand All @@ -262,7 +282,7 @@ export default isHttpAdapterSupported && function httpAdapter(config) {
}

// Add Content-Length header if data exists
headers.set('Content-Length', data.length, false);
headers.setContentLength(data.length, false);

if (config.maxBodyLength > -1 && data.length > config.maxBodyLength) {
return reject(new AxiosError(
Expand Down
4 changes: 2 additions & 2 deletions lib/env/classes/FormData.js
@@ -1,2 +1,2 @@
import FormData from 'form-data';
export default FormData;
import _FormData from 'form-data';
export default typeof FormData !== 'undefined' ? FormData : _FormData;
110 changes: 110 additions & 0 deletions lib/helpers/formDataToStream.js
@@ -0,0 +1,110 @@
import {Readable} from 'stream';
import utils from "../utils.js";
import readBlob from "./readBlob.js";

const BOUNDARY_ALPHABET = utils.ALPHABET.ALPHA_DIGIT + '-_';

const textEncoder = new TextEncoder();

const CRLF = '\r\n';
const CRLF_BYTES = textEncoder.encode(CRLF);
const CRLF_BYTES_COUNT = 2;

class FormDataPart {
constructor(name, value) {
const {escapeName} = this.constructor;
const isStringValue = utils.isString(value);

let headers = `Content-Disposition: form-data; name="${escapeName(name)}"${
!isStringValue && value.name ? `; filename="${escapeName(value.name)}"` : ''
}${CRLF}`;

if (isStringValue) {
value = textEncoder.encode(String(value).replace(/\r?\n|\r\n?/g, CRLF));
} else {
headers += `Content-Type: ${value.type || "application/octet-stream"}${CRLF}`

Check notice

Code scanning / CodeQL

Semicolon insertion

Avoid automated semicolon insertion (90% of all statements in [the enclosing function](1) have an explicit semicolon).
}

this.headers = textEncoder.encode(headers + CRLF);

this.contentLength = isStringValue ? value.byteLength : value.size;

this.size = this.headers.byteLength + this.contentLength + CRLF_BYTES_COUNT;

this.name = name;
this.value = value;
}

async *encode(){
yield this.headers;

const {value} = this;

if(utils.isTypedArray(value)) {
yield value;
} else {
yield* readBlob(value);
}

yield CRLF_BYTES;
}

static escapeName(name) {
return String(name).replace(/[\r\n"]/g, (match) => ({
'\r' : '%0D',
'\n' : '%0A',
'"' : '%22',
}[match]));
}
}

const formDataToStream = (form, headersHandler, options) => {
const {
tag = 'form-data-boundary',
size = 25,
boundary = tag + '-' + utils.generateString(size, BOUNDARY_ALPHABET)
} = options || {};

if(!utils.isFormData(form)) {
throw TypeError('FormData instance required');
}

if (boundary.length < 1 || boundary.length > 70) {
throw Error('boundary must be 10-70 characters long')
}

const boundaryBytes = textEncoder.encode('--' + boundary + CRLF);
const footerBytes = textEncoder.encode('--' + boundary + '--' + CRLF + CRLF);
let contentLength = footerBytes.byteLength;

const parts = Array.from(form.entries()).map(([name, value]) => {
const part = new FormDataPart(name, value);
contentLength += part.size;
return part;
});

contentLength += boundaryBytes.byteLength * parts.length;

contentLength = utils.toFiniteNumber(contentLength);

const computedHeaders = {
'Content-Type': `multipart/form-data; boundary=${boundary}`
}

if (Number.isFinite(contentLength)) {
computedHeaders['Content-Length'] = contentLength;
}

headersHandler && headersHandler(computedHeaders);

return Readable.from((async function *() {
for(const part of parts) {
yield boundaryBytes;
yield* part.encode();
}

yield footerBytes;
})());
};

export default formDataToStream;
15 changes: 15 additions & 0 deletions lib/helpers/readBlob.js
@@ -0,0 +1,15 @@
const {asyncIterator} = Symbol;

const readBlob = async function* (blob) {
if (blob.stream) {
yield* blob.stream()
} else if (blob.arrayBuffer) {
yield await blob.arrayBuffer()
} else if (blob[asyncIterator]) {
yield* asyncIterator.call(blob);
} else {
yield blob;
}
}

export default readBlob;
18 changes: 4 additions & 14 deletions lib/helpers/toFormData.js
Expand Up @@ -2,7 +2,8 @@

import utils from '../utils.js';
import AxiosError from '../core/AxiosError.js';
import envFormData from '../env/classes/FormData.js';
// temporary hotfix to avoid circular references until AxiosURLSearchParams is refactored
import PlatformFormData from '../platform/node/classes/FormData.js';

/**
* Determines if the given thing is a array or js object.
Expand Down Expand Up @@ -59,17 +60,6 @@ const predicates = utils.toFlatObject(utils, {}, null, function filter(prop) {
return /^is[A-Z]/.test(prop);
});

/**
* If the thing is a FormData object, return true, otherwise return false.
*
* @param {unknown} thing - The thing to check.
*
* @returns {boolean}
*/
function isSpecCompliant(thing) {
return thing && utils.isFunction(thing.append) && thing[Symbol.toStringTag] === 'FormData' && thing[Symbol.iterator];
}

/**
* Convert a data object to FormData
*
Expand Down Expand Up @@ -99,7 +89,7 @@ function toFormData(obj, formData, options) {
}

// eslint-disable-next-line no-param-reassign
formData = formData || new (envFormData || FormData)();
formData = formData || new (PlatformFormData || FormData)();

// eslint-disable-next-line no-param-reassign
options = utils.toFlatObject(options, {
Expand All @@ -117,7 +107,7 @@ function toFormData(obj, formData, options) {
const dots = options.dots;
const indexes = options.indexes;
const _Blob = options.Blob || typeof Blob !== 'undefined' && Blob;
const useBlob = _Blob && isSpecCompliant(formData);
const useBlob = _Blob && utils.isSpecCompliantForm(formData);

if (!utils.isFunction(visitor)) {
throw new TypeError('visitor must be a function');
Expand Down
36 changes: 35 additions & 1 deletion lib/utils.js
Expand Up @@ -512,7 +512,7 @@ const matchAll = (regExp, str) => {
const isHTMLForm = kindOfTest('HTMLFormElement');

const toCamelCase = str => {
return str.toLowerCase().replace(/[_-\s]([a-z\d])(\w*)/g,
return str.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,
function replacer(m, p1, p2) {
return p1.toUpperCase() + p2;
}
Expand Down Expand Up @@ -596,6 +596,37 @@ const toFiniteNumber = (value, defaultValue) => {
return Number.isFinite(value) ? value : defaultValue;
}

const ALPHA = 'abcdefghijklmnopqrstuvwxyz'

const DIGIT = '0123456789';

const ALPHABET = {
DIGIT,
ALPHA,
ALPHA_DIGIT: ALPHA + ALPHA.toUpperCase() + DIGIT
}

const generateString = (size = 16, alphabet = ALPHABET.ALPHA_DIGIT) => {
let str = '';
const {length} = alphabet;
while (size--) {
str += alphabet[Math.random() * length|0]
}

return str;
}

/**
* If the thing is a FormData object, return true, otherwise return false.
*
* @param {unknown} thing - The thing to check.
*
* @returns {boolean}
*/
function isSpecCompliantForm(thing) {
return !!(thing && isFunction(thing.append) && thing[Symbol.toStringTag] === 'FormData' && thing[Symbol.iterator]);
}

const toJSONObject = (obj) => {
const stack = new Array(10);

Expand Down Expand Up @@ -673,5 +704,8 @@ export default {
findKey,
global: _global,
isContextDefined,
ALPHABET,
generateString,
isSpecCompliantForm,
toJSONObject
};
6 changes: 4 additions & 2 deletions package.json
Expand Up @@ -89,6 +89,7 @@
"es6-promise": "^4.2.8",
"eslint": "^8.17.0",
"express": "^4.18.1",
"formdata-node": "^5.0.0",
"formidable": "^2.0.1",
"fs-extra": "^10.1.0",
"get-stream": "^3.0.0",
Expand Down Expand Up @@ -126,7 +127,8 @@
},
"browser": {
"./lib/adapters/http.js": "./lib/helpers/null.js",
"./lib/platform/node/index.js": "./lib/platform/browser/index.js"
"./lib/platform/node/index.js": "./lib/platform/browser/index.js",
"./lib/platform/node/classes/FormData.js": "./lib/helpers/null.js"
},
"jsdelivr": "dist/axios.min.js",
"unpkg": "dist/axios.min.js",
Expand Down Expand Up @@ -201,4 +203,4 @@
"@commitlint/config-conventional"
]
}
}
}