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..59d63cb5 100644 --- a/android/src/main/java/com/getcapacitor/plugin/http/CapacitorHttpUrlConnection.java +++ b/android/src/main/java/com/getcapacitor/plugin/http/CapacitorHttpUrlConnection.java @@ -6,6 +6,7 @@ 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 +21,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 +172,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 +189,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 +204,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 +218,7 @@ public void setRequestBody(PluginCall call, JSValue body) throws JSONException, } uploader.finish(); } else { - this.writeRequestBody(body.toString()); + this.writeRequestBody(body.toString(), gzipCompression); } } @@ -224,10 +227,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(); + } } } 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/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'], }; 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')) {