From 7a09fa9460980ddad36579f3d06b9532cf58de3d Mon Sep 17 00:00:00 2001 From: Sefa Ilkimen Date: Mon, 18 Nov 2019 02:01:02 +0100 Subject: [PATCH] feat: add ponyfills to support multipart requests on android webview versions < 50 and iOS versions < 13.2 --- .vscode/settings.json | 3 ++ plugin.xml | 1 + test/e2e-specs.js | 8 +-- test/e2e-tooling/caps.js | 3 +- test/js-specs.js | 104 ++++++++++++++++++++++++++++++++---- test/mocks/File.mock.js | 5 ++ www/advanced-http.js | 5 +- www/dependency-validator.js | 6 +-- www/helpers.js | 30 ++++++----- www/messages.js | 3 +- www/ponyfills.js | 47 ++++++++++++++++ www/public-interface.js | 5 +- 12 files changed, 184 insertions(+), 36 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 www/ponyfills.js diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ff30c44 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.tabSize": 2 +} \ No newline at end of file diff --git a/plugin.xml b/plugin.xml index 45d2d42..418a77e 100644 --- a/plugin.xml +++ b/plugin.xml @@ -17,6 +17,7 @@ + diff --git a/test/e2e-specs.js b/test/e2e-specs.js index fbb8308..83f753d 100644 --- a/test/e2e-specs.js +++ b/test/e2e-specs.js @@ -799,12 +799,12 @@ const tests = [ } }, { - disabled: true, description: 'should serialize FormData instance correctly when it contains string value', expected: 'resolved: {"status": 200, ...', before: helpers.setMultipartSerializer, func: function (resolve, reject) { - var formData = new FormData(); + var ponyfills = cordova.plugin.http.ponyfills; + var formData = new ponyfills.FormData(); formData.append('myString', 'This is a test!'); var url = 'https://httpbin.org/anything'; @@ -818,13 +818,13 @@ const tests = [ } }, { - disabled: true, description: 'should serialize FormData instance correctly when it contains blob value', expected: 'resolved: {"status": 200, ...', before: helpers.setMultipartSerializer, func: function (resolve, reject) { + var ponyfills = cordova.plugin.http.ponyfills; helpers.getWithXhr(function(blob) { - var formData = new FormData(); + var formData = new ponyfills.FormData(); formData.append('CordovaLogo', blob); var url = 'https://httpbin.org/anything'; diff --git a/test/e2e-tooling/caps.js b/test/e2e-tooling/caps.js index 8cc4a74..dff023e 100644 --- a/test/e2e-tooling/caps.js +++ b/test/e2e-tooling/caps.js @@ -22,8 +22,7 @@ const configs = { }, localAndroidEmulator: { platformName: 'Android', - platformVersion: '9', - automationName: 'XCUITest', + platformVersion: '5', deviceName: 'Android Emulator', autoWebview: true, fullReset: true, diff --git a/test/js-specs.js b/test/js-specs.js index 51516c9..8a2698c 100644 --- a/test/js-specs.js +++ b/test/js-specs.js @@ -529,7 +529,7 @@ describe('Common helpers', function () { const jsUtil = require('../www/js-util'); const messages = require('../www/messages'); const dependencyValidator = require('../www/dependency-validator')(mockWindow, null, messages); - const helpers = require('../www/helpers')(mockWindow, jsUtil, null, messages, base64, null, dependencyValidator); + const helpers = require('../www/helpers')(mockWindow, jsUtil, null, messages, base64, null, dependencyValidator, {}); const testString = 'Test String öäüß 👍😉'; const testStringBase64 = Buffer.from(testString).toString('base64'); @@ -540,11 +540,6 @@ describe('Common helpers', function () { (() => helpers.processData({}, 'utf8')).should.throw(messages.TYPE_MISMATCH_DATA); }); - it('throws an error when needed Web API is not available', () => { - const helpers = require('../www/helpers')({}, jsUtil, null, messages, null, null); - (() => helpers.processData(null, 'multipart')).should.throw(`${messages.INSTANCE_TYPE_NOT_SUPPORTED} FormData`); - }); - it('throws an error when given data does not match allowed instance types', () => { (() => helpers.processData('myString', 'multipart')).should.throw(messages.INSTANCE_TYPE_MISMATCH_DATA); }); @@ -640,12 +635,12 @@ describe('Dependency Validator', function () { }); }); - describe('checkFormDataApi()', function () { - it('throws an error if FormData.entries() API is not supported', function () { + describe('checkFormDataInstance()', function () { + it('throws an error if FormData.entries() is not supported on given instance', function () { const console = new ConsoleMock(); const validator = require('../www/dependency-validator')({ FormData: {}}, console, messages); - (() => validator.checkFormDataApi()).should.throw(messages.MISSING_FORMDATA_ENTRIES_API); + (() => validator.checkFormDataInstance({})).should.throw(messages.MISSING_FORMDATA_ENTRIES_API); }); }); @@ -658,3 +653,94 @@ describe('Dependency Validator', function () { }); }); }); + +describe('Ponyfills', function () { + const mockWindow = { + Blob: BlobMock, + File: FileMock, + }; + + const init = require('../www/ponyfills'); + init.debug = true; + const ponyfills = init(mockWindow); + + describe('Iterator', function () { + it('exposes interface correctly', () => { + const iterator = new ponyfills.Iterator([]); + iterator.next.should.be.a('function'); + }); + + describe('next()', function () { + it('returns iteration object correctly when list is empty', () => { + const iterator = new ponyfills.Iterator([]); + iterator.next().should.be.eql({ done: true, value: undefined }); + }); + + it('returns iteration object correctly when end posititon of list is not reached yet', () => { + const iterator = new ponyfills.Iterator([['first', 'this is the first item']]); + iterator.next().should.be.eql({ done: false, value: ['first', 'this is the first item'] }); + }); + + it('returns iteration object correctly when end posititon of list is already reached', () => { + const iterator = new ponyfills.Iterator([['first', 'this is the first item']]); + iterator.next(); + iterator.next().should.be.eql({ done: true, value: undefined }); + }); + }); + }); + + describe('FormData', function () { + it('exposes interface correctly', () => { + const formData = new ponyfills.FormData(); + + formData.append.should.be.a('function'); + formData.entries.should.be.a('function'); + }); + + describe('append()', function () { + it('appends string value correctly', () => { + const formData = new ponyfills.FormData(); + + formData.append('test', 'myTestString'); + formData.__items[0].should.be.eql(['test', 'myTestString']); + }); + + it('appends numeric value correctly', () => { + const formData = new ponyfills.FormData(); + + formData.append('test', 10); + formData.__items[0].should.be.eql(['test', '10']); + formData.__items[0][1].should.be.a('string'); + }); + + it('appends Blob value correctly', () => { + const formData = new ponyfills.FormData(); + const blob = new BlobMock(['another test'], { type: 'text/plain' }); + + formData.append('myBlob', blob, 'myFileName.txt'); + formData.__items[0].should.be.eql(['myBlob', blob]); + formData.__items[0][1].name.should.be.equal('myFileName.txt'); + formData.__items[0][1].lastModifiedDate.should.be.a('Date'); + }); + + it('appends File value correctly', () => { + const formData = new ponyfills.FormData(); + const blob = new BlobMock(['another test'], { type: 'text/plain' }); + const file = new FileMock(blob, 'myFileName.txt'); + + formData.append('myFile', file, 'myOverriddenFileName.txt'); + formData.__items[0].should.be.eql(['myFile', file]); + formData.__items[0][1].name.should.be.equal('myFileName.txt'); + formData.__items[0][1].lastModifiedDate.should.be.eql(file.lastModifiedDate); + }); + }); + + describe('entries()', function () { + it('returns an iterator correctly', () => { + const formData = new ponyfills.FormData(); + + formData.entries().should.be.an.instanceof(ponyfills.Iterator); + }) + }); + }); +}); diff --git a/test/mocks/File.mock.js b/test/mocks/File.mock.js index 1c1ccfc..9806262 100644 --- a/test/mocks/File.mock.js +++ b/test/mocks/File.mock.js @@ -4,9 +4,14 @@ module.exports = class FileMock extends BlobMock { constructor(blob, fileName) { super(blob, { type: blob.type }); this._fileName = fileName || ''; + this.__lastModifiedDate = new Date(); } get name() { return this._fileName; } + + get lastModifiedDate() { + return this.__lastModifiedDate; + } } diff --git a/www/advanced-http.js b/www/advanced-http.js index 54f90c0..bd1237a 100644 --- a/www/advanced-http.js +++ b/www/advanced-http.js @@ -15,9 +15,10 @@ var lodash = require(pluginId + '.lodash'); var WebStorageCookieStore = require(pluginId + '.local-storage-store')(ToughCookie, lodash); var cookieHandler = require(pluginId + '.cookie-handler')(window.localStorage, ToughCookie, WebStorageCookieStore); var dependencyValidator = require(pluginId + '.dependency-validator')(window, window.console, messages); -var helpers = require(pluginId + '.helpers')(window, jsUtil, cookieHandler, messages, base64, errorCodes, dependencyValidator); +var ponyfills = require(pluginId + '.ponyfills')(window); +var helpers = require(pluginId + '.helpers')(window, jsUtil, cookieHandler, messages, base64, errorCodes, dependencyValidator, ponyfills); var urlUtil = require(pluginId + '.url-util')(jsUtil); -var publicInterface = require(pluginId + '.public-interface')(exec, cookieHandler, urlUtil, helpers, globalConfigs, errorCodes); +var publicInterface = require(pluginId + '.public-interface')(exec, cookieHandler, urlUtil, helpers, globalConfigs, errorCodes, ponyfills); dependencyValidator.logWarnings(); diff --git a/www/dependency-validator.js b/www/dependency-validator.js index 9858a07..9951034 100644 --- a/www/dependency-validator.js +++ b/www/dependency-validator.js @@ -2,7 +2,7 @@ module.exports = function init(global, console, messages) { var interface = { checkBlobApi: checkBlobApi, checkFileReaderApi: checkFileReaderApi, - checkFormDataApi: checkFormDataApi, + checkFormDataInstance: checkFormDataInstance, checkTextEncoderApi: checkTextEncoderApi, logWarnings: logWarnings, }; @@ -29,8 +29,8 @@ module.exports = function init(global, console, messages) { } } - function checkFormDataApi() { - if (!global.FormData || !global.FormData.prototype || !global.FormData.prototype.entries) { + function checkFormDataInstance(instance) { + if (!instance || !instance.entries) { throw new Error(messages.MISSING_FORMDATA_ENTRIES_API); } } diff --git a/www/helpers.js b/www/helpers.js index 05486d6..4b4ed28 100644 --- a/www/helpers.js +++ b/www/helpers.js @@ -1,4 +1,4 @@ -module.exports = function init(global, jsUtil, cookieHandler, messages, base64, errorCodes, dependencyValidator) { +module.exports = function init(global, jsUtil, cookieHandler, messages, base64, errorCodes, dependencyValidator, ponyfills) { var validSerializers = ['urlencoded', 'json', 'utf8', 'multipart']; var validCertModes = ['default', 'nocheck', 'pinned', 'legacy']; var validClientAuthModes = ['none', 'systemstore', 'buffer']; @@ -370,24 +370,30 @@ module.exports = function init(global, jsUtil, cookieHandler, messages, base64, } } - function getAllowedInstanceType(dataSerializer) { - return dataSerializer === 'multipart' ? 'FormData' : null; + function getAllowedInstanceTypes(dataSerializer) { + return dataSerializer === 'multipart' ? ['FormData'] : null; } function processData(data, dataSerializer, cb) { var currentDataType = jsUtil.getTypeOf(data); var allowedDataTypes = getAllowedDataTypes(dataSerializer); - var allowedInstanceType = getAllowedInstanceType(dataSerializer); + var allowedInstanceTypes = getAllowedInstanceTypes(dataSerializer); - if (allowedInstanceType && !global[allowedInstanceType]) { - throw new Error(messages.INSTANCE_TYPE_NOT_SUPPORTED + ' ' + allowedInstanceType); + if (allowedInstanceTypes) { + var isCorrectInstanceType = false; + + allowedInstanceTypes.forEach(function(type) { + if ((global[type] && data instanceof global[type]) || (ponyfills[type] && data instanceof ponyfills[type])) { + isCorrectInstanceType = true; + } + }); + + if (!isCorrectInstanceType) { + throw new Error(messages.INSTANCE_TYPE_MISMATCH_DATA + ' ' + allowedInstanceTypes.join(', ')); + } } - if (allowedInstanceType && !(data instanceof global[allowedInstanceType])) { - throw new Error(messages.INSTANCE_TYPE_MISMATCH_DATA + ' ' + allowedInstanceType); - } - - if (!allowedInstanceType && allowedDataTypes.indexOf(currentDataType) === -1) { + if (!allowedInstanceTypes && allowedDataTypes.indexOf(currentDataType) === -1) { throw new Error(messages.TYPE_MISMATCH_DATA + ' ' + allowedDataTypes.join(', ')); } @@ -404,8 +410,8 @@ module.exports = function init(global, jsUtil, cookieHandler, messages, base64, function processFormData(data, cb) { dependencyValidator.checkBlobApi(); dependencyValidator.checkFileReaderApi(); - dependencyValidator.checkFormDataApi(); dependencyValidator.checkTextEncoderApi(); + dependencyValidator.checkFormDataInstance(data); var textEncoder = new global.TextEncoder('utf8'); var iterator = data.entries(); diff --git a/www/messages.js b/www/messages.js index 03e02dc..3a4f6b8 100644 --- a/www/messages.js +++ b/www/messages.js @@ -3,7 +3,6 @@ module.exports = { EMPTY_FILE_PATHS: 'advanced-http: "filePaths" option array must not be empty, ', EMPTY_NAMES: 'advanced-http: "names" option array must not be empty, ', INSTANCE_TYPE_MISMATCH_DATA: 'advanced-http: "data" option is configured to support only following instance types:', - INSTANCE_TYPE_NOT_SUPPORTED: 'advanced-http: this webview does not support following Web API which is needed for this plugin:', INVALID_CLIENT_AUTH_ALIAS: 'advanced-http: invalid client certificate alias, needs to be a string or undefined, ', INVALID_CLIENT_AUTH_MODE: 'advanced-http: invalid client certificate authentication mode, supported modes are:', INVALID_CLIENT_AUTH_OPTIONS: 'advanced-http: invalid client certificate authentication options, needs to be an dictionary style object', @@ -22,7 +21,7 @@ module.exports = { MISSING_BLOB_API: 'advanced-http: Blob API is not supported in this webview. If you want to use "multipart/form-data" requests, you need to load a polyfill library before loading this plugin. Check out https://github.com/silkimen/cordova-plugin-advanced-http/wiki/Web-APIs-required-for-Multipart-requests for more info.', MISSING_FILE_READER_API: 'advanced-http: FileReader API is not supported in this webview. If you want to use "multipart/form-data" requests, you need to load a polyfill library before loading this plugin. Check out https://github.com/silkimen/cordova-plugin-advanced-http/wiki/Web-APIs-required-for-Multipart-requests for more info.', MISSING_FORMDATA_API: 'advanced-http: FormData API is not supported in this webview. If you want to use "multipart/form-data" requests, you need to load a polyfill library before loading this plugin. Check out https://github.com/silkimen/cordova-plugin-advanced-http/wiki/Web-APIs-required-for-Multipart-requests for more info.', - MISSING_FORMDATA_ENTRIES_API: 'advanced-http: This webview does not implement FormData API specification correctly, FormData.entries() is missing. If you want to use "multipart/form-data" requests, you need to load a polyfill library before loading this plugin. Check out https://github.com/silkimen/cordova-plugin-advanced-http/wiki/Web-APIs-required-for-Multipart-requests for more info.', + MISSING_FORMDATA_ENTRIES_API: 'advanced-http: Given instance of FormData does not implement FormData API specification correctly, FormData.entries() is missing. If you want to use "multipart/form-data" requests, you can use an included ponyfill. Check out https://github.com/silkimen/cordova-plugin-advanced-http/wiki/Web-APIs-required-for-Multipart-requests for more info.', MISSING_TEXT_ENCODER_API: 'advanced-http: TextEncoder API is not supported in this webview. If you want to use "multipart/form-data" requests, you need to load a polyfill library before loading this plugin. Check out https://github.com/silkimen/cordova-plugin-advanced-http/wiki/Web-APIs-required-for-Multipart-requests for more info.', POST_PROCESSING_FAILED: 'advanced-http: an error occured during post processing response:', TYPE_MISMATCH_DATA: 'advanced-http: "data" option is configured to support only following data types:', diff --git a/www/ponyfills.js b/www/ponyfills.js new file mode 100644 index 0000000..67b9bbc --- /dev/null +++ b/www/ponyfills.js @@ -0,0 +1,47 @@ +module.exports = function init(global) { + var interface = { FormData: FormData }; + + // expose all constructor functions for testing purposes + if (init.debug) { + interface.Iterator = Iterator; + } + + function FormData() { + this.__items = []; + } + + FormData.prototype.append = function(name, value, filename) { + if (global.File && value instanceof global.File) { + // nothing to do + } else if (global.Blob && value instanceof global.Blob) { + // mimic File instance by adding missing properties + value.lastModifiedDate = new Date(); + value.name = filename || ''; + } else { + value = value.toString ? value.toString() : value; + } + + this.__items.push([ name, value ]); + }; + + FormData.prototype.entries = function() { + return new Iterator(this.__items); + }; + + function Iterator(items) { + this.__items = items; + this.__position = -1; + } + + Iterator.prototype.next = function() { + this.__position += 1; + + if (this.__position < this.__items.length) { + return { done: false, value: this.__items[this.__position] }; + } + + return { done: true, value: undefined }; + } + + return interface; +}; \ No newline at end of file diff --git a/www/public-interface.js b/www/public-interface.js index 10d81f6..09fb1a3 100644 --- a/www/public-interface.js +++ b/www/public-interface.js @@ -1,4 +1,4 @@ -module.exports = function init(exec, cookieHandler, urlUtil, helpers, globalConfigs, errorCodes) { +module.exports = function init(exec, cookieHandler, urlUtil, helpers, globalConfigs, errorCodes, ponyfills) { var publicInterface = { getBasicAuthHeader: getBasicAuthHeader, useBasicAuth: useBasicAuth, @@ -29,7 +29,8 @@ module.exports = function init(exec, cookieHandler, urlUtil, helpers, globalConf head: head, uploadFile: uploadFile, downloadFile: downloadFile, - ErrorCode: errorCodes + ErrorCode: errorCodes, + ponyfills: ponyfills }; function getBasicAuthHeader(username, password) {