WIP: major progress for #101

- feat(www): implement preprocessor for FormData instances
- feat(www): implement API checks for multipart requests
- feat(android): implement multipart requests
- chore(specs): implement www specs for new prprocessor
This commit is contained in:
Sefa Ilkimen
2019-11-11 04:49:35 +01:00
parent b3276ad2d4
commit 594d03aa41
12 changed files with 348 additions and 50 deletions
@@ -1,7 +1,9 @@
package com.silkimen.cordovahttp;
import java.io.IOException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
@@ -19,9 +21,11 @@ import com.silkimen.http.TLSConfiguration;
import org.apache.cordova.CallbackContext;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import android.util.Base64;
import android.util.Log;
abstract class CordovaHttpBase implements Runnable {
@@ -120,7 +124,7 @@ abstract class CordovaHttpBase implements Runnable {
request.readTimeout(this.timeout);
request.acceptCharset("UTF-8");
request.uncompress(true);
request.setConnectionFactory(new OkConnectionFactory());
HttpRequest.setConnectionFactory(new OkConnectionFactory());
if (this.tlsConfiguration.getHostnameVerifier() != null) {
request.setHostnameVerifier(this.tlsConfiguration.getHostnameVerifier());
@@ -141,6 +145,8 @@ abstract class CordovaHttpBase implements Runnable {
request.contentType("text/plain", "UTF-8");
} else if ("urlencoded".equals(this.serializer)) {
// intentionally left blank, because content type is set in HttpRequest.form()
} else if ("multipart".equals(this.serializer)) {
request.contentType("multipart/form-data");
}
}
@@ -155,6 +161,22 @@ abstract class CordovaHttpBase implements Runnable {
request.send(((JSONObject) this.data).getString("text"));
} else if ("urlencoded".equals(this.serializer)) {
request.form(JsonUtils.getObjectMap((JSONObject) this.data));
} else if ("multipart".equals(this.serializer)) {
JSONArray buffers = ((JSONObject) this.data).getJSONArray("buffers");
JSONArray names = ((JSONObject) this.data).getJSONArray("names");
JSONArray fileNames = ((JSONObject) this.data).getJSONArray("fileNames");
JSONArray types = ((JSONObject) this.data).getJSONArray("types");
for (int i = 0; i < buffers.length(); ++i) {
byte[] bytes = Base64.decode(buffers.getString(i), Base64.DEFAULT);
String name = names.getString(i);
if (fileNames.isNull(i)) {
request.part(name, new String(bytes, "UTF-8"));
} else {
request.part(name, fileNames.getString(i), types.getString(i), new ByteArrayInputStream(bytes));
}
}
}
}
+24 -2
View File
@@ -33,6 +33,7 @@ const helpers = {
setJsonSerializer: function (resolve) { resolve(cordova.plugin.http.setDataSerializer('json')); },
setUtf8StringSerializer: function (resolve) { resolve(cordova.plugin.http.setDataSerializer('utf8')); },
setUrlEncodedSerializer: function (resolve) { resolve(cordova.plugin.http.setDataSerializer('urlencoded')); },
setMultipartSerializer: function (resolve) { resolve(cordova.plugin.http.setDataSerializer('multipart')); },
disableFollowingRedirect: function (resolve) { resolve(cordova.plugin.http.setFollowRedirect(false)); },
enableFollowingRedirect: function(resolve) { resolve(cordova.plugin.http.setFollowRedirect(true)); },
getWithXhr: function (done, url, type) {
@@ -789,8 +790,29 @@ const tests = [
result.data.status.should.be.equal(418);
result.data.error.should.be.equal("\n -=[ teapot ]=-\n\n _...._\n .' _ _ `.\n | .\"` ^ `\". _,\n \\_;`\"---\"`|//\n | ;/\n \\_ _/\n `\"\"\"`\n");
}
}
// @TODO: not ready yet
},
// TODO: not ready yet
// {
// 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();
// formData.append('myString', 'This is a test!');
// var url = 'https://httpbin.org/anything';
// var options = { method: 'post', data: formData };
// cordova.plugin.http.sendRequest(url, options, resolve, reject);
// },
// validationFunc: function (driver, result) {
// console.log(result.data);
// result.type.should.be.equal('resolved');
// result.data.status.should.be.equal(200);
// }
// }
// TODO: not ready yet
// {
// description: 'should authenticate correctly when client cert auth is configured with a PKCS12 container',
// expected: 'resolved: {"status": 200, ...',
+98 -16
View File
@@ -1,8 +1,11 @@
const chai = require('chai');
const mock = require('mock-require');
const util = require('util');
const should = chai.should();
const BlobMock = require('./mocks/Blob.mock');
const ConsoleMock = require('./mocks/Console.mock');
const FileReaderMock = require('./mocks/FileReader.mock');
const FormDataMock = require('./mocks/FormData.mock');
describe('Advanced HTTP public interface', function () {
@@ -32,7 +35,7 @@ describe('Advanced HTTP public interface', function () {
beforeEach(() => {
// mocked btoa function (base 64 encoding strings)
global.btoa = decoded => new Buffer(decoded).toString('base64');
global.btoa = decoded => Buffer.from(decoded).toString('base64');
loadHttp(getDependenciesBlueprint());
});
@@ -512,33 +515,82 @@ describe('Common helpers', function () {
});
});
describe('getProcessedData()', function () {
describe('processData()', function () {
const mockWindow = {
Blob: BlobMock,
FileReader: FileReaderMock,
FormData: FormDataMock,
TextEncoder: util.TextEncoder,
}
const base64 = { fromArrayBuffer: ab => Buffer.from(ab).toString('base64') };
const jsUtil = require('../www/js-util');
const messages = require('../www/messages');
const dependencyValidator = require('../www/dependency-validator')(FormDataMock, null, messages);
const helpers = require('../www/helpers')({ FormData: FormDataMock }, jsUtil, null, messages, null, null, dependencyValidator);
const dependencyValidator = require('../www/dependency-validator')(mockWindow, null, messages);
const helpers = require('../www/helpers')(mockWindow, jsUtil, null, messages, base64, null, dependencyValidator);
const testString = 'Test String öäüß 👍😉';
const testStringBase64 = Buffer.from(testString).toString('base64');
it('throws an error when given data does not match allowed data types', () => {
(() => helpers.getProcessedData('myString', 'urlencoded')).should.throw(messages.TYPE_MISMATCH_DATA);
(() => helpers.getProcessedData('myString', 'json')).should.throw(messages.TYPE_MISMATCH_DATA);
(() => helpers.getProcessedData({}, 'utf8')).should.throw(messages.TYPE_MISMATCH_DATA);
(() => helpers.processData('myString', 'urlencoded')).should.throw(messages.TYPE_MISMATCH_DATA);
(() => helpers.processData('myString', 'json')).should.throw(messages.TYPE_MISMATCH_DATA);
(() => 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.getProcessedData(null, 'multipart')).should.throw(`${messages.INSTANCE_TYPE_NOT_SUPPORTED} FormData`);
(() => 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.getProcessedData('myString', 'multipart')).should.throw(messages.INSTANCE_TYPE_MISMATCH_DATA);
(() => helpers.processData('myString', 'multipart')).should.throw(messages.INSTANCE_TYPE_MISMATCH_DATA);
});
it('processes data correctly when serializer "utf8" is configured', () => {
helpers.getProcessedData('myString', 'utf8').should.be.eql({text: 'myString'});
it('processes data correctly when serializer "utf8" is configured', (cb) => {
helpers.processData('myString', 'utf8', (data) => {
data.should.be.eql({text: 'myString'});
cb();
})
});
it('processes data correctly when serializer "multipart" is configured', () => {
helpers.getProcessedData(new FormDataMock(), 'multipart');
it('processes data correctly when serializer "multipart" is configured and form data contains string value', (cb) => {
const formData = new FormDataMock();
formData.append('myString', testString);
helpers.processData(formData, 'multipart', (data) => {
data.buffers.length.should.be.equal(1);
data.names.length.should.be.equal(1);
data.fileNames.length.should.be.equal(1);
data.types.length.should.be.equal(1);
data.buffers[0].should.be.eql(testStringBase64);
data.names[0].should.be.equal('myString');
should.equal(data.fileNames[0], null);
data.types[0].should.be.equal('text/plain');
cb();
});
});
it('processes data correctly when serializer "multipart" is configured and form data contains file value', (cb) => {
const formData = new FormDataMock();
formData.append('myFile', new BlobMock([testString], { type: 'application/octet-stream' }));
helpers.processData(formData, 'multipart', (data) => {
data.buffers.length.should.be.equal(1);
data.names.length.should.be.equal(1);
data.fileNames.length.should.be.equal(1);
data.types.length.should.be.equal(1);
data.buffers[0].should.be.eql(testStringBase64);
data.names[0].should.be.equal('myFile');
data.fileNames[0].should.be.equal('blob');
data.types[0].should.be.equal('application/octet-stream');
cb();
});
});
});
});
@@ -550,7 +602,7 @@ describe('Dependency Validator', function () {
it('logs a warning message if FormData API is not supported', function () {
const console = new ConsoleMock();
require('../www/dependency-validator')(undefined, console, messages).logWarnings();
require('../www/dependency-validator')({}, console, messages).logWarnings();
console.messageList.length.should.be.equal(1);
console.messageList[0].type.should.be.equal('warn');
@@ -560,7 +612,7 @@ describe('Dependency Validator', function () {
it('logs a warning message if FormData.entries() API is not supported', function () {
const console = new ConsoleMock();
require('../www/dependency-validator')({}, console, messages).logWarnings();
require('../www/dependency-validator')({ FormData: {} }, console, messages).logWarnings();
console.messageList.length.should.be.equal(1);
console.messageList[0].type.should.be.equal('warn');
@@ -568,9 +620,39 @@ describe('Dependency Validator', function () {
});
});
describe('checkBlobApi()', function () {
it('throws an error if Blob API is not supported', function () {
const console = new ConsoleMock();
const validator = require('../www/dependency-validator')({}, console, messages);
(() => validator.checkBlobApi()).should.throw(messages.MISSING_BLOB_API);
});
});
describe('checkFileReaderApi()', function () {
it('throws an error if FileReader API is not supported', function () {
const console = new ConsoleMock();
const validator = require('../www/dependency-validator')({}, console, messages);
(() => validator.checkFileReaderApi()).should.throw(messages.MISSING_FILE_READER_API);
});
});
describe('checkFormDataApi()', function () {
it('throws an error if FormData.entries() API is not supported', function () {
(() => require('../www/dependency-validator')(null, null, messages).checkFormDataApi()).should.throw(messages.MISSING_FORMDATA_ENTRIES_API);
const console = new ConsoleMock();
const validator = require('../www/dependency-validator')({ FormData: {}}, console, messages);
(() => validator.checkFormDataApi()).should.throw(messages.MISSING_FORMDATA_ENTRIES_API);
});
});
describe('checkTextEncoderApi()', function () {
it('throws an error if TextEncoder API is not supported', function () {
const console = new ConsoleMock();
const validator = require('../www/dependency-validator')({}, console, messages);
(() => validator.checkTextEncoderApi()).should.throw(messages.MISSING_TEXT_ENCODER_API);
});
});
});
+35
View File
@@ -0,0 +1,35 @@
module.exports = class BlobMock {
constructor(blobParts, options) {
if (blobParts instanceof BlobMock) {
this._buffer = blobParts._buffer;
} else {
this._buffer = new Uint8Array(Buffer.concat(blobParts.map(part => Buffer.from(part, 'utf8')))).buffer;
}
this._type = options.type || '';
}
get size() {
return this._buffer.length;
}
get type() {
return this._type;
}
arrayBuffer() {
throw new Error('Not implemented in BlobMock.');
}
slice() {
throw new Error('Not implemented in BlobMock.');
}
stream() {
throw new Error('Not implemented in BlobMock.');
}
text() {
throw new Error('Not implemented in BlobMock.');
}
}
+12
View File
@@ -0,0 +1,12 @@
const BlobMock = require('./Blob.mock');
module.exports = class FileMock extends BlobMock {
constructor(blob, fileName) {
super(blob, { type: blob.type });
this._fileName = fileName || '';
}
get name() {
return this._fileName;
}
}
+39
View File
@@ -0,0 +1,39 @@
module.exports = class FileReaderMock {
constructor() {
this.EMPTY = 0;
this.LOADING = 1;
this.DONE = 2;
this.error = null;
this.onabort = () => {};
this.onerror = () => {};
this.onload = () => {};
this.onloadend = () => {};
this.onloadstart = () => {};
this.onprogress = () => {};
this.readyState = this.EMPTY;
this.result = null;
}
readAsArrayBuffer(file) {
this.readyState = this.LOADING;
this.onloadstart();
this.onprogress();
this.result = file._buffer;
this.readyState = this.DONE;
this.onloadend();
this.onload();
}
readAsBinaryString() {
throw new Error('Not implemented in FileReaderMock.');
}
readAsDataUrl() {
throw new Error('Not implemented in FileReaderMock.');
}
readAsText() {
throw new Error('Not implemented in FileReaderMock.');
}
}
+24 -13
View File
@@ -1,6 +1,17 @@
const BlobMock = require('./Blob.mock');
const FileMock = require('./File.mock');
module.exports = class FormDataMock {
append() {
throw new Error('Not implemented in FormDataMock.');
constructor() {
this.map = new Map();
}
append(name, value, filename) {
if (value instanceof BlobMock) {
this.map.set(name, new FileMock(value, filename))
} else {
this.map.set(name, value);
}
}
delete() {
@@ -8,34 +19,34 @@ module.exports = class FormDataMock {
}
entries() {
throw new Error('Not implemented in FormDataMock.');
return this.map.entries();
}
forEach() {
throw new Error('Not implemented in FormDataMock.');
forEach(cb) {
return this.map.forEach(cb);
}
get() {
throw new Error('Not implemented in FormDataMock.');
get(key) {
return this.map.get(key);
}
getAll() {
throw new Error('Not implemented in FormDataMock.');
}
has() {
throw new Error('Not implemented in FormDataMock.');
has(key) {
return this.map.has(key);
}
keys() {
throw new Error('Not implemented in FormDataMock.');
return this.map.keys();
}
set() {
throw new Error('Not implemented in FormDataMock.');
set(key, value) {
return this.map.set(key, value);
}
values() {
throw new Error('Not implemented in FormDataMock.');
return this.map.values();
}
};
+1 -1
View File
@@ -14,7 +14,7 @@ var ToughCookie = require(pluginId + '.tough-cookie');
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.FormData, window.console, messages);
var dependencyValidator = require(pluginId + '.dependency-validator')(window, window.console, messages);
var helpers = require(pluginId + '.helpers')(window, jsUtil, cookieHandler, messages, base64, errorCodes, dependencyValidator);
var urlUtil = require(pluginId + '.url-util')(jsUtil);
var publicInterface = require(pluginId + '.public-interface')(exec, cookieHandler, urlUtil, helpers, globalConfigs, errorCodes);
+25 -4
View File
@@ -1,22 +1,43 @@
module.exports = function init(FormData, console, messages) {
module.exports = function init(global, console, messages) {
var interface = {
checkBlobApi: checkBlobApi,
checkFileReaderApi: checkFileReaderApi,
checkFormDataApi: checkFormDataApi,
checkTextEncoderApi: checkTextEncoderApi,
logWarnings: logWarnings,
};
return interface;
function logWarnings() {
if (!FormData) {
if (!global.FormData) {
console.warn(messages.MISSING_FORMDATA_API);
} else if (!FormData.prototype || !FormData.prototype.entries) {
} else if (!global.FormData.prototype || !global.FormData.prototype.entries) {
console.warn(messages.MISSING_FORMDATA_ENTRIES_API);
}
}
function checkBlobApi() {
if (!global.Blob || !global.Blob.prototype) {
throw new Error(messages.MISSING_BLOB_API);
}
}
function checkFileReaderApi() {
if (!global.FileReader || !global.FileReader.prototype) {
throw new Error(messages.MISSING_FILE_READER_API);
}
}
function checkFormDataApi() {
if (!FormData || !FormData.prototype || !FormData.prototype.entries) {
if (!global.FormData || !global.FormData.prototype || !global.FormData.prototype.entries) {
throw new Error(messages.MISSING_FORMDATA_ENTRIES_API);
}
}
function checkTextEncoderApi() {
if (!global.TextEncoder || !global.TextEncoder.prototype) {
throw new Error(messages.MISSING_TEXT_ENCODER_API);
}
}
};
+58 -8
View File
@@ -18,7 +18,7 @@ module.exports = function init(global, jsUtil, cookieHandler, messages, base64,
checkTimeoutValue: checkTimeoutValue,
checkUploadFileOptions: checkUploadFileOptions,
getMergedHeaders: getMergedHeaders,
getProcessedData: getProcessedData,
processData: processData,
handleMissingCallbacks: handleMissingCallbacks,
handleMissingOptions: handleMissingOptions,
injectCookieHandler: injectCookieHandler,
@@ -374,7 +374,7 @@ module.exports = function init(global, jsUtil, cookieHandler, messages, base64,
return dataSerializer === 'multipart' ? 'FormData' : null;
}
function getProcessedData(data, dataSerializer) {
function processData(data, dataSerializer, cb) {
var currentDataType = jsUtil.getTypeOf(data);
var allowedDataTypes = getAllowedDataTypes(dataSerializer);
var allowedInstanceType = getAllowedInstanceType(dataSerializer);
@@ -391,17 +391,67 @@ module.exports = function init(global, jsUtil, cookieHandler, messages, base64,
throw new Error(messages.TYPE_MISMATCH_DATA + ' ' + allowedDataTypes.join(', '));
}
if (dataSerializer === 'utf8') {
data = { text: data };
switch (dataSerializer) {
case 'utf8':
return cb({ text: data });
case 'multipart':
return processFormData(data, cb);
default:
return cb(data);
}
}
function processFormData(data, cb) {
dependencyValidator.checkBlobApi();
dependencyValidator.checkFileReaderApi();
dependencyValidator.checkFormDataApi();
dependencyValidator.checkTextEncoderApi();
var textEncoder = new global.TextEncoder('utf8');
var iterator = data.entries();
var result = {
buffers: [],
names: [],
fileNames: [],
types: []
};
processFormDataIterator(iterator, textEncoder, result, cb);
}
function processFormDataIterator(iterator, textEncoder, result, onFinished) {
var entry = iterator.next();
if (entry.done) {
return onFinished(result);
}
if (dataSerializer === 'multipart') {
dependencyValidator.checkFormDataApi();
if (entry.value[1] instanceof global.Blob) {
var reader = new global.FileReader();
// TODO
reader.onload = function() {
result.buffers.push(base64.fromArrayBuffer(reader.result));
result.names.push(entry.value[0]);
result.fileNames.push(entry.value[1].name || 'blob');
result.types.push(entry.value[1].type || '');
processFormDataIterator(iterator, textEncoder, result, onFinished);
};
return reader.readAsArrayBuffer(entry.value[1]);
}
return data;
if (jsUtil.getTypeOf(entry.value[1]) === 'String') {
result.buffers.push(base64.fromArrayBuffer(textEncoder.encode(entry.value[1]).buffer));
result.names.push(entry.value[0]);
result.fileNames.push(null);
result.types.push('text/plain');
return processFormDataIterator(iterator, textEncoder, result, onFinished)
}
// skip items which are not supported
processFormDataIterator(iterator, textEncoder, result, onFinished);
}
function handleMissingCallbacks(successFn, failFn) {
+5 -2
View File
@@ -19,8 +19,11 @@ module.exports = {
INVALID_TIMEOUT_VALUE: 'advanced-http: invalid timeout value, needs to be a positive numeric value, <timeout: number>',
MANDATORY_FAIL: 'advanced-http: missing mandatory "onFail" callback function',
MANDATORY_SUCCESS: 'advanced-http: missing mandatory "onSuccess" callback function',
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/FormData-API-requirements 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/FormData-API-requirements for more info.',
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_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:',
TYPE_MISMATCH_FILE_PATHS: 'advanced-http: "filePaths" option needs to be an string array, <filePaths: string[]>',
+3 -2
View File
@@ -152,8 +152,9 @@ module.exports = function init(exec, cookieHandler, urlUtil, helpers, globalConf
case 'post':
case 'put':
case 'patch':
var data = helpers.getProcessedData(options.data, options.serializer);
return exec(onSuccess, onFail, 'CordovaHttpPlugin', options.method, [url, data, options.serializer, headers, options.timeout, options.followRedirect, options.responseType]);
return helpers.processData(options.data, options.serializer, function(data) {
exec(onSuccess, onFail, 'CordovaHttpPlugin', options.method, [url, data, options.serializer, headers, options.timeout, options.followRedirect, options.responseType]);
});
case 'upload':
var fileOptions = helpers.checkUploadFileOptions(options.filePath, options.name);
return exec(onSuccess, onFail, 'CordovaHttpPlugin', 'uploadFiles', [url, headers, fileOptions.filePaths, fileOptions.names, options.timeout, options.followRedirect, options.responseType]);