From 16bb6e87ba88b5d8afc1c284d6aa695ab52a06ac Mon Sep 17 00:00:00 2001 From: Philipp von Weitershausen Date: Fri, 20 Jan 2017 18:40:28 -0800 Subject: [PATCH] XHR: support typed arrays for request payloads Summary: Support `xhr.send(data)` for typed arrays. **Test plan:** run UIExplorer example on iOS and Android. Closes https://github.com/facebook/react-native/pull/11904 Differential Revision: D4425551 fbshipit-source-id: 065ab5873407a406ca4a831068ab138606c3361b --- .eslintrc | 1 + Examples/UIExplorer/js/XHRExample.js | 6 + .../UIExplorer/js/XHRExampleBinaryUpload.js | 170 ++++++++++++++++++ Examples/UIExplorer/js/XHRExampleFormData.js | 28 +-- Libraries/Network/RCTNetworking.android.js | 25 +-- Libraries/Network/RCTNetworking.ios.js | 10 +- Libraries/Network/RCTNetworking.mm | 5 + Libraries/Network/convertRequestBody.js | 40 +++++ Libraries/Utilities/binaryToBase64.js | 30 ++++ Libraries/WebSocket/WebSocket.js | 25 +-- .../modules/network/NetworkingModule.java | 16 ++ 11 files changed, 293 insertions(+), 63 deletions(-) create mode 100644 Examples/UIExplorer/js/XHRExampleBinaryUpload.js create mode 100644 Libraries/Network/convertRequestBody.js create mode 100644 Libraries/Utilities/binaryToBase64.js diff --git a/.eslintrc b/.eslintrc index f1f81f0ff..ef11f5e96 100644 --- a/.eslintrc +++ b/.eslintrc @@ -61,6 +61,7 @@ "SyntheticEvent": false, "$Either": false, "$All": false, + "$ArrayBufferView": false, "$Tuple": false, "$Supertype": false, "$Subtype": false, diff --git a/Examples/UIExplorer/js/XHRExample.js b/Examples/UIExplorer/js/XHRExample.js index fdb56088b..5bd0788a4 100644 --- a/Examples/UIExplorer/js/XHRExample.js +++ b/Examples/UIExplorer/js/XHRExample.js @@ -25,6 +25,7 @@ var React = require('react'); var XHRExampleDownload = require('./XHRExampleDownload'); +var XHRExampleBinaryUpload = require('./XHRExampleBinaryUpload'); var XHRExampleFormData = require('./XHRExampleFormData'); var XHRExampleHeaders = require('./XHRExampleHeaders'); var XHRExampleFetch = require('./XHRExampleFetch'); @@ -40,6 +41,11 @@ exports.examples = [{ render() { return ; } +}, { + title: 'multipart/form-data Upload', + render() { + return ; + } }, { title: 'multipart/form-data Upload', render() { diff --git a/Examples/UIExplorer/js/XHRExampleBinaryUpload.js b/Examples/UIExplorer/js/XHRExampleBinaryUpload.js new file mode 100644 index 000000000..34c890fcc --- /dev/null +++ b/Examples/UIExplorer/js/XHRExampleBinaryUpload.js @@ -0,0 +1,170 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @flow + */ +'use strict'; + +const React = require('react'); +const ReactNative = require('react-native'); +const { + Alert, + Linking, + Picker, + StyleSheet, + Text, + TouchableHighlight, + View, +} = ReactNative; + +const BINARY_TYPES = { + String, + ArrayBuffer, + Int8Array, + Uint8Array, + Uint8ClampedArray, + Int16Array, + Uint16Array, + Int32Array, + Uint32Array, + Float32Array, + Float64Array, + DataView, +}; + +const SAMPLE_TEXT = ` +I am the spirit that negates. +And rightly so, for all that comes to be +Deserves to perish wretchedly; +'Twere better nothing would begin. +Thus everything that that your terms, sin, +Destruction, evil represent— +That is my proper element. + +--Faust, JW Goethe +`; + + +class XHRExampleBinaryUpload extends React.Component { + + static handlePostTestServerUpload(xhr: XMLHttpRequest) { + if (xhr.status !== 200) { + Alert.alert( + 'Upload failed', + 'Expected HTTP 200 OK response, got ' + xhr.status + ); + return; + } + if (!xhr.responseText) { + Alert.alert( + 'Upload failed', + 'No response payload.' + ); + return; + } + var index = xhr.responseText.indexOf('http://www.posttestserver.com/'); + if (index === -1) { + Alert.alert( + 'Upload failed', + 'Invalid response payload.' + ); + return; + } + var url = xhr.responseText.slice(index).split('\n')[0]; + console.log('Upload successful: ' + url); + Linking.openURL(url); + } + + state = { + type: 'Uint8Array', + }; + + _upload = () => { + var xhr = new XMLHttpRequest(); + xhr.open('POST', 'http://posttestserver.com/post.php'); + xhr.onload = () => XHRExampleBinaryUpload.handlePostTestServerUpload(xhr); + xhr.setRequestHeader('Content-Type', 'text/plain'); + + if (this.state.type === 'String') { + xhr.send(SAMPLE_TEXT); + return; + } + + const arrayBuffer = new ArrayBuffer(256); + const asBytes = new Uint8Array(arrayBuffer); + for (let i = 0; i < SAMPLE_TEXT.length; i++) { + asBytes[i] = SAMPLE_TEXT.charCodeAt(i); + } + if (this.state.type === 'ArrayBuffer') { + xhr.send(arrayBuffer); + return; + } + if (this.state.type === 'Uint8Array') { + xhr.send(asBytes); + return; + } + + const TypedArrayClass = BINARY_TYPES[this.state.type]; + xhr.send(new TypedArrayClass(arrayBuffer)); + }; + + render() { + return ( + + Upload 255 bytes as... + this.setState({type})}> + {Object.keys(BINARY_TYPES).map((type) => ( + + ))} + + + + + Upload + + + + + ); + } + +} + +const styles = StyleSheet.create({ + uploadButton: { + marginTop: 16, + }, + uploadButtonBox: { + flex: 1, + paddingVertical: 12, + alignItems: 'center', + backgroundColor: 'blue', + borderRadius: 4, + }, + uploadButtonLabel: { + color: 'white', + fontSize: 16, + fontWeight: '500', + }, +}); + +module.exports = XHRExampleBinaryUpload; diff --git a/Examples/UIExplorer/js/XHRExampleFormData.js b/Examples/UIExplorer/js/XHRExampleFormData.js index 9a70b484d..04cc5d086 100644 --- a/Examples/UIExplorer/js/XHRExampleFormData.js +++ b/Examples/UIExplorer/js/XHRExampleFormData.js @@ -38,6 +38,8 @@ const { View, } = ReactNative; +const XHRExampleBinaryUpload = require('./XHRExampleBinaryUpload'); + const PAGE_SIZE = 20; class XHRExampleFormData extends React.Component { @@ -109,31 +111,7 @@ class XHRExampleFormData extends React.Component { xhr.open('POST', 'http://posttestserver.com/post.php'); xhr.onload = () => { this.setState({isUploading: false}); - if (xhr.status !== 200) { - Alert.alert( - 'Upload failed', - 'Expected HTTP 200 OK response, got ' + xhr.status - ); - return; - } - if (!xhr.responseText) { - Alert.alert( - 'Upload failed', - 'No response payload.' - ); - return; - } - var index = xhr.responseText.indexOf('http://www.posttestserver.com/'); - if (index === -1) { - Alert.alert( - 'Upload failed', - 'Invalid response payload.' - ); - return; - } - var url = xhr.responseText.slice(index).split('\n')[0]; - console.log('Upload successful: ' + url); - Linking.openURL(url); + XHRExampleBinaryUpload.handlePostTestServerUpload(xhr); }; var formdata = new FormData(); if (this.state.randomPhoto) { diff --git a/Libraries/Network/RCTNetworking.android.js b/Libraries/Network/RCTNetworking.android.js index 7a0a9073f..e6c879bc4 100644 --- a/Libraries/Network/RCTNetworking.android.js +++ b/Libraries/Network/RCTNetworking.android.js @@ -16,9 +16,14 @@ const FormData = require('FormData'); const NativeEventEmitter = require('NativeEventEmitter'); const RCTNetworkingNative = require('NativeModules').Networking; +const convertRequestBody = require('convertRequestBody'); + +import type {RequestBody} from 'convertRequestBody'; type Header = [string, string]; +// Convert FormData headers to arrays, which are easier to consume in +// native on Android. function convertHeadersMapToArray(headers: Object): Array
{ const headerArray = []; for (const name in headers) { @@ -47,16 +52,19 @@ class RCTNetworking extends NativeEventEmitter { trackingName: string, url: string, headers: Object, - data: string | FormData | {uri: string}, + data: RequestBody, responseType: 'text' | 'base64', incrementalUpdates: boolean, timeout: number, callback: (requestId: number) => any ) { - const body = - typeof data === 'string' ? {string: data} : - data instanceof FormData ? {formData: getParts(data)} : - data; + const body = convertRequestBody(data); + if (body && body.formData) { + body.formData = body.formData.map((part) => ({ + ...part, + headers: convertHeadersMapToArray(part.headers), + })); + } const requestId = generateRequestId(); RCTNetworkingNative.sendRequest( method, @@ -80,11 +88,4 @@ class RCTNetworking extends NativeEventEmitter { } } -function getParts(data) { - return data.getParts().map((part) => { - part.headers = convertHeadersMapToArray(part.headers); - return part; - }); -} - module.exports = new RCTNetworking(); diff --git a/Libraries/Network/RCTNetworking.ios.js b/Libraries/Network/RCTNetworking.ios.js index fca6351dd..b67049036 100644 --- a/Libraries/Network/RCTNetworking.ios.js +++ b/Libraries/Network/RCTNetworking.ios.js @@ -14,6 +14,9 @@ const FormData = require('FormData'); const NativeEventEmitter = require('NativeEventEmitter'); const RCTNetworkingNative = require('NativeModules').Networking; +const convertRequestBody = require('convertRequestBody'); + +import type {RequestBody} from 'convertRequestBody'; class RCTNetworking extends NativeEventEmitter { @@ -26,16 +29,13 @@ class RCTNetworking extends NativeEventEmitter { trackingName: string, url: string, headers: Object, - data: string | FormData | {uri: string}, + data: RequestBody, responseType: 'text' | 'base64', incrementalUpdates: boolean, timeout: number, callback: (requestId: number) => any ) { - const body = - typeof data === 'string' ? {string: data} : - data instanceof FormData ? {formData: data.getParts()} : - data; + const body = convertRequestBody(data); RCTNetworkingNative.sendRequest({ method, url, diff --git a/Libraries/Network/RCTNetworking.mm b/Libraries/Network/RCTNetworking.mm index a8aa11a62..0f6b64557 100644 --- a/Libraries/Network/RCTNetworking.mm +++ b/Libraries/Network/RCTNetworking.mm @@ -303,6 +303,11 @@ RCT_EXPORT_MODULE() if (body) { return callback(nil, @{@"body": body}); } + NSString *base64String = [RCTConvert NSString:query[@"base64"]]; + if (base64String) { + NSData *data = [[NSData alloc] initWithBase64EncodedString:base64String options:0]; + return callback(nil, @{@"body": data}); + } NSURLRequest *request = [RCTConvert NSURLRequest:query[@"uri"]]; if (request) { diff --git a/Libraries/Network/convertRequestBody.js b/Libraries/Network/convertRequestBody.js new file mode 100644 index 000000000..e82689692 --- /dev/null +++ b/Libraries/Network/convertRequestBody.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule convertRequestBody + * @flow + */ +'use strict'; + +const binaryToBase64 = require('binaryToBase64'); + +const FormData = require('FormData'); + +export type RequestBody = + string + | FormData + | {uri: string} + | ArrayBuffer + | $ArrayBufferView + ; + +function convertRequestBody(body: RequestBody): Object { + if (typeof body === 'string') { + return {string: body}; + } + if (body instanceof FormData) { + return {formData: body.getParts()}; + } + if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + // $FlowFixMe: no way to assert that 'body' is indeed an ArrayBufferView + return {base64: binaryToBase64(body)}; + } + return body; +} + +module.exports = convertRequestBody; diff --git a/Libraries/Utilities/binaryToBase64.js b/Libraries/Utilities/binaryToBase64.js new file mode 100644 index 000000000..ba23c5c85 --- /dev/null +++ b/Libraries/Utilities/binaryToBase64.js @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @providesModule binaryToBase64 + * @flow + */ +'use strict'; + +const base64 = require('base64-js'); + +function binaryToBase64(data: ArrayBuffer | $ArrayBufferView) { + if (data instanceof ArrayBuffer) { + data = new Uint8Array(data); + } + if (data instanceof Uint8Array) { + return base64.fromByteArray(data); + } + if (!ArrayBuffer.isView(data)) { + throw new Error('data must be ArrayBuffer or typed array'); + } + const {buffer, byteOffset, byteLength} = data; + return base64.fromByteArray(new Uint8Array(buffer, byteOffset, byteLength)); +} + +module.exports = binaryToBase64; diff --git a/Libraries/WebSocket/WebSocket.js b/Libraries/WebSocket/WebSocket.js index 7496ef1f9..551859e2e 100644 --- a/Libraries/WebSocket/WebSocket.js +++ b/Libraries/WebSocket/WebSocket.js @@ -15,24 +15,13 @@ const NativeEventEmitter = require('NativeEventEmitter'); const Platform = require('Platform'); const RCTWebSocketModule = require('NativeModules').WebSocketModule; const WebSocketEvent = require('WebSocketEvent'); +const binaryToBase64 = require('binaryToBase64'); const EventTarget = require('event-target-shim'); const base64 = require('base64-js'); import type EventSubscription from 'EventSubscription'; -type ArrayBufferView = - Int8Array | - Uint8Array | - Uint8ClampedArray | - Int16Array | - Uint16Array | - Int32Array | - Uint32Array | - Float32Array | - Float64Array | - DataView; - const CONNECTING = 0; const OPEN = 1; const CLOSING = 2; @@ -108,7 +97,7 @@ class WebSocket extends EventTarget(...WEBSOCKET_EVENTS) { this._close(code, reason); } - send(data: string | ArrayBuffer | ArrayBufferView): void { + send(data: string | ArrayBuffer | $ArrayBufferView): void { if (this.readyState === this.CONNECTING) { throw new Error('INVALID_STATE_ERR'); } @@ -118,14 +107,8 @@ class WebSocket extends EventTarget(...WEBSOCKET_EVENTS) { return; } - - if (ArrayBuffer.isView(data)) { - // $FlowFixMe: no way to assert that 'data' is indeed an ArrayBufferView now - data = data.buffer; - } - if (data instanceof ArrayBuffer) { - data = base64.fromByteArray(new Uint8Array(data)); - RCTWebSocketModule.sendBinary(data, this._socketId); + if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + RCTWebSocketModule.sendBinary(binaryToBase64(data), this._socketId); return; } diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java b/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java index bd3062bb0..80f8513a5 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java @@ -46,6 +46,7 @@ import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.ResponseBody; +import okio.ByteString; /** * Implements the XMLHttpRequest JavaScript interface. @@ -58,6 +59,7 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { private static final String REQUEST_BODY_KEY_STRING = "string"; private static final String REQUEST_BODY_KEY_URI = "uri"; private static final String REQUEST_BODY_KEY_FORMDATA = "formData"; + private static final String REQUEST_BODY_KEY_BASE64 = "base64"; private static final String USER_AGENT_HEADER_NAME = "user-agent"; private static final int CHUNK_TIMEOUT_NS = 100 * 1000000; // 100ms private static final int MAX_CHUNK_SIZE_BETWEEN_FLUSHES = 8 * 1024; // 8K @@ -251,6 +253,20 @@ public final class NetworkingModule extends ReactContextBaseJavaModule { } else { requestBuilder.method(method, RequestBody.create(contentMediaType, body)); } + } else if (data.hasKey(REQUEST_BODY_KEY_BASE64)) { + if (contentType == null) { + ResponseUtil.onRequestError( + eventEmitter, + requestId, + "Payload is set but no content-type header specified", + null); + return; + } + String base64String = data.getString(REQUEST_BODY_KEY_BASE64); + MediaType contentMediaType = MediaType.parse(contentType); + requestBuilder.method( + method, + RequestBody.create(contentMediaType, ByteString.decodeBase64(base64String))); } else if (data.hasKey(REQUEST_BODY_KEY_URI)) { if (contentType == null) { ResponseUtil.onRequestError(