From 04021e4f8143754c6709356547c436adcd62cabd Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 27 Feb 2024 11:43:59 -0500 Subject: [PATCH 1/3] Add `Encodable` conformance to `JSONValue` --- Sources/GoogleAI/JSONValue.swift | 20 ++++++ Tests/GoogleAITests/JSONValueTests.swift | 90 +++++++++++++++++++----- 2 files changed, 93 insertions(+), 17 deletions(-) diff --git a/Sources/GoogleAI/JSONValue.swift b/Sources/GoogleAI/JSONValue.swift index b6166bb..f32fdb5 100644 --- a/Sources/GoogleAI/JSONValue.swift +++ b/Sources/GoogleAI/JSONValue.swift @@ -68,4 +68,24 @@ extension JSONValue: Decodable { } } +extension JSONValue: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case let .number(numberValue): + try container.encode(numberValue) + case let .string(stringValue): + try container.encode(stringValue) + case let .bool(boolValue): + try container.encode(boolValue) + case let .object(objectValue): + try container.encode(objectValue) + case let .array(arrayValue): + try container.encode(arrayValue) + } + } +} + extension JSONValue: Equatable {} diff --git a/Tests/GoogleAITests/JSONValueTests.swift b/Tests/GoogleAITests/JSONValueTests.swift index 14f9d96..bea4707 100644 --- a/Tests/GoogleAITests/JSONValueTests.swift +++ b/Tests/GoogleAITests/JSONValueTests.swift @@ -16,46 +16,52 @@ import XCTest @testable import GoogleGenerativeAI final class JSONValueTests: XCTestCase { + let decoder = JSONDecoder() + let encoder = JSONEncoder() + + let numberKey = "pi" + let numberValue = 3.14159 + let stringKey = "hello" + let stringValue = "Hello, world!" + + override func setUp() { + encoder.outputFormatting = .sortedKeys + } + func testDecodeNull() throws { let jsonData = try XCTUnwrap("null".data(using: .utf8)) - let jsonObject = try XCTUnwrap(JSONDecoder().decode(JSONValue.self, from: jsonData)) + let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData)) XCTAssertEqual(jsonObject, .null) } func testDecodeNumber() throws { - let expectedNumber = 3.14159 - let jsonData = try XCTUnwrap("\(expectedNumber)".data(using: .utf8)) + let jsonData = try XCTUnwrap("\(numberValue)".data(using: .utf8)) - let jsonObject = try XCTUnwrap(JSONDecoder().decode(JSONValue.self, from: jsonData)) + let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData)) - XCTAssertEqual(jsonObject, .number(expectedNumber)) + XCTAssertEqual(jsonObject, .number(numberValue)) } func testDecodeString() throws { - let expectedString = "hello-world" - let jsonData = try XCTUnwrap("\"\(expectedString)\"".data(using: .utf8)) + let jsonData = try XCTUnwrap("\"\(stringValue)\"".data(using: .utf8)) - let jsonObject = try XCTUnwrap(JSONDecoder().decode(JSONValue.self, from: jsonData)) + let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData)) - XCTAssertEqual(jsonObject, .string(expectedString)) + XCTAssertEqual(jsonObject, .string(stringValue)) } func testDecodeBool() throws { let expectedBool = true let jsonData = try XCTUnwrap("\(expectedBool)".data(using: .utf8)) - let jsonObject = try XCTUnwrap(JSONDecoder().decode(JSONValue.self, from: jsonData)) + let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData)) XCTAssertEqual(jsonObject, .bool(expectedBool)) } func testDecodeObject() throws { - let numberKey = "pi" - let numberValue = 3.14159 - let stringKey = "hello" - let stringValue = "world" let expectedObject: JSONObject = [ numberKey: .number(numberValue), stringKey: .string(stringValue), @@ -68,18 +74,68 @@ final class JSONValueTests: XCTestCase { """ let jsonData = try XCTUnwrap(json.data(using: .utf8)) - let jsonObject = try XCTUnwrap(JSONDecoder().decode(JSONValue.self, from: jsonData)) + let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData)) XCTAssertEqual(jsonObject, .object(expectedObject)) } func testDecodeArray() throws { - let numberValue = 3.14159 let expectedArray: [JSONValue] = [.null, .number(numberValue)] let jsonData = try XCTUnwrap("[ null, \(numberValue) ]".data(using: .utf8)) - let jsonObject = try XCTUnwrap(JSONDecoder().decode(JSONValue.self, from: jsonData)) + let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData)) XCTAssertEqual(jsonObject, .array(expectedArray)) } + + func testEncodeNull() throws { + let jsonData = try encoder.encode(JSONValue.null) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, "null") + } + + func testEncodeNumber() throws { + let jsonData = try encoder.encode(JSONValue.number(numberValue)) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, "\(numberValue)") + } + + func testEncodeString() throws { + let jsonData = try encoder.encode(JSONValue.string(stringValue)) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, "\"\(stringValue)\"") + } + + func testEncodeBool() throws { + let boolValue = true + + let jsonData = try encoder.encode(JSONValue.bool(boolValue)) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, "\(boolValue)") + } + + func testEncodeObject() throws { + let objectValue: JSONObject = [ + numberKey: .number(numberValue), + stringKey: .string(stringValue), + ] + + let jsonData = try encoder.encode(JSONValue.object(objectValue)) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, "{\"\(stringKey)\":\"\(stringValue)\",\"\(numberKey)\":\(numberValue)}") + } + + func testEncodeArray() throws { + let arrayValue: [JSONValue] = [.null, .number(numberValue)] + + let jsonData = try encoder.encode(JSONValue.array(arrayValue)) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, "[null,\(numberValue)]") + } } From 6ab6377e759f18ae642212fdb1821d8f8be37585 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 28 Feb 2024 12:13:35 -0500 Subject: [PATCH 2/3] Init numbers as `Decimal` before encoding --- Sources/GoogleAI/JSONValue.swift | 2 +- Tests/GoogleAITests/JSONValueTests.swift | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/GoogleAI/JSONValue.swift b/Sources/GoogleAI/JSONValue.swift index f32fdb5..a279e91 100644 --- a/Sources/GoogleAI/JSONValue.swift +++ b/Sources/GoogleAI/JSONValue.swift @@ -75,7 +75,7 @@ extension JSONValue: Encodable { case .null: try container.encodeNil() case let .number(numberValue): - try container.encode(numberValue) + try container.encode(Decimal(numberValue)) case let .string(stringValue): try container.encode(stringValue) case let .bool(boolValue): diff --git a/Tests/GoogleAITests/JSONValueTests.swift b/Tests/GoogleAITests/JSONValueTests.swift index bea4707..19c871e 100644 --- a/Tests/GoogleAITests/JSONValueTests.swift +++ b/Tests/GoogleAITests/JSONValueTests.swift @@ -21,6 +21,7 @@ final class JSONValueTests: XCTestCase { let numberKey = "pi" let numberValue = 3.14159 + let numberValueEncoded = "3.14159" let stringKey = "hello" let stringValue = "Hello, world!" @@ -127,7 +128,10 @@ final class JSONValueTests: XCTestCase { let jsonData = try encoder.encode(JSONValue.object(objectValue)) let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) - XCTAssertEqual(json, "{\"\(stringKey)\":\"\(stringValue)\",\"\(numberKey)\":\(numberValue)}") + XCTAssertEqual( + json, + "{\"\(stringKey)\":\"\(stringValue)\",\"\(numberKey)\":\(numberValueEncoded)}" + ) } func testEncodeArray() throws { @@ -136,6 +140,6 @@ final class JSONValueTests: XCTestCase { let jsonData = try encoder.encode(JSONValue.array(arrayValue)) let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) - XCTAssertEqual(json, "[null,\(numberValue)]") + XCTAssertEqual(json, "[null,\(numberValueEncoded)]") } } From c6157e62e900e2ecb63800048c1106f231b49760 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 28 Feb 2024 12:41:08 -0500 Subject: [PATCH 3/3] Add comment about converting to `Decimal` before encode --- Sources/GoogleAI/JSONValue.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/GoogleAI/JSONValue.swift b/Sources/GoogleAI/JSONValue.swift index a279e91..5ce52cd 100644 --- a/Sources/GoogleAI/JSONValue.swift +++ b/Sources/GoogleAI/JSONValue.swift @@ -75,6 +75,11 @@ extension JSONValue: Encodable { case .null: try container.encodeNil() case let .number(numberValue): + // Convert to `Decimal` before encoding for consistent floating-point serialization across + // platforms. E.g., `Double` serializes 3.14159 as 3.1415899999999999 in some cases and + // 3.14159 in others. See + // https://forums.swift.org/t/jsonencoder-encodable-floating-point-rounding-error/41390/4 for + // more details. try container.encode(Decimal(numberValue)) case let .string(stringValue): try container.encode(stringValue)