diff --git a/README.md b/README.md index 0c172ca..8e0d821 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,11 @@ Available writing options: - `.escapeUnicode` — Escape non-ASCII characters as `\uXXXX` - `.newlineAtEnd` — Add trailing newline `\n` +Non-standard options (unavailable when `strictStandardJSON` trait is enabled): + +- `.allowInfAndNaN` — Write `Infinity` and `NaN` literals +- `.infAndNaNAsNull` — Write `Infinity` and `NaN` as `null` (takes precedence) + ## Read and Write Options ### Reading Options @@ -375,7 +380,7 @@ Available options: Non-standard options (unavailable when `strictStandardJSON` trait is enabled): - `.allowInfAndNaN` — Write `Infinity` and `NaN` literals -- `.infAndNaNAsNull` — Write `Infinity` and `NaN` as `null` +- `.infAndNaNAsNull` — Write `Infinity` and `NaN` as `null` (takes precedence) ## Package Traits diff --git a/Sources/YYJSON/Configuration.swift b/Sources/YYJSON/Configuration.swift index c7ec75b..108ba37 100644 --- a/Sources/YYJSON/Configuration.swift +++ b/Sources/YYJSON/Configuration.swift @@ -93,10 +93,14 @@ public struct YYJSONWriteOptions: OptionSet, Sendable { #if !YYJSON_DISABLE_NON_STANDARD - /// Write inf and nan number as 'Infinity' and 'NaN' literal. + /// Writes infinity and NaN values as `Infinity` and `NaN` literals. + /// + /// If you set `infAndNaNAsNull`, it takes precedence. public static let allowInfAndNaN = YYJSONWriteOptions(rawValue: YYJSON_WRITE_ALLOW_INF_AND_NAN) - /// Write inf and nan number as null literal. + /// Writes infinity and NaN values as `null` literals. + /// + /// This option takes precedence over `allowInfAndNaN`. public static let infAndNaNAsNull = YYJSONWriteOptions(rawValue: YYJSON_WRITE_INF_AND_NAN_AS_NULL) #endif // !YYJSON_DISABLE_NON_STANDARD diff --git a/Sources/YYJSON/Encoder.swift b/Sources/YYJSON/Encoder.swift index 7ac2a0d..8640947 100644 --- a/Sources/YYJSON/Encoder.swift +++ b/Sources/YYJSON/Encoder.swift @@ -58,7 +58,8 @@ import Foundation codingPath: [], userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: writeOptions ) try value.encode(to: encoder) @@ -135,6 +136,7 @@ import Foundation let userInfo: [CodingUserInfoKey: Any] let dateEncodingStrategy: DateEncodingStrategy let dataEncodingStrategy: DataEncodingStrategy + let writeOptions: YYJSONWriteOptions init( doc: UnsafeMutablePointer, @@ -142,7 +144,8 @@ import Foundation codingPath: [CodingKey] = [], userInfo: [CodingUserInfoKey: Any] = [:], dateEncodingStrategy: DateEncodingStrategy = .deferredToDate, - dataEncodingStrategy: DataEncodingStrategy = .base64 + dataEncodingStrategy: DataEncodingStrategy = .base64, + writeOptions: YYJSONWriteOptions = .default ) { self.doc = doc self.value = value @@ -150,6 +153,7 @@ import Foundation self.userInfo = userInfo self.dateEncodingStrategy = dateEncodingStrategy self.dataEncodingStrategy = dataEncodingStrategy + self.writeOptions = writeOptions } func container(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key: CodingKey { @@ -188,6 +192,15 @@ import Foundation dataEncodingStrategy: dataEncodingStrategy ) } + + func numberValue(_ value: Double) -> UnsafeMutablePointer { + #if !YYJSON_DISABLE_NON_STANDARD + if !value.isFinite && writeOptions.contains(.infAndNaNAsNull) { + return yyjson_mut_null(doc) + } + #endif + return yyjson_mut_real(doc, value) + } } // MARK: - Encoding Containers @@ -248,7 +261,7 @@ import Foundation mutating func encode(_ value: Double, forKey key: Key) throws { guard let obj = self.value else { return } - let numVal = yyjson_mut_real(doc, value) + let numVal = encoder.numberValue(value) let keyVal = yyFromString(key.stringValue, in: doc) _ = yyjson_mut_obj_put(obj, keyVal, numVal) } @@ -332,7 +345,8 @@ import Foundation codingPath: codingPath + [key], userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) let encodedValue = try encodeValue(value, using: encoder) let keyVal = yyFromString(key.stringValue, in: doc) @@ -349,7 +363,8 @@ import Foundation codingPath: codingPath + [key], userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) return encoder.container(keyedBy: type) } @@ -362,7 +377,8 @@ import Foundation codingPath: codingPath + [key], userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) return encoder.container(keyedBy: type) } @@ -375,7 +391,8 @@ import Foundation codingPath: codingPath + [key], userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) return nestedEncoder.unkeyedContainer() } @@ -388,7 +405,8 @@ import Foundation codingPath: codingPath + [key], userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) return _YYUnkeyedEncodingContainer( doc: doc, @@ -408,7 +426,8 @@ import Foundation codingPath: codingPath, userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) } @@ -420,7 +439,8 @@ import Foundation codingPath: codingPath + [key], userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) } let nestedObj = yyjson_mut_obj(doc) @@ -432,7 +452,8 @@ import Foundation codingPath: codingPath + [key], userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) } @@ -447,7 +468,8 @@ import Foundation codingPath: codingPath, userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) try date.encode(to: encoder) guard let val = encoder.value else { @@ -481,7 +503,8 @@ import Foundation codingPath: codingPath, userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) try closure(date, encoder) guard let val = encoder.value else { @@ -509,7 +532,8 @@ import Foundation codingPath: codingPath, userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) try data.encode(to: encoder) guard let val = encoder.value else { @@ -527,7 +551,8 @@ import Foundation codingPath: codingPath, userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) try closure(data, encoder) guard let val = encoder.value else { @@ -612,7 +637,7 @@ import Foundation mutating func encode(_ value: Double) throws { guard let arr = self.value else { return } - let numVal = yyjson_mut_real(doc, value) + let numVal = encoder.numberValue(value) _ = yyjson_mut_arr_append(arr, numVal) } @@ -689,7 +714,8 @@ import Foundation codingPath: codingPath + [AnyCodingKey(index: count)], userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) let encodedValue = try encodeValue(value, using: encoder) _ = yyjson_mut_arr_append(arr, encodedValue) @@ -708,7 +734,8 @@ import Foundation codingPath: codingPath + [AnyCodingKey(index: count)], userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) return encoder.container(keyedBy: type) } @@ -724,7 +751,8 @@ import Foundation codingPath: codingPath + [AnyCodingKey(index: count)], userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) return _YYUnkeyedEncodingContainer( doc: doc, @@ -744,7 +772,8 @@ import Foundation codingPath: codingPath, userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) } @@ -759,7 +788,8 @@ import Foundation codingPath: codingPath, userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) try date.encode(to: encoder) guard let val = encoder.value else { @@ -793,7 +823,8 @@ import Foundation codingPath: codingPath, userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) try closure(date, encoder) guard let val = encoder.value else { @@ -821,7 +852,8 @@ import Foundation codingPath: codingPath, userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) try data.encode(to: encoder) guard let val = encoder.value else { @@ -839,7 +871,8 @@ import Foundation codingPath: codingPath, userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) try closure(data, encoder) guard let val = encoder.value else { @@ -910,7 +943,7 @@ import Foundation } mutating func encode(_ value: Double) throws { - self.value = yyjson_mut_real(doc, value) + self.value = encoder.numberValue(value) encoder.value = self.value } @@ -981,7 +1014,8 @@ import Foundation codingPath: codingPath, userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) try value.encode(to: nestedEncoder) self.value = nestedEncoder.value @@ -997,7 +1031,8 @@ import Foundation codingPath: codingPath, userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) try date.encode(to: nestedEncoder) guard let val = nestedEncoder.value else { @@ -1031,7 +1066,8 @@ import Foundation codingPath: codingPath, userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) try closure(date, nestedEncoder) guard let val = nestedEncoder.value else { @@ -1057,7 +1093,8 @@ import Foundation codingPath: codingPath, userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) try data.encode(to: nestedEncoder) guard let val = nestedEncoder.value else { @@ -1075,7 +1112,8 @@ import Foundation codingPath: codingPath, userInfo: userInfo, dateEncodingStrategy: dateEncodingStrategy, - dataEncodingStrategy: dataEncodingStrategy + dataEncodingStrategy: dataEncodingStrategy, + writeOptions: encoder.writeOptions ) try closure(data, nestedEncoder) guard let val = nestedEncoder.value else { diff --git a/Sources/YYJSON/Serialization.swift b/Sources/YYJSON/Serialization.swift index 62052c9..1e73bc7 100644 --- a/Sources/YYJSON/Serialization.swift +++ b/Sources/YYJSON/Serialization.swift @@ -64,6 +64,20 @@ public enum YYJSONSerialization { /// Add a single newline character `\n` at the end of the JSON. public static let newlineAtEnd = WritingOptions(rawValue: 1 << 6) + + #if !YYJSON_DISABLE_NON_STANDARD + + /// Writes infinity and NaN values as `Infinity` and `NaN` literals. + /// + /// If you set `infAndNaNAsNull`, it takes precedence. + public static let allowInfAndNaN = WritingOptions(rawValue: 1 << 7) + + /// Writes infinity and NaN values as `null` literals. + /// + /// This option takes precedence over `allowInfAndNaN`. + public static let infAndNaNAsNull = WritingOptions(rawValue: 1 << 8) + + #endif // !YYJSON_DISABLE_NON_STANDARD } /// Returns a Foundation object from given JSON data. @@ -127,8 +141,17 @@ public enum YYJSONSerialization { let isTopLevelContainer = obj is NSArray || obj is NSDictionary let isFragment = obj is NSString || obj is NSNumber || obj is NSNull + let allowNonFiniteNumbers: Bool + #if !YYJSON_DISABLE_NON_STANDARD + allowNonFiniteNumbers = + options.contains(.infAndNaNAsNull) + || options.contains(.allowInfAndNaN) + #else + allowNonFiniteNumbers = false + #endif + if isTopLevelContainer { - guard isValidJSONObject(obj) else { + guard isValidJSONObject(obj, allowNonFiniteNumbers: allowNonFiniteNumbers) else { throw YYJSONError.invalidData("Invalid JSON object") } } else if isFragment { @@ -166,6 +189,15 @@ public enum YYJSONSerialization { flags |= YYJSON_WRITE_ESCAPE_UNICODE } + #if !YYJSON_DISABLE_NON_STANDARD + if options.contains(.allowInfAndNaN) { + flags |= YYJSON_WRITE_ALLOW_INF_AND_NAN + } + if options.contains(.infAndNaNAsNull) { + flags |= YYJSON_WRITE_INF_AND_NAN_AS_NULL + } + #endif + // Formatting options if options.contains(.newlineAtEnd) { flags |= YYJSON_WRITE_NEWLINE_AT_END @@ -197,12 +229,22 @@ public enum YYJSONSerialization { guard obj is NSArray || obj is NSDictionary else { return false } - return isValidJSONObjectRecursive(obj) + return isValidJSONObjectRecursive(obj, allowNonFiniteNumbers: false) } // MARK: - Private Helpers - private static func isValidJSONObjectRecursive(_ obj: Any) -> Bool { + private static func isValidJSONObject(_ obj: Any, allowNonFiniteNumbers: Bool) -> Bool { + guard obj is NSArray || obj is NSDictionary else { + return false + } + return isValidJSONObjectRecursive(obj, allowNonFiniteNumbers: allowNonFiniteNumbers) + } + + private static func isValidJSONObjectRecursive( + _ obj: Any, + allowNonFiniteNumbers: Bool + ) -> Bool { switch obj { case let dict as NSDictionary: for (key, value) in dict { @@ -211,11 +253,11 @@ public enum YYJSONSerialization { } if let number = value as? NSNumber { let doubleValue = number.doubleValue - if doubleValue.isNaN || doubleValue.isInfinite { + if !allowNonFiniteNumbers && (doubleValue.isNaN || doubleValue.isInfinite) { return false } } - if !isValidJSONObjectRecursive(value) { + if !isValidJSONObjectRecursive(value, allowNonFiniteNumbers: allowNonFiniteNumbers) { return false } } @@ -225,11 +267,11 @@ public enum YYJSONSerialization { for element in arr { if let number = element as? NSNumber { let doubleValue = number.doubleValue - if doubleValue.isNaN || doubleValue.isInfinite { + if !allowNonFiniteNumbers && (doubleValue.isNaN || doubleValue.isInfinite) { return false } } - if !isValidJSONObjectRecursive(element) { + if !isValidJSONObjectRecursive(element, allowNonFiniteNumbers: allowNonFiniteNumbers) { return false } } @@ -278,6 +320,14 @@ public enum YYJSONSerialization { if options.contains(.newlineAtEnd) { writeOptions.insert(.newlineAtEnd) } + #if !YYJSON_DISABLE_NON_STANDARD + if options.contains(.allowInfAndNaN) { + writeOptions.insert(.allowInfAndNaN) + } + if options.contains(.infAndNaNAsNull) { + writeOptions.insert(.infAndNaNAsNull) + } + #endif return try value.data(options: writeOptions) } @@ -297,6 +347,14 @@ public enum YYJSONSerialization { case let num as NSNumber: let doubleValue = num.doubleValue if doubleValue.isNaN || doubleValue.isInfinite { + #if !YYJSON_DISABLE_NON_STANDARD + if options.contains(.infAndNaNAsNull) { + return yyjson_mut_null(doc) + } + if options.contains(.allowInfAndNaN) { + return yyjson_mut_real(doc, doubleValue) + } + #endif throw YYJSONError.invalidData("NaN or Infinity not allowed in JSON") } diff --git a/Tests/YYJSONTests/ConfigurationTests.swift b/Tests/YYJSONTests/ConfigurationTests.swift index 4c97f42..0d50513 100644 --- a/Tests/YYJSONTests/ConfigurationTests.swift +++ b/Tests/YYJSONTests/ConfigurationTests.swift @@ -220,6 +220,33 @@ import Testing #expect(json == "null") } + @Test func allowInfAndNaNNaNOption() throws { + var encoder = YYJSONEncoder() + encoder.writeOptions = .allowInfAndNaN + + let data = try encoder.encode(Double.nan) + let json = String(data: data, encoding: .utf8)! + #expect(json.contains("NaN") || json.contains("nan")) + } + + @Test func infAndNaNAsNullNaNOption() throws { + var encoder = YYJSONEncoder() + encoder.writeOptions = .infAndNaNAsNull + + let data = try encoder.encode(Double.nan) + let json = String(data: data, encoding: .utf8)! + #expect(json == "null") + } + + @Test func infAndNaNAsNullOverridesAllowInfAndNaN() throws { + var encoder = YYJSONEncoder() + encoder.writeOptions = [.allowInfAndNaN, .infAndNaNAsNull] + + let data = try encoder.encode(Double.infinity) + let json = String(data: data, encoding: .utf8)! + #expect(json == "null") + } + #endif // !YYJSON_DISABLE_NON_STANDARD @Test func newlineAtEndOption() throws { diff --git a/Tests/YYJSONTests/SerializationTests.swift b/Tests/YYJSONTests/SerializationTests.swift index dbeb8dd..db015c0 100644 --- a/Tests/YYJSONTests/SerializationTests.swift +++ b/Tests/YYJSONTests/SerializationTests.swift @@ -168,6 +168,59 @@ import Testing #expect(json == "[1,2,3,4,5]") } + #if !YYJSON_DISABLE_NON_STANDARD + + @Test func writeAllowsInfAndNaNLiterals() throws { + let dict: NSDictionary = [ + "inf": Double.infinity, + "nan": Double.nan, + ] + let data = try YYJSONSerialization.data( + withJSONObject: dict, + options: .allowInfAndNaN + ) + let json = String(data: data, encoding: .utf8)! + #expect(json.contains("Infinity") || json.contains("inf")) + #expect(json.contains("NaN") || json.contains("nan")) + } + + @Test func writeInfAndNaNAsNull() throws { + let dict: NSDictionary = [ + "inf": Double.infinity, + "nan": Double.nan, + ] + let data = try YYJSONSerialization.data( + withJSONObject: dict, + options: .infAndNaNAsNull + ) + let json = String(data: data, encoding: .utf8)! + #expect(json.contains("\"inf\":null")) + #expect(json.contains("\"nan\":null")) + } + + @Test func writeInfAndNaNAsNullOverridesAllowInfAndNaN() throws { + let dict: NSDictionary = [ + "inf": Double.infinity, + "nan": Double.nan, + ] + let data = try YYJSONSerialization.data( + withJSONObject: dict, + options: [.allowInfAndNaN, .infAndNaNAsNull] + ) + let json = String(data: data, encoding: .utf8)! + #expect(json.contains("\"inf\":null")) + #expect(json.contains("\"nan\":null")) + } + + @Test func writeNonFiniteWithoutOptionThrows() throws { + let dict: NSDictionary = ["value": Double.nan] + #expect(throws: YYJSONError.self) { + _ = try YYJSONSerialization.data(withJSONObject: dict) + } + } + + #endif // !YYJSON_DISABLE_NON_STANDARD + #if !YYJSON_DISABLE_READER private static func jsonString(