@onekeyfe/react-native-sni-connect 1.0.0 → 3.0.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -19
- package/SniConnect.podspec +2 -2
- package/android/build.gradle +0 -21
- package/android/src/main/java/com/sniconnect/SniConnectLogger.kt +55 -0
- package/android/src/main/java/com/sniconnect/SniConnectModule.kt +116 -121
- package/android/src/main/java/com/sniconnect/SniConnectValidation.kt +144 -0
- package/ios/SniConnect-Bridging-Header.h +0 -1
- package/ios/SniConnect.mm +19 -41
- package/ios/SniConnect.swift +32 -55
- package/ios/SniConnectClient.swift +83 -101
- package/ios/SniConnectLog.swift +29 -0
- package/ios/SniConnectValidation.swift +151 -0
- package/lib/module/index.js +0 -15
- package/lib/typescript/src/NativeSniConnect.d.ts +1 -14
- package/lib/typescript/src/index.d.ts +0 -6
- package/package.json +56 -66
- package/src/NativeSniConnect.ts +1 -10
- package/src/index.tsx +0 -20
- package/LICENSE +0 -20
- package/lib/module/@types/react-native-codegen.d.js.map +0 -1
- package/lib/module/NativeSniConnect.js.map +0 -1
- package/lib/module/index.js.map +0 -1
- package/lib/typescript/src/NativeSniConnect.d.ts.map +0 -1
- package/lib/typescript/src/index.d.ts.map +0 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
package com.sniconnect
|
|
2
|
+
|
|
3
|
+
import java.net.Inet6Address
|
|
4
|
+
import java.net.InetAddress
|
|
5
|
+
import java.util.Locale
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Boundary validation/normalization for SNI request inputs.
|
|
9
|
+
*
|
|
10
|
+
* The module connects to a caller-supplied IP while preserving the TLS SNI/Host of
|
|
11
|
+
* `hostname`. Because the connect target is caller-controlled, every field that
|
|
12
|
+
* reaches the network layer is validated here to prevent SSRF, scheme/host/port
|
|
13
|
+
* override, cleartext downgrade and CR/LF header injection.
|
|
14
|
+
*/
|
|
15
|
+
internal object SniConnectValidation {
|
|
16
|
+
|
|
17
|
+
class ValidationException(message: String) : IllegalArgumentException(message)
|
|
18
|
+
|
|
19
|
+
private val ALLOWED_METHODS =
|
|
20
|
+
setOf("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
|
|
21
|
+
|
|
22
|
+
private val HOSTNAME_REGEX = Regex(
|
|
23
|
+
"^(?=.{1,253}\$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\$"
|
|
24
|
+
)
|
|
25
|
+
private val IPV4_REGEX = Regex("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\$")
|
|
26
|
+
private val SCHEME_REGEX = Regex("^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
27
|
+
|
|
28
|
+
fun normalizeMethod(method: String): String {
|
|
29
|
+
val upper = method.trim().uppercase(Locale.US)
|
|
30
|
+
if (upper !in ALLOWED_METHODS) {
|
|
31
|
+
throw ValidationException("Invalid method: $method")
|
|
32
|
+
}
|
|
33
|
+
return upper
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
fun validateHostname(hostname: String) {
|
|
37
|
+
if (hostname.isEmpty() || hostname.length > 253 || !HOSTNAME_REGEX.matches(hostname)) {
|
|
38
|
+
throw ValidationException("Invalid hostname: $hostname")
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Must be a relative path/query only — reject absolute/protocol-relative URLs and control chars. */
|
|
43
|
+
fun normalizePath(path: String): String {
|
|
44
|
+
val trimmed = path.trim()
|
|
45
|
+
if (containsControlChars(trimmed)) {
|
|
46
|
+
throw ValidationException("Invalid path")
|
|
47
|
+
}
|
|
48
|
+
if (trimmed.contains("://") || trimmed.startsWith("//") || SCHEME_REGEX.containsMatchIn(trimmed.take(64).substringBefore('/'))) {
|
|
49
|
+
throw ValidationException("Invalid path: absolute URLs are not allowed")
|
|
50
|
+
}
|
|
51
|
+
if (trimmed.isEmpty()) return "/"
|
|
52
|
+
return if (trimmed.startsWith("/")) trimmed else "/$trimmed"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fun validateHeaders(headers: Map<String, String>) {
|
|
56
|
+
for ((key, value) in headers) {
|
|
57
|
+
if (key.isEmpty() || containsControlChars(key) || containsControlChars(value)) {
|
|
58
|
+
throw ValidationException("Invalid header: $key")
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private fun containsControlChars(s: String): Boolean =
|
|
64
|
+
s.any { it.code < 0x20 || it.code == 0x7F }
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validate `ip` is a literal IPv4/IPv6 address (never a hostname) routing to a
|
|
68
|
+
* public/global-unicast destination. Rejects loopback, private, link-local
|
|
69
|
+
* (incl. 169.254.169.254 metadata), CGNAT, multicast and reserved ranges.
|
|
70
|
+
*/
|
|
71
|
+
fun validatePublicIp(ip: String) {
|
|
72
|
+
val octets = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)?.map { it.toInt() }
|
|
73
|
+
if (octets != null) {
|
|
74
|
+
if (octets.any { it > 255 }) throw ValidationException("Invalid IP: $ip")
|
|
75
|
+
if (isForbiddenIpv4(octets)) throw ValidationException("Forbidden IP: $ip")
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
// IPv6: only treat as literal if it contains ':' (avoids any DNS lookup).
|
|
79
|
+
if (ip.contains(':')) {
|
|
80
|
+
val addr: InetAddress = try {
|
|
81
|
+
InetAddress.getByName(ip)
|
|
82
|
+
} catch (e: Exception) {
|
|
83
|
+
throw ValidationException("Invalid IP: $ip")
|
|
84
|
+
}
|
|
85
|
+
if (addr !is Inet6Address) throw ValidationException("Invalid IP: $ip")
|
|
86
|
+
if (isForbiddenIpv6(addr)) throw ValidationException("Forbidden IP: $ip")
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
throw ValidationException("Invalid IP: $ip")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private fun isForbiddenIpv4(o: List<Int>): Boolean {
|
|
93
|
+
val a = o[0]; val b = o[1]; val c = o[2]; val d = o[3]
|
|
94
|
+
return when {
|
|
95
|
+
a == 0 -> true // 0.0.0.0/8
|
|
96
|
+
a == 10 -> true // 10/8 private
|
|
97
|
+
a == 127 -> true // 127/8 loopback
|
|
98
|
+
a == 100 && (b and 0xC0) == 0x40 -> true // 100.64/10 CGNAT
|
|
99
|
+
a == 169 && b == 254 -> true // 169.254/16 link-local + metadata
|
|
100
|
+
a == 172 && b in 16..31 -> true // 172.16/12 private
|
|
101
|
+
a == 192 && b == 168 -> true // 192.168/16 private
|
|
102
|
+
a == 192 && b == 0 && c == 0 -> true // 192.0.0/24
|
|
103
|
+
a == 192 && b == 0 && c == 2 -> true // 192.0.2/24 TEST-NET-1
|
|
104
|
+
a == 198 && (b == 18 || b == 19) -> true // 198.18/15 benchmarking
|
|
105
|
+
a == 198 && b == 51 && c == 100 -> true // 198.51.100/24 TEST-NET-2
|
|
106
|
+
a == 203 && b == 0 && c == 113 -> true // 203.0.113/24 TEST-NET-3
|
|
107
|
+
a >= 224 -> true // 224/4 multicast + 240/4 reserved + broadcast
|
|
108
|
+
else -> false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private fun isForbiddenIpv6(addr: Inet6Address): Boolean {
|
|
113
|
+
if (addr.isAnyLocalAddress || addr.isLoopbackAddress || addr.isLinkLocalAddress ||
|
|
114
|
+
addr.isSiteLocalAddress || addr.isMulticastAddress
|
|
115
|
+
) {
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
val bytes = addr.address
|
|
119
|
+
// Unique local fc00::/7
|
|
120
|
+
if ((bytes[0].toInt() and 0xFE) == 0xFC) return true
|
|
121
|
+
// IPv4-mapped ::ffff:a.b.c.d — validate the embedded IPv4
|
|
122
|
+
val mappedPrefixZero = (0..9).all { bytes[it].toInt() == 0 }
|
|
123
|
+
if (mappedPrefixZero && (bytes[10].toInt() and 0xFF) == 0xFF && (bytes[11].toInt() and 0xFF) == 0xFF) {
|
|
124
|
+
return isForbiddenIpv4(
|
|
125
|
+
listOf(
|
|
126
|
+
bytes[12].toInt() and 0xFF,
|
|
127
|
+
bytes[13].toInt() and 0xFF,
|
|
128
|
+
bytes[14].toInt() and 0xFF,
|
|
129
|
+
bytes[15].toInt() and 0xFF,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Parse a validated IPv4/IPv6 literal into an InetAddress without DNS resolution. */
|
|
137
|
+
fun literalToInetAddress(ip: String): InetAddress {
|
|
138
|
+
val octets = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)?.map { it.toInt().toByte() }
|
|
139
|
+
if (octets != null) {
|
|
140
|
+
return InetAddress.getByAddress(byteArrayOf(octets[0], octets[1], octets[2], octets[3]))
|
|
141
|
+
}
|
|
142
|
+
return InetAddress.getByName(ip) // safe: already validated as an IPv6 literal
|
|
143
|
+
}
|
|
144
|
+
}
|
package/ios/SniConnect.mm
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#import <React/RCTBridgeModule.h>
|
|
2
|
-
#import <React/RCTEventEmitter.h>
|
|
3
2
|
#import <React/RCTUtils.h>
|
|
4
3
|
|
|
5
4
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
@@ -8,7 +7,7 @@
|
|
|
8
7
|
|
|
9
8
|
// Forward declaration of the Swift implementation
|
|
10
9
|
@interface SniConnectImpl : NSObject
|
|
11
|
-
- (instancetype)
|
|
10
|
+
- (instancetype)init;
|
|
12
11
|
- (void)request:(NSDictionary *)config
|
|
13
12
|
resolve:(RCTPromiseResolveBlock)resolve
|
|
14
13
|
reject:(RCTPromiseRejectBlock)reject;
|
|
@@ -21,7 +20,7 @@
|
|
|
21
20
|
reject:(RCTPromiseRejectBlock)reject;
|
|
22
21
|
@end
|
|
23
22
|
|
|
24
|
-
@interface SniConnect :
|
|
23
|
+
@interface SniConnect : NSObject
|
|
25
24
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
26
25
|
<NativeSniConnectSpec>
|
|
27
26
|
#else
|
|
@@ -31,63 +30,42 @@
|
|
|
31
30
|
|
|
32
31
|
@implementation SniConnect {
|
|
33
32
|
SniConnectImpl *_implementation;
|
|
34
|
-
BOOL _hasListeners;
|
|
35
33
|
}
|
|
36
34
|
|
|
37
35
|
RCT_EXPORT_MODULE(SniConnect)
|
|
38
36
|
|
|
39
|
-
// Expose hasListeners as a property for Swift access
|
|
40
|
-
- (BOOL)hasListeners {
|
|
41
|
-
return _hasListeners;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
37
|
+ (BOOL)requiresMainQueueSetup {
|
|
45
38
|
return NO;
|
|
46
39
|
}
|
|
47
40
|
|
|
48
41
|
- (instancetype)init {
|
|
49
42
|
if (self = [super init]) {
|
|
50
|
-
_implementation = [[SniConnectImpl alloc]
|
|
51
|
-
_hasListeners = NO;
|
|
43
|
+
_implementation = [[SniConnectImpl alloc] init];
|
|
52
44
|
}
|
|
53
45
|
return self;
|
|
54
46
|
}
|
|
55
47
|
|
|
56
|
-
// Event emitter methods
|
|
57
|
-
- (NSArray<NSString *> *)supportedEvents {
|
|
58
|
-
return @[@"SniConnectLog"];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
- (void)startObserving {
|
|
62
|
-
_hasListeners = YES;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
- (void)stopObserving {
|
|
66
|
-
_hasListeners = NO;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Method to send log event to JS
|
|
70
|
-
- (void)sendLogEvent:(NSDictionary *)logData {
|
|
71
|
-
if (_hasListeners) {
|
|
72
|
-
[self sendEventWithName:@"SniConnectLog" body:logData];
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
48
|
#ifdef RCT_NEW_ARCH_ENABLED
|
|
77
49
|
// TurboModule interface implementation
|
|
78
50
|
- (void)request:(JS::NativeSniConnect::SniConnectRequest &)config
|
|
79
51
|
resolve:(RCTPromiseResolveBlock)resolve
|
|
80
52
|
reject:(RCTPromiseRejectBlock)reject {
|
|
81
|
-
// Convert Codegen struct to NSDictionary for Swift implementation
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
53
|
+
// Convert Codegen struct to NSDictionary for the Swift implementation.
|
|
54
|
+
// Null-guard every field so a nil value never crashes the dictionary literal,
|
|
55
|
+
// and forward requestId so cancellation works under the new architecture.
|
|
56
|
+
NSMutableDictionary *configDict = [NSMutableDictionary dictionary];
|
|
57
|
+
configDict[@"ip"] = config.ip() ?: @"";
|
|
58
|
+
configDict[@"hostname"] = config.hostname() ?: @"";
|
|
59
|
+
configDict[@"method"] = config.method() ?: @"GET";
|
|
60
|
+
configDict[@"path"] = config.path() ?: @"/";
|
|
61
|
+
configDict[@"headers"] = config.headers() ?: @{};
|
|
62
|
+
configDict[@"timeout"] = @(config.timeout());
|
|
63
|
+
if (config.requestId()) {
|
|
64
|
+
configDict[@"requestId"] = config.requestId();
|
|
65
|
+
}
|
|
66
|
+
if (config.body()) {
|
|
67
|
+
configDict[@"body"] = config.body();
|
|
68
|
+
}
|
|
91
69
|
|
|
92
70
|
[_implementation request:configDict resolve:resolve reject:reject];
|
|
93
71
|
}
|
package/ios/SniConnect.swift
CHANGED
|
@@ -8,52 +8,25 @@ private enum SniConnectError: Error {
|
|
|
8
8
|
@objc(SniConnectImpl)
|
|
9
9
|
final class SniConnectImpl: NSObject {
|
|
10
10
|
private let client: SniConnectClient
|
|
11
|
-
private weak var eventSender: AnyObject?
|
|
12
11
|
|
|
13
12
|
@objc
|
|
14
|
-
init(
|
|
15
|
-
self.eventSender = eventSender
|
|
13
|
+
override init() {
|
|
16
14
|
self.client = SniConnectClient()
|
|
17
15
|
super.init()
|
|
18
|
-
|
|
19
|
-
// Set up logging closure for the client
|
|
20
|
-
self.client.onLog = { [weak self] level, message in
|
|
21
|
-
self?.sendLogEvent(level: level, message: message)
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
private func sendLogEvent(level: String, message: String) {
|
|
26
|
-
// Send log event to the Objective-C bridge
|
|
27
|
-
guard let eventSender = eventSender else {
|
|
28
|
-
return
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
let logData: [String: Any] = [
|
|
32
|
-
"level": level,
|
|
33
|
-
"message": message,
|
|
34
|
-
"timestamp": Int(Date().timeIntervalSince1970 * 1000)
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
// Call the sendLogEvent method on the bridge
|
|
38
|
-
let selector = NSSelectorFromString("sendLogEvent:")
|
|
39
|
-
if eventSender.responds(to: selector) {
|
|
40
|
-
eventSender.perform(selector, with: logData)
|
|
41
|
-
}
|
|
42
16
|
}
|
|
43
17
|
|
|
44
18
|
@objc
|
|
45
19
|
public func request(
|
|
46
20
|
_ config: NSDictionary,
|
|
47
|
-
resolve
|
|
48
|
-
reject
|
|
21
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
22
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
49
23
|
) {
|
|
50
24
|
do {
|
|
51
25
|
let parsed = try Self.parseDictionary(config)
|
|
52
26
|
handleRequest(config: parsed, resolve: resolve, reject: reject)
|
|
53
27
|
} catch {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
reject("SNI_REQUEST_FAILED", error.localizedDescription, error)
|
|
28
|
+
SniConnectLog.error("Config parsing failed: \(error)")
|
|
29
|
+
reject("SNI_INVALID_CONFIG", "\(error)", error)
|
|
57
30
|
}
|
|
58
31
|
}
|
|
59
32
|
|
|
@@ -62,12 +35,20 @@ final class SniConnectImpl: NSObject {
|
|
|
62
35
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
63
36
|
reject: @escaping RCTPromiseRejectBlock
|
|
64
37
|
) {
|
|
65
|
-
|
|
38
|
+
do {
|
|
39
|
+
try Self.validate(config)
|
|
40
|
+
} catch {
|
|
41
|
+
SniConnectLog.error("Config validation failed: \(error)")
|
|
42
|
+
reject("SNI_INVALID_CONFIG", "\(error)", error)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Create the task and register it synchronously before JS can cancel it.
|
|
66
47
|
let task = Task { () -> SniConnectClient.Response in
|
|
67
48
|
return try await client.performRequest(config: config)
|
|
68
49
|
}
|
|
69
50
|
|
|
70
|
-
// Register task
|
|
51
|
+
// Register task if requestId is provided
|
|
71
52
|
if let requestId = config.requestId {
|
|
72
53
|
client.registerTask(task, for: requestId)
|
|
73
54
|
}
|
|
@@ -86,16 +67,11 @@ final class SniConnectImpl: NSObject {
|
|
|
86
67
|
"multiValueHeaders": result.multiValueHeaders,
|
|
87
68
|
])
|
|
88
69
|
} catch let error as SniConnectClient.SniConnectError {
|
|
89
|
-
NSLog("[SniConnect] ❌ [\(error.code)] \(error.message)")
|
|
90
|
-
sendLogEvent(level: "error", message: "[\(error.code)] \(error.message)")
|
|
91
70
|
reject(error.code, error.message, error)
|
|
92
71
|
} catch is CancellationError {
|
|
93
|
-
NSLog("[SniConnect] ❌ Request cancelled")
|
|
94
|
-
sendLogEvent(level: "info", message: "Request cancelled")
|
|
95
72
|
reject("SNI_CANCELLED", "Request cancelled", nil)
|
|
96
73
|
} catch {
|
|
97
|
-
|
|
98
|
-
sendLogEvent(level: "error", message: "Request failed: \(error.localizedDescription)")
|
|
74
|
+
SniConnectLog.error("Request failed: \(error.localizedDescription)")
|
|
99
75
|
reject("SNI_UNKNOWN_ERROR", error.localizedDescription, error)
|
|
100
76
|
}
|
|
101
77
|
}
|
|
@@ -104,8 +80,8 @@ final class SniConnectImpl: NSObject {
|
|
|
104
80
|
@objc
|
|
105
81
|
public func cancelRequest(
|
|
106
82
|
_ requestId: String,
|
|
107
|
-
resolve
|
|
108
|
-
reject
|
|
83
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
84
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
109
85
|
) {
|
|
110
86
|
client.cancelRequest(requestId: requestId)
|
|
111
87
|
resolve(["success": true])
|
|
@@ -114,7 +90,7 @@ final class SniConnectImpl: NSObject {
|
|
|
114
90
|
@objc
|
|
115
91
|
public func cancelAllRequests(
|
|
116
92
|
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
117
|
-
reject
|
|
93
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
118
94
|
) {
|
|
119
95
|
client.cancelAllRequests()
|
|
120
96
|
resolve(["success": true])
|
|
@@ -123,7 +99,7 @@ final class SniConnectImpl: NSObject {
|
|
|
123
99
|
@objc
|
|
124
100
|
public func clearDNSCache(
|
|
125
101
|
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
126
|
-
reject
|
|
102
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
127
103
|
) {
|
|
128
104
|
client.clearDNSCache()
|
|
129
105
|
resolve(["success": true])
|
|
@@ -162,24 +138,25 @@ final class SniConnectImpl: NSObject {
|
|
|
162
138
|
)
|
|
163
139
|
}
|
|
164
140
|
|
|
141
|
+
private static func validate(_ config: SniConnectClient.RequestConfig) throws {
|
|
142
|
+
try SniConnectValidation.validatePublicIP(config.ip)
|
|
143
|
+
try SniConnectValidation.validateHostname(config.hostname)
|
|
144
|
+
try SniConnectValidation.validateHeaders(config.headers)
|
|
145
|
+
_ = try SniConnectValidation.normalizeMethod(config.method)
|
|
146
|
+
_ = try SniConnectValidation.normalizePath(config.path)
|
|
147
|
+
}
|
|
148
|
+
|
|
165
149
|
private static func serializeResponseData(_ data: Any) -> String {
|
|
166
|
-
if let
|
|
167
|
-
|
|
168
|
-
let jsonString = String(data: jsonData, encoding: .utf8) {
|
|
169
|
-
return jsonString
|
|
150
|
+
if let stringValue = data as? String {
|
|
151
|
+
return stringValue
|
|
170
152
|
}
|
|
171
153
|
|
|
172
|
-
if
|
|
173
|
-
let jsonData = try? JSONSerialization.data(withJSONObject:
|
|
154
|
+
if JSONSerialization.isValidJSONObject(data),
|
|
155
|
+
let jsonData = try? JSONSerialization.data(withJSONObject: data, options: []),
|
|
174
156
|
let jsonString = String(data: jsonData, encoding: .utf8) {
|
|
175
157
|
return jsonString
|
|
176
158
|
}
|
|
177
159
|
|
|
178
|
-
if let stringValue = data as? String {
|
|
179
|
-
return stringValue
|
|
180
|
-
}
|
|
181
|
-
|
|
182
160
|
return String(describing: data)
|
|
183
161
|
}
|
|
184
162
|
}
|
|
185
|
-
|