From 3b27142fa329da250eab5f7e54d94ce14c8abd73 Mon Sep 17 00:00:00 2001 From: Dante1349 Date: Thu, 20 Jan 2022 10:45:46 +0100 Subject: [PATCH 1/2] Enable compressed payload for http requests Enable gzip compression of body payload gzip init logs gzip flag fix headers refactoring improved casting Gzip compression for web (pako) Gzip compression fix of headers --- .../http/CapacitorHttpUrlConnection.java | 27 +- .../plugin/http/HttpRequestHandler.java | 18 +- ios/Plugin/CapacitorUrlRequest.swift | 19 +- ios/Plugin/Data+Gzip.swift | 290 ++++++++++++++++++ ios/Plugin/HttpRequestHandler.swift | 9 +- package.json | 6 +- src/definitions.ts | 5 + src/request.ts | 11 +- 8 files changed, 366 insertions(+), 19 deletions(-) create mode 100644 ios/Plugin/Data+Gzip.swift diff --git a/android/src/main/java/com/getcapacitor/plugin/http/CapacitorHttpUrlConnection.java b/android/src/main/java/com/getcapacitor/plugin/http/CapacitorHttpUrlConnection.java index f4170856..53997b10 100644 --- a/android/src/main/java/com/getcapacitor/plugin/http/CapacitorHttpUrlConnection.java +++ b/android/src/main/java/com/getcapacitor/plugin/http/CapacitorHttpUrlConnection.java @@ -3,9 +3,11 @@ import android.os.Build; import android.os.LocaleList; import android.text.TextUtils; +import android.util.Log; import com.getcapacitor.JSArray; import com.getcapacitor.JSObject; import com.getcapacitor.PluginCall; +import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; import java.io.IOException; import java.io.InputStream; @@ -20,6 +22,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.zip.GZIPOutputStream; import org.json.JSONException; public class CapacitorHttpUrlConnection implements ICapacitorHttpUrlConnection { @@ -170,6 +173,7 @@ public void setDoOutput(boolean shouldDoOutput) { */ public void setRequestBody(PluginCall call, JSValue body) throws JSONException, IOException { String contentType = connection.getRequestProperty("Content-Type"); + Boolean gzipCompression = call.getBoolean("gzipCompression", false); String dataString = ""; if (contentType == null || contentType.isEmpty()) return; @@ -186,7 +190,7 @@ public void setRequestBody(PluginCall call, JSValue body) throws JSONException, } else if (body == null) { dataString = call.getString("data"); } - this.writeRequestBody(dataString.toString()); + this.writeRequestBody(dataString.toString(), gzipCompression); } else if (contentType.contains("application/x-www-form-urlencoded")) { StringBuilder builder = new StringBuilder(); @@ -201,7 +205,7 @@ public void setRequestBody(PluginCall call, JSValue body) throws JSONException, builder.append("&"); } } - this.writeRequestBody(builder.toString()); + this.writeRequestBody(builder.toString(), gzipCompression); } else if (contentType.contains("multipart/form-data")) { FormUploader uploader = new FormUploader(connection); @@ -215,7 +219,7 @@ public void setRequestBody(PluginCall call, JSValue body) throws JSONException, } uploader.finish(); } else { - this.writeRequestBody(body.toString()); + this.writeRequestBody(body.toString(), gzipCompression); } } @@ -224,10 +228,17 @@ public void setRequestBody(PluginCall call, JSValue body) throws JSONException, * * @param body The string value to write to the connection stream. */ - private void writeRequestBody(String body) throws IOException { - try (DataOutputStream os = new DataOutputStream(connection.getOutputStream())) { - os.write(body.getBytes(StandardCharsets.UTF_8)); - os.flush(); + private void writeRequestBody(String body, boolean gzipCompression) throws IOException { + if (gzipCompression) { + try (GZIPOutputStream gos = new GZIPOutputStream(connection.getOutputStream())) { + gos.write(body.getBytes(StandardCharsets.UTF_8)); + gos.flush(); + } + } else { + try (DataOutputStream dos = new DataOutputStream(connection.getOutputStream())) { + dos.write(body.getBytes(StandardCharsets.UTF_8)); + dos.flush(); + } } } @@ -279,7 +290,7 @@ public int getResponseCode() throws IOException { * * @return the value of this {@code URLConnection}'s {@code URL} * field. - * @see java.net.URLConnection#url + * @see java.net.URLConnection */ public URL getURL() { return connection.getURL(); diff --git a/android/src/main/java/com/getcapacitor/plugin/http/HttpRequestHandler.java b/android/src/main/java/com/getcapacitor/plugin/http/HttpRequestHandler.java index 215beb51..389c5edc 100644 --- a/android/src/main/java/com/getcapacitor/plugin/http/HttpRequestHandler.java +++ b/android/src/main/java/com/getcapacitor/plugin/http/HttpRequestHandler.java @@ -127,11 +127,11 @@ public HttpURLConnectionBuilder setUrlParams(JSObject params, boolean shouldEnco String initialQueryBuilderStr = initialQuery == null ? "" : initialQuery; Iterator keys = params.keys(); - + if (!keys.hasNext()) { return this; } - + StringBuilder urlQueryBuilder = new StringBuilder(initialQueryBuilderStr); // Build the new query string @@ -167,7 +167,13 @@ public HttpURLConnectionBuilder setUrlParams(JSObject params, boolean shouldEnco URI encodedUri = new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), urlQuery, uri.getFragment()); this.url = encodedUri.toURL(); } else { - String unEncodedUrlString = uri.getScheme() + "://" + uri.getAuthority() + uri.getPath() + ((!urlQuery.equals("")) ? "?" + urlQuery : "") + ((uri.getFragment() != null) ? uri.getFragment() : ""); + String unEncodedUrlString = + uri.getScheme() + + "://" + + uri.getAuthority() + + uri.getPath() + + ((!urlQuery.equals("")) ? "?" + urlQuery : "") + + ((uri.getFragment() != null) ? uri.getFragment() : ""); this.url = new URL(unEncodedUrlString); } @@ -368,12 +374,18 @@ public static JSObject request(PluginCall call, String httpMethod) throws IOExce Integer readTimeout = call.getInt("readTimeout"); Boolean disableRedirects = call.getBoolean("disableRedirects"); Boolean shouldEncode = call.getBoolean("shouldEncodeUrlParams", true); + Boolean gzipCompression = call.getBoolean("gzipCompression", false); ResponseType responseType = ResponseType.parse(call.getString("responseType")); String method = httpMethod != null ? httpMethod.toUpperCase() : call.getString("method", "").toUpperCase(); boolean isHttpMutate = method.equals("DELETE") || method.equals("PATCH") || method.equals("POST") || method.equals("PUT"); + // Set gzip header if compression is enabled + if (gzipCompression) { + headers.put("Content-Encoding", "gzip"); + } + URL url = new URL(urlString); HttpURLConnectionBuilder connectionBuilder = new HttpURLConnectionBuilder() .setUrl(url) diff --git a/ios/Plugin/CapacitorUrlRequest.swift b/ios/Plugin/CapacitorUrlRequest.swift index ac846d1a..3d9e0c7f 100644 --- a/ios/Plugin/CapacitorUrlRequest.swift +++ b/ios/Plugin/CapacitorUrlRequest.swift @@ -118,11 +118,24 @@ public class CapacitorUrlRequest: NSObject, URLSessionTaskDelegate { } } - public func setRequestBody(_ body: JSValue) throws { - let contentType = self.getRequestHeader("Content-Type") as? String + public func getRequestDataAsGzip(_ body: JSValue) throws -> Data? { + // string to Data + let dataBody = try getRequestDataAsString(body) + // gzip compression + let compressedData: Data = try dataBody.gzipped() + return compressedData + } + + public func setRequestBody(_ body: JSValue, _ gzipCompression: Bool) throws { + let contentType = self.getRequestHeader("Content-Type") as? String + if contentType != nil { - request.httpBody = try getRequestData(body, contentType!) + if(gzipCompression) { + request.httpBody = try getRequestDataAsGzip(body) + } else { + request.httpBody = try getRequestData(body, contentType!) + } } } diff --git a/ios/Plugin/Data+Gzip.swift b/ios/Plugin/Data+Gzip.swift new file mode 100644 index 00000000..6848fd7d --- /dev/null +++ b/ios/Plugin/Data+Gzip.swift @@ -0,0 +1,290 @@ +// +// Data+Gzip.swift +// + +/* + The MIT License (MIT) + + © 2014-2020 1024jp + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 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 NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS 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. + */ + +import struct Foundation.Data + +#if os(Linux) + import zlibLinux +#else + import zlib +#endif + +/// Compression level whose rawValue is based on the zlib's constants. +public struct CompressionLevel: RawRepresentable { + + /// Compression level in the range of `0` (no compression) to `9` (maximum compression). + public let rawValue: Int32 + + public static let noCompression = CompressionLevel(Z_NO_COMPRESSION) + public static let bestSpeed = CompressionLevel(Z_BEST_SPEED) + public static let bestCompression = CompressionLevel(Z_BEST_COMPRESSION) + + public static let defaultCompression = CompressionLevel(Z_DEFAULT_COMPRESSION) + + + public init(rawValue: Int32) { + + self.rawValue = rawValue + } + + + public init(_ rawValue: Int32) { + + self.rawValue = rawValue + } + +} + + +/// Errors on gzipping/gunzipping based on the zlib error codes. +public struct GzipError: Swift.Error { + // cf. http://www.zlib.net/manual.html + + public enum Kind: Equatable { + /// The stream structure was inconsistent. + /// + /// - underlying zlib error: `Z_STREAM_ERROR` (-2) + case stream + + /// The input data was corrupted + /// (input stream not conforming to the zlib format or incorrect check value). + /// + /// - underlying zlib error: `Z_DATA_ERROR` (-3) + case data + + /// There was not enough memory. + /// + /// - underlying zlib error: `Z_MEM_ERROR` (-4) + case memory + + /// No progress is possible or there was not enough room in the output buffer. + /// + /// - underlying zlib error: `Z_BUF_ERROR` (-5) + case buffer + + /// The zlib library version is incompatible with the version assumed by the caller. + /// + /// - underlying zlib error: `Z_VERSION_ERROR` (-6) + case version + + /// An unknown error occurred. + /// + /// - parameter code: return error by zlib + case unknown(code: Int) + } + + /// Error kind. + public let kind: Kind + + /// Returned message by zlib. + public let message: String + + + internal init(code: Int32, msg: UnsafePointer?) { + + self.message = { + guard let msg = msg, let message = String(validatingUTF8: msg) else { + return "Unknown gzip error" + } + return message + }() + + self.kind = { + switch code { + case Z_STREAM_ERROR: + return .stream + case Z_DATA_ERROR: + return .data + case Z_MEM_ERROR: + return .memory + case Z_BUF_ERROR: + return .buffer + case Z_VERSION_ERROR: + return .version + default: + return .unknown(code: Int(code)) + } + }() + } + + + public var localizedDescription: String { + + return self.message + } + +} + + +extension Data { + + /// Whether the receiver is compressed in gzip format. + public var isGzipped: Bool { + + return self.starts(with: [0x1f, 0x8b]) // check magic number + } + + + /// Create a new `Data` instance by compressing the receiver using zlib. + /// Throws an error if compression failed. + /// + /// - Parameter level: Compression level. + /// - Returns: Gzip-compressed `Data` instance. + /// - Throws: `GzipError` + public func gzipped(level: CompressionLevel = .defaultCompression) throws -> Data { + + guard !self.isEmpty else { + return Data() + } + + var stream = z_stream() + var status: Int32 + + status = deflateInit2_(&stream, level.rawValue, Z_DEFLATED, MAX_WBITS + 16, MAX_MEM_LEVEL, Z_DEFAULT_STRATEGY, ZLIB_VERSION, Int32(DataSize.stream)) + + guard status == Z_OK else { + // deflateInit2 returns: + // Z_VERSION_ERROR The zlib library version is incompatible with the version assumed by the caller. + // Z_MEM_ERROR There was not enough memory. + // Z_STREAM_ERROR A parameter is invalid. + + throw GzipError(code: status, msg: stream.msg) + } + + var data = Data(capacity: DataSize.chunk) + repeat { + if Int(stream.total_out) >= data.count { + data.count += DataSize.chunk + } + + let inputCount = self.count + let outputCount = data.count + + self.withUnsafeBytes { (inputPointer: UnsafeRawBufferPointer) in + stream.next_in = UnsafeMutablePointer(mutating: inputPointer.bindMemory(to: Bytef.self).baseAddress!).advanced(by: Int(stream.total_in)) + stream.avail_in = uint(inputCount) - uInt(stream.total_in) + + data.withUnsafeMutableBytes { (outputPointer: UnsafeMutableRawBufferPointer) in + stream.next_out = outputPointer.bindMemory(to: Bytef.self).baseAddress!.advanced(by: Int(stream.total_out)) + stream.avail_out = uInt(outputCount) - uInt(stream.total_out) + + status = deflate(&stream, Z_FINISH) + + stream.next_out = nil + } + + stream.next_in = nil + } + + } while stream.avail_out == 0 + + guard deflateEnd(&stream) == Z_OK, status == Z_STREAM_END else { + throw GzipError(code: status, msg: stream.msg) + } + + data.count = Int(stream.total_out) + + return data + } + + + /// Create a new `Data` instance by decompressing the receiver using zlib. + /// Throws an error if decompression failed. + /// + /// - Returns: Gzip-decompressed `Data` instance. + /// - Throws: `GzipError` + public func gunzipped() throws -> Data { + + guard !self.isEmpty else { + return Data() + } + + var stream = z_stream() + var status: Int32 + + status = inflateInit2_(&stream, MAX_WBITS + 32, ZLIB_VERSION, Int32(DataSize.stream)) + + guard status == Z_OK else { + // inflateInit2 returns: + // Z_VERSION_ERROR The zlib library version is incompatible with the version assumed by the caller. + // Z_MEM_ERROR There was not enough memory. + // Z_STREAM_ERROR A parameters are invalid. + + throw GzipError(code: status, msg: stream.msg) + } + + var data = Data(capacity: self.count * 2) + repeat { + if Int(stream.total_out) >= data.count { + data.count += self.count / 2 + } + + let inputCount = self.count + let outputCount = data.count + + self.withUnsafeBytes { (inputPointer: UnsafeRawBufferPointer) in + stream.next_in = UnsafeMutablePointer(mutating: inputPointer.bindMemory(to: Bytef.self).baseAddress!).advanced(by: Int(stream.total_in)) + stream.avail_in = uint(inputCount) - uInt(stream.total_in) + + data.withUnsafeMutableBytes { (outputPointer: UnsafeMutableRawBufferPointer) in + stream.next_out = outputPointer.bindMemory(to: Bytef.self).baseAddress!.advanced(by: Int(stream.total_out)) + stream.avail_out = uInt(outputCount) - uInt(stream.total_out) + + status = inflate(&stream, Z_SYNC_FLUSH) + + stream.next_out = nil + } + + stream.next_in = nil + } + + } while status == Z_OK + + guard inflateEnd(&stream) == Z_OK, status == Z_STREAM_END else { + // inflate returns: + // Z_DATA_ERROR The input data was corrupted (input stream not conforming to the zlib format or incorrect check value). + // Z_STREAM_ERROR The stream structure was inconsistent (for example if next_in or next_out was NULL). + // Z_MEM_ERROR There was not enough memory. + // Z_BUF_ERROR No progress is possible or there was not enough room in the output buffer when Z_FINISH is used. + + throw GzipError(code: status, msg: stream.msg) + } + + data.count = Int(stream.total_out) + + return data + } + +} + + +private enum DataSize { + + static let chunk = 1 << 14 + static let stream = MemoryLayout.size +} diff --git a/ios/Plugin/HttpRequestHandler.swift b/ios/Plugin/HttpRequestHandler.swift index d3d5f26d..c84e350e 100644 --- a/ios/Plugin/HttpRequestHandler.swift +++ b/ios/Plugin/HttpRequestHandler.swift @@ -154,11 +154,12 @@ class HttpRequestHandler { guard let urlString = call.getString("url") else { throw URLError(.badURL) } guard let method = httpMethod ?? call.getString("method") else { throw URLError(.dataNotAllowed) } - let headers = (call.getObject("headers") ?? [:]) as! [String: String] + var headers = (call.getObject("headers") ?? [:]) as! [String: String] let params = (call.getObject("params") ?? [:]) as! [String: Any] let responseType = call.getString("responseType") ?? "text"; let connectTimeout = call.getDouble("connectTimeout"); let readTimeout = call.getDouble("readTimeout"); + let gzipCompression = call.getBool("gzipCompression") ?? false let request = try! CapacitorHttpRequestBuilder() .setUrl(urlString) @@ -167,6 +168,10 @@ class HttpRequestHandler { .openConnection() .build(); + if (gzipCompression) { + headers["Content-Encoding"] = "gzip" + } + request.setRequestHeaders(headers) // Timeouts in iOS are in seconds. So read the value in millis and divide by 1000 @@ -175,7 +180,7 @@ class HttpRequestHandler { if let data = call.options["data"] as? JSValue { do { - try request.setRequestBody(data) + try request.setRequestBody(data, gzipCompression) } catch { // Explicitly reject if the http request body was not set successfully, // so as to not send a known malformed request, and to provide the developer with additional context. diff --git a/package.json b/package.json index 4e76ed48..49f49aa7 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "@capacitor/android": "^3.0.0", "@capacitor/core": "^3.0.0", "@capacitor/filesystem": "^1.0.0", - "@capacitor/ios": "^3.0.0" + "@capacitor/ios": "^3.0.0", + "pako": "^2.0.4" }, "devDependencies": { "@ionic/prettier-config": "^1.0.1", @@ -34,7 +35,8 @@ "rimraf": "^3.0.2", "rollup": "^2.50.0", "typedoc": "^0.20.36", - "typescript": "^4.2.4" + "typescript": "^4.2.4", + "@types/pako": "^1.0.3" }, "husky": { "hooks": { diff --git a/src/definitions.ts b/src/definitions.ts index 649cc088..2f51b6dc 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -65,6 +65,11 @@ export interface HttpOptions { * (already encoded, azure/firebase testing, etc.). The default is _true_. */ shouldEncodeUrlParams?: boolean; + /** + * Use this option if you need a gzip compression of the data payload + * A compatible consumer interface must be ensured. The default is _false_. + */ + gzipCompression?: boolean; } export interface HttpParams { diff --git a/src/request.ts b/src/request.ts index c6400c16..babc82fc 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,3 +1,4 @@ +import * as Pako from 'pako'; import type { HttpOptions, HttpResponse, @@ -77,7 +78,15 @@ export const buildRequestInit = ( // If body is already a string, then pass it through as-is. if (typeof options.data === 'string') { - output.body = options.data; + if (options && options.gzipCompression && options.headers) { + options.headers['Content-Encoding'] = 'gzip'; + output.headers = options.headers; + + const gzippedData: Uint8Array = Pako.gzip(options.data); + output.body = gzippedData.buffer; + } else { + output.body = options.data; + } } // Build request initializers based off of content-type else if (type.includes('application/x-www-form-urlencoded')) { From 278dcb0cac2033377c9d0bee650804933f2363e9 Mon Sep 17 00:00:00 2001 From: Dante1349 Date: Thu, 20 Jan 2022 11:32:31 +0100 Subject: [PATCH 2/2] cleanup --- .../getcapacitor/plugin/http/CapacitorHttpUrlConnection.java | 3 +-- rollup.config.js | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/getcapacitor/plugin/http/CapacitorHttpUrlConnection.java b/android/src/main/java/com/getcapacitor/plugin/http/CapacitorHttpUrlConnection.java index 53997b10..59d63cb5 100644 --- a/android/src/main/java/com/getcapacitor/plugin/http/CapacitorHttpUrlConnection.java +++ b/android/src/main/java/com/getcapacitor/plugin/http/CapacitorHttpUrlConnection.java @@ -3,7 +3,6 @@ import android.os.Build; import android.os.LocaleList; import android.text.TextUtils; -import android.util.Log; import com.getcapacitor.JSArray; import com.getcapacitor.JSObject; import com.getcapacitor.PluginCall; @@ -290,7 +289,7 @@ public int getResponseCode() throws IOException { * * @return the value of this {@code URLConnection}'s {@code URL} * field. - * @see java.net.URLConnection + * @see java.net.URLConnection#url */ public URL getURL() { return connection.getURL(); diff --git a/rollup.config.js b/rollup.config.js index 75496efa..df39d788 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -7,6 +7,7 @@ export default { name: 'capacitorCommunityHttp', globals: { '@capacitor/core': 'capacitorExports', + 'pako': 'Pako', }, sourcemap: true, inlineDynamicImports: true, @@ -18,5 +19,5 @@ export default { inlineDynamicImports: true, }, ], - external: ['@capacitor/core'], + external: ['@capacitor/core', 'pako'], };