From 5eedbafdaacc66845b66e7fbb5852133b22ab505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Stormacq?= Date: Mon, 29 Sep 2025 22:32:31 +0200 Subject: [PATCH 1/4] refactor JSON --- Sources/Converse/ContentBlocks/JSON.swift | 166 ++++++++++++------ Tests/Converse/JSONTests.swift | 205 ++++++++++++---------- 2 files changed, 225 insertions(+), 146 deletions(-) diff --git a/Sources/Converse/ContentBlocks/JSON.swift b/Sources/Converse/ContentBlocks/JSON.swift index 5d17cc8..2deca30 100644 --- a/Sources/Converse/ContentBlocks/JSON.swift +++ b/Sources/Converse/ContentBlocks/JSON.swift @@ -19,57 +19,130 @@ import FoundationEssentials import Foundation #endif -public struct JSON: Codable, @unchecked Sendable { // FIXME: make Sendable - public var value: Any? +public enum JSONValue: Codable, Sendable { + case null + case int(Int) + case double(Double) + case string(String) + case bool(Bool) + case array([JSONValue]) + case object([String: JSONValue]) + + public init(_ value: Any?) { + switch value { + case nil: + self = .null + case let v as Int: + self = .int(v) + case let v as Double: + self = .double(v) + case let v as String: + self = .string(v) + case let v as Bool: + self = .bool(v) + case let v as [Any]: + self = .array(v.map { JSONValue($0) }) + case let v as [String: Any]: + self = .object(v.mapValues { JSONValue($0) }) + case let v as [String: JSON]: + self = .object(v.mapValues { $0.value }) + case let v as [JSON]: + self = .array(v.map { $0.value }) + case let v as JSONValue: + self = v + case let v as JSON: + self = v.value + default: + fatalError("JSONValue: Unsupported type: \(type(of: value))") + } + } +} + +public struct JSON: Codable, Sendable { + public var value: JSONValue - /// Returns the value inside the JSON object defined by the given key. public subscript(key: String) -> T? { get { - if let dictionary = value as? [String: JSON] { - let json: JSON? = dictionary[key] - let nestedValue: Any? = json?.getValue() - return nestedValue as? T + if case let .object(dictionary) = value { + let jsonValue = dictionary[key] + switch jsonValue { + case .int(let v): return v as? T + case .double(let v): return v as? T + case .string(let v): return v as? T + case .bool(let v): return v as? T + case .array(let v): return v as? T + case .object(let v): return v as? T + case .null: return nil + case .none: return nil + } } return nil } } - /// Returns the JSON object defined by the given key. public subscript(key: String) -> JSON? { get { - if let dictionary = value as? [String: JSON] { - return dictionary[key] + if case let .object(dictionary) = value { + if let v = dictionary[key] { + return JSON(with: v) + } } return nil } } + +// public subscript(key: String) -> JSONValue? { +// get { +// if case let .object(dictionary) = value { +// if let v = dictionary[key] { +// return v +// } +// } +// return nil +// } +// } - /// Returns the value inside the JSON object defined by the given key. public func getValue(_ key: String) -> T? { - if let dictionary = value as? [String: JSON] { - return dictionary[key]?.value as? T + if case let .object(dictionary) = value { + if let v = dictionary[key] { + switch v { + case .int(let val): return val as? T + case .double(let val): return val as? T + case .string(let val): return val as? T + case .bool(let val): return val as? T + case .array(let val): return val as? T + case .object(let val): return val as? T + case .null: return nil + } + } } return nil } - /// Returns the value inside the JSON object. public func getValue() -> T? { - value as? T + switch value { + case .int(let v): return v as? T + case .double(let v): return v as? T + case .string(let v): return v as? T + case .bool(let v): return v as? T + case .array(let v): return v as? T + case .object(let v): return v as? T + case .null: return nil + } } // MARK: Initializers public init(with value: Any?) { + self.value = JSONValue(value) + } + + public init(with value: JSONValue) { self.value = value } public init(from string: String) throws { - var s: String! - if string.isEmpty { - s = "{}" - } else { - s = string - } + let s = string.isEmpty ? "{}" : string guard let data = s.data(using: .utf8) else { throw BedrockLibraryError.encodingError("Could not encode String to Data") } @@ -87,19 +160,19 @@ public struct JSON: Codable, @unchecked Sendable { // FIXME: make Sendable public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { - self.value = nil + self.value = .null } else if let intValue = try? container.decode(Int.self) { - self.value = intValue + self.value = .int(intValue) } else if let doubleValue = try? container.decode(Double.self) { - self.value = doubleValue + self.value = .double(doubleValue) } else if let stringValue = try? container.decode(String.self) { - self.value = stringValue + self.value = .string(stringValue) } else if let boolValue = try? container.decode(Bool.self) { - self.value = boolValue + self.value = .bool(boolValue) } else if let arrayValue = try? container.decode([JSON].self) { - self.value = arrayValue.map { JSON(with: $0.value) } + self.value = .array(arrayValue.map { $0.value }) } else if let dictionaryValue = try? container.decode([String: JSON].self) { - self.value = dictionaryValue.mapValues { JSON(with: $0.value) } + self.value = .object(dictionaryValue.mapValues { $0.value }) } else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type") } @@ -109,28 +182,21 @@ public struct JSON: Codable, @unchecked Sendable { // FIXME: make Sendable public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() - if let jsonValue = value as? JSON { - try jsonValue.encode(to: encoder) - } else if let intValue = value as? Int { - try container.encode(intValue) - } else if let doubleValue = value as? Double { - try container.encode(doubleValue) - } else if let stringValue = value as? String { - try container.encode(stringValue) - } else if let boolValue = value as? Bool { - try container.encode(boolValue) - } else if let arrayValue = value as? [Any] { - let jsonArray = arrayValue.map { JSON(with: $0) } - try container.encode(jsonArray) - } else if let dictionaryValue = value as? [String: Any] { - let jsonDictionary = dictionaryValue.mapValues { JSON(with: $0) } - try container.encode(jsonDictionary) - } else { - // try container.encode(String(describing: value ?? "nil")) - throw EncodingError.invalidValue( - value ?? "nil", - EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type") - ) + switch value { + case .null: + try container.encodeNil() + case .int(let v): + try container.encode(v) + case .double(let v): + try container.encode(v) + case .string(let v): + try container.encode(v) + case .bool(let v): + try container.encode(v) + case .array(let v): + try container.encode(v.map { JSON(with: $0) }) + case .object(let v): + try container.encode(v.mapValues { JSON(with: $0) }) } } } diff --git a/Tests/Converse/JSONTests.swift b/Tests/Converse/JSONTests.swift index 409108a..7515f03 100644 --- a/Tests/Converse/JSONTests.swift +++ b/Tests/Converse/JSONTests.swift @@ -21,8 +21,8 @@ import Testing @Suite("JSONTests") struct JSONTests { - @Test("JSON getValue") - func jsonGetValue() async throws { + @Test("JSON getValue 1") + func jsonGetValue1() async throws { let json = JSON(with: [ "name": JSON(with: "Jane Doe"), "age": JSON(with: 30), @@ -33,106 +33,119 @@ struct JSONTests { #expect(json.getValue("isMember") == true) #expect(json.getValue("nonExistentKey") == nil) } - - @Test("JSON getValue nested") - func jsonGetValueNested() async throws { + + @Test("JSON getValue 2") + func jsonGetValue2() async throws { let json = JSON(with: [ - "name": JSON(with: "Jane Doe"), - "age": JSON(with: 30), - "isMember": JSON(with: true), - "address": JSON(with: [ - "street": JSON(with: "123 Main St"), - "city": JSON(with: "Anytown"), - "state": JSON(with: "CA"), - "zip": JSON(with: "12345"), - "isSomething": JSON(with: true), - ]), + "name": JSONValue("Jane Doe"), + "age": JSONValue(30), + "isMember": JSONValue(true), ]) #expect(json.getValue("name") == "Jane Doe") #expect(json.getValue("age") == 30) #expect(json.getValue("isMember") == true) #expect(json.getValue("nonExistentKey") == nil) - #expect(json["address"]?.getValue("street") == "123 Main St") - #expect(json["address"]?.getValue("city") == "Anytown") - #expect(json["address"]?.getValue("state") == "CA") - #expect(json["address"]?.getValue("zip") == "12345") - #expect(json["address"]?.getValue("isSomething") == true) - #expect(json["address"]?.getValue("nonExistentKey") == nil) } - @Test("JSON Subscript") - func jsonSubscript() async throws { - let json = JSON(with: [ - "name": JSON(with: "Jane Doe"), - "age": JSON(with: 30), - "isMember": JSON(with: true), - ]) - #expect(json["name"] == "Jane Doe") - #expect(json["age"] == 30) - #expect(json["isMember"] == true) - #expect(json["nonExistentKey"] == nil) - } - - @Test("JSON Subscript nested") - func jsonSubscriptNested() async throws { - let json = JSON(with: [ - "name": JSON(with: "Jane Doe"), - "age": JSON(with: 30), - "isMember": JSON(with: true), - "address": JSON(with: [ - "street": JSON(with: "123 Main St"), - "city": JSON(with: "Anytown"), - "state": JSON(with: "CA"), - "zip": JSON(with: 12345), - "isSomething": JSON(with: true), - ]), - ]) - #expect(json["name"] == "Jane Doe") - #expect(json["age"] == 30) - #expect(json["isMember"] == true) - #expect(json["nonExistentKey"] == nil) - #expect(json["address"]?["street"] == "123 Main St") - #expect(json["address"]?["city"] == "Anytown") - #expect(json["address"]?["state"] == "CA") - #expect(json["address"]?["zip"] == 12345) - #expect(json["address"]?["isSomething"] == true) - #expect(json["address"]?.getValue("nonExistentKey") == nil) - } - - @Test("JSON String Initializer with Valid String") - func jsonStringInitializer() async throws { - let validJSONString = """ - { - "name": "Jane Doe", - "age": 30, - "isMember": true - } - """ - - let json = try JSON(from: validJSONString) - #expect(json.getValue("name") == "Jane Doe") - #expect(json.getValue("age") == 30) - #expect(json.getValue("isMember") == true) - } - - @Test("JSON String Initializer with Invalid String") - func jsonInvalidStringInitializer() async throws { - let invalidJSONString = """ - { - "name": "Jane Doe", - "age": 30, - "isMember": true, - """ // Note: trailing comma, making this invalid - #expect(throws: BedrockLibraryError.self) { - let _ = try JSON(from: invalidJSONString) - } - } - - @Test("Empty JSON") - func emptyJSON() async throws { - #expect(throws: Never.self) { - let json = try JSON(from: "") - #expect(json.getValue("nonExistentKey") == nil) - } - } +// @Test("JSON getValue nested") +// func jsonGetValueNested() async throws { +// let json = JSON(with: [ +// "name": JSON(with: "Jane Doe"), +// "age": JSON(with: 30), +// "isMember": JSON(with: true), +// "address": JSON(with: [ +// "street": JSON(with: "123 Main St"), +// "city": JSON(with: "Anytown"), +// "state": JSON(with: "CA"), +// "zip": JSON(with: "12345"), +// "isSomething": JSON(with: true), +// ]), +// ]) +// #expect(json.getValue("name") == "Jane Doe") +// #expect(json.getValue("age") == 30) +// #expect(json.getValue("isMember") == true) +// #expect(json.getValue("nonExistentKey") == nil) +// #expect(json["address"]?.getValue("street") == "123 Main St") +// #expect(json["address"]?.getValue("city") == "Anytown") +// #expect(json["address"]?.getValue("state") == "CA") +// #expect(json["address"]?.getValue("zip") == "12345") +// #expect(json["address"]?.getValue("isSomething") == true) +// #expect(json["address"]?.getValue("nonExistentKey") == nil) +// } +// +// @Test("JSON Subscript") +// func jsonSubscript() async throws { +// let json = JSON(with: [ +// "name": JSON(with: "Jane Doe"), +// "age": JSON(with: 30), +// "isMember": JSON(with: true), +// ]) +// #expect(json["name"] == "Jane Doe") +// #expect(json["age"] == 30) +// #expect(json["isMember"] == true) +// #expect(json["nonExistentKey"] == nil) +// } +// +// @Test("JSON Subscript nested") +// func jsonSubscriptNested() async throws { +// let json = JSON(with: [ +// "name": JSON(with: "Jane Doe"), +// "age": JSON(with: 30), +// "isMember": JSON(with: true), +// "address": JSON(with: [ +// "street": JSON(with: "123 Main St"), +// "city": JSON(with: "Anytown"), +// "state": JSON(with: "CA"), +// "zip": JSON(with: 12345), +// "isSomething": JSON(with: true), +// ]), +// ]) +// #expect(json["name"] == "Jane Doe") +// #expect(json["age"] == 30) +// #expect(json["isMember"] == true) +// #expect(json["nonExistentKey"] == nil) +// #expect(json["address"]?["street"] == "123 Main St") +// #expect(json["address"]?["city"] == "Anytown") +// #expect(json["address"]?["state"] == "CA") +// #expect(json["address"]?["zip"] == 12345) +// #expect(json["address"]?["isSomething"] == true) +// #expect(json["address"]?.getValue("nonExistentKey") == nil) +// } +// +// @Test("JSON String Initializer with Valid String") +// func jsonStringInitializer() async throws { +// let validJSONString = """ +// { +// "name": "Jane Doe", +// "age": 30, +// "isMember": true +// } +// """ +// +// let json = try JSON(from: validJSONString) +// #expect(json.getValue("name") == "Jane Doe") +// #expect(json.getValue("age") == 30) +// #expect(json.getValue("isMember") == true) +// } +// +// @Test("JSON String Initializer with Invalid String") +// func jsonInvalidStringInitializer() async throws { +// let invalidJSONString = """ +// { +// "name": "Jane Doe", +// "age": 30, +// "isMember": true, +// """ // Note: trailing comma, making this invalid +// #expect(throws: BedrockLibraryError.self) { +// let _ = try JSON(from: invalidJSONString) +// } +// } +// +// @Test("Empty JSON") +// func emptyJSON() async throws { +// #expect(throws: Never.self) { +// let json = try JSON(from: "") +// #expect(json.getValue("nonExistentKey") == nil) +// } +// } } From b0787d10d054285967e445ad5deefedf9ddacb9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Stormacq?= Date: Tue, 30 Sep 2025 14:45:53 +0100 Subject: [PATCH 2/4] remove Any? on JSON --- .../ContentBlocks/DocumentToJSON.swift | 29 +- Sources/Converse/ContentBlocks/JSON.swift | 175 ++++++------ Sources/Converse/ConverseRequest.swift | 20 +- Tests/Converse/ConverseToolTests.swift | 30 ++- Tests/Converse/JSONTests.swift | 254 ++++++++++-------- Tests/Converse/ToolResultBlockTests.swift | 2 +- .../ConverseStreamToolTests.swift | 2 +- Tests/Mock/MockBedrockRuntimeClient.swift | 4 +- 8 files changed, 278 insertions(+), 238 deletions(-) diff --git a/Sources/Converse/ContentBlocks/DocumentToJSON.swift b/Sources/Converse/ContentBlocks/DocumentToJSON.swift index a70ed84..d91ed5f 100644 --- a/Sources/Converse/ContentBlocks/DocumentToJSON.swift +++ b/Sources/Converse/ContentBlocks/DocumentToJSON.swift @@ -23,31 +23,34 @@ import Foundation // FIXME: avoid extensions on structs you do not control extension SmithyDocument { - public func toJSON() throws -> JSON { + private func toJSONValue() throws -> JSONValue { switch self.type { case .string: - return JSON(with: try self.asString()) + return try JSONValue(self.asString()) case .boolean: - return JSON(with: try self.asBoolean()) + return try JSONValue(self.asBoolean()) case .integer: - return JSON(with: try self.asInteger()) + return try JSONValue(self.asInteger()) case .double, .float: - return JSON(with: try self.asDouble()) + return try JSONValue(self.asDouble()) case .list: - let array = try self.asList().map { try $0.toJSON() } - return JSON(with: array) + let array = try self.asList().map { try $0.toJSONValue() } + return JSONValue(array) case .map: let map = try self.asStringMap() - var result: [String: JSON] = [:] - for (key, value) in map { - result[key] = try value.toJSON() - } - return JSON(with: result) + let newMap = try map.mapValues({ try $0.toJSONValue() }) + return JSONValue(newMap) case .blob: let data = try self.asBlob() - return JSON(with: data) + let json = try JSON(from: data) + return json.value default: throw DocumentError.typeMismatch("Unsupported type for JSON conversion: \(self.type)") } } + + public func toJSON() throws -> JSON { + let value = try self.toJSONValue() + return JSON(with: value) + } } diff --git a/Sources/Converse/ContentBlocks/JSON.swift b/Sources/Converse/ContentBlocks/JSON.swift index 2deca30..14141b4 100644 --- a/Sources/Converse/ContentBlocks/JSON.swift +++ b/Sources/Converse/ContentBlocks/JSON.swift @@ -29,42 +29,44 @@ public enum JSONValue: Codable, Sendable { case object([String: JSONValue]) public init(_ value: Any?) { - switch value { - case nil: + + guard let value else { self = .null + return + } + switch value { case let v as Int: self = .int(v) + break case let v as Double: self = .double(v) + break case let v as String: self = .string(v) + break case let v as Bool: self = .bool(v) + break case let v as [Any]: self = .array(v.map { JSONValue($0) }) - case let v as [String: Any]: - self = .object(v.mapValues { JSONValue($0) }) - case let v as [String: JSON]: - self = .object(v.mapValues { $0.value }) - case let v as [JSON]: - self = .array(v.map { $0.value }) - case let v as JSONValue: - self = v - case let v as JSON: - self = v.value + break + case let v as [String: JSONValue]: + self = .object(v) + break + case let v as [JSONValue]: + self = .array(v) + break default: fatalError("JSONValue: Unsupported type: \(type(of: value))") } } -} - -public struct JSON: Codable, Sendable { - public var value: JSONValue public subscript(key: String) -> T? { get { - if case let .object(dictionary) = value { - let jsonValue = dictionary[key] + if case let .object(dictionary) = self { + guard let jsonValue = dictionary[key] else { + return nil + } switch jsonValue { case .int(let v): return v as? T case .double(let v): return v as? T @@ -73,47 +75,90 @@ public struct JSON: Codable, Sendable { case .array(let v): return v as? T case .object(let v): return v as? T case .null: return nil - case .none: return nil } } return nil } } - public subscript(key: String) -> JSON? { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let intValue = try? container.decode(Int.self) { + self = .int(intValue) + } else if let doubleValue = try? container.decode(Double.self) { + self = .double(doubleValue) + } else if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + } else if let boolValue = try? container.decode(Bool.self) { + self = .bool(boolValue) + } else if let arrayValue = try? container.decode([JSONValue].self) { + self = .array(arrayValue) + } else if let dictionaryValue = try? container.decode([String: JSONValue].self) { + self = .object(dictionaryValue) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type") + } + } + + // MARK: Public Methods + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case .int(let v): + try container.encode(v) + case .double(let v): + try container.encode(v) + case .string(let v): + try container.encode(v) + case .bool(let v): + try container.encode(v) + case .array(let v): + try container.encode(v) + case .object(let v): + try container.encode(v) + } + } + +} + +public struct JSON: Codable, Sendable { + public let value: JSONValue + + public subscript(key: String) -> T? { + get { + value[key] + } + } + + public subscript(key: String) -> JSONValue? { get { if case let .object(dictionary) = value { if let v = dictionary[key] { - return JSON(with: v) + return v } } return nil } } - -// public subscript(key: String) -> JSONValue? { -// get { -// if case let .object(dictionary) = value { -// if let v = dictionary[key] { -// return v -// } -// } -// return nil -// } -// } public func getValue(_ key: String) -> T? { if case let .object(dictionary) = value { - if let v = dictionary[key] { - switch v { - case .int(let val): return val as? T - case .double(let val): return val as? T - case .string(let val): return val as? T - case .bool(let val): return val as? T - case .array(let val): return val as? T - case .object(let val): return val as? T - case .null: return nil - } + guard let v = dictionary[key] else { + return nil + } + switch v { + case .int(let val): return val as? T + case .double(let val): return val as? T + case .string(let val): return val as? T + case .bool(let val): return val as? T + case .array(let val): return val as? T + case .object(let val): return val as? T + case .null: return nil } } return nil @@ -133,10 +178,6 @@ public struct JSON: Codable, Sendable { // MARK: Initializers - public init(with value: Any?) { - self.value = JSONValue(value) - } - public init(with value: JSONValue) { self.value = value } @@ -151,52 +192,18 @@ public struct JSON: Codable, Sendable { public init(from data: Data) throws { do { + print(String(decoding: data, as: UTF8.self)) self = try JSONDecoder().decode(JSON.self, from: data) } catch { throw BedrockLibraryError.decodingError("Failed to decode JSON: \(error)") } } + // Codable + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() - if container.decodeNil() { - self.value = .null - } else if let intValue = try? container.decode(Int.self) { - self.value = .int(intValue) - } else if let doubleValue = try? container.decode(Double.self) { - self.value = .double(doubleValue) - } else if let stringValue = try? container.decode(String.self) { - self.value = .string(stringValue) - } else if let boolValue = try? container.decode(Bool.self) { - self.value = .bool(boolValue) - } else if let arrayValue = try? container.decode([JSON].self) { - self.value = .array(arrayValue.map { $0.value }) - } else if let dictionaryValue = try? container.decode([String: JSON].self) { - self.value = .object(dictionaryValue.mapValues { $0.value }) - } else { - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type") - } + value = try container.decode(JSONValue.self) } - // MARK: Public Methods - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch value { - case .null: - try container.encodeNil() - case .int(let v): - try container.encode(v) - case .double(let v): - try container.encode(v) - case .string(let v): - try container.encode(v) - case .bool(let v): - try container.encode(v) - case .array(let v): - try container.encode(v.map { JSON(with: $0) }) - case .object(let v): - try container.encode(v.mapValues { JSON(with: $0) }) - } - } } diff --git a/Sources/Converse/ConverseRequest.swift b/Sources/Converse/ConverseRequest.swift index 69f39c8..84879e6 100644 --- a/Sources/Converse/ConverseRequest.swift +++ b/Sources/Converse/ConverseRequest.swift @@ -70,13 +70,21 @@ public struct ConverseRequest { } func getAdditionalModelRequestFields() throws -> Smithy.Document? { + //FIXME: this is incorrect. We should check for all Claude models if model == .claudev3_7_sonnet, let maxReasoningTokens { - let reasoningConfigJSON = JSON(with: [ - "thinking": [ - "type": "enabled", - "budget_tokens": maxReasoningTokens, - ] - ]) + let reasoningConfigJSON = JSON( + with: .array( + [ + .object([ + "thinking": .object([ + "type": .string("enabled"), + "budget_tokens": .int(maxReasoningTokens), + ]) + ]) + ] + ) + ) + return try reasoningConfigJSON.toDocument() } return nil diff --git a/Tests/Converse/ConverseToolTests.swift b/Tests/Converse/ConverseToolTests.swift index 9ff042c..3499778 100644 --- a/Tests/Converse/ConverseToolTests.swift +++ b/Tests/Converse/ConverseToolTests.swift @@ -24,7 +24,7 @@ extension BedrockServiceTests { func converseRequestTool() async throws { let tool = try Tool( name: "toolName", - inputSchema: JSON(with: ["code": "string"]), + inputSchema: JSON(with: .object(["code": .string("string")])), description: "toolDescription" ) let builder = try ConverseRequestBuilder(with: .nova_lite) @@ -42,7 +42,7 @@ extension BedrockServiceTests { } else { id = "" name = "" - input = JSON(with: ["code": "wrong"]) + input = JSON(with: .object(["code": .string("wrong")])) } #expect(id == "toolId") #expect(name == "toolName") @@ -53,7 +53,11 @@ extension BedrockServiceTests { func converseToolWithReusedBuilder() async throws { var builder = try ConverseRequestBuilder(with: .nova_lite) .withPrompt("Use tool") - .withTool(name: "toolName", inputSchema: JSON(with: ["code": "string"]), description: "toolDescription") + .withTool( + name: "toolName", + inputSchema: JSON(with: .object(["code": .string("string")])), + description: "toolDescription" + ) #expect(builder.prompt != nil) #expect(builder.prompt! == "Use tool") @@ -73,7 +77,7 @@ extension BedrockServiceTests { } else { id = "" name = "" - input = JSON(with: ["code": "wrong"]) + input = JSON(with: .object(["code": .string("wrong")])) } #expect(id == "toolId") @@ -109,7 +113,7 @@ extension BedrockServiceTests { #expect(throws: BedrockLibraryError.self) { let tool = try Tool( name: "toolName", - inputSchema: JSON(with: ["code": "string"]), + inputSchema: JSON(with: .object(["code": .string("string")])), description: "toolDescription" ) let _ = try ConverseRequestBuilder(with: .titan_text_g1_express) @@ -130,11 +134,11 @@ extension BedrockServiceTests { func converseToolResult() async throws { let tool = try Tool( name: "toolName", - inputSchema: JSON(with: ["code": "string"]), + inputSchema: JSON(with: .object(["code": .string("string")])), description: "toolDescription" ) let id = "toolId" - let toolUse = ToolUseBlock(id: id, name: "toolName", input: JSON(with: ["code": "abc"])) + let toolUse = ToolUseBlock(id: id, name: "toolName", input: JSON(with: .object(["code": .string("string")]))) let history = [Message("Use tool"), Message(toolUse)] let builder = try ConverseRequestBuilder(with: .nova_lite) @@ -151,7 +155,7 @@ extension BedrockServiceTests { func converseToolResultWithoutToolUse() async throws { let tool = try Tool( name: "toolName", - inputSchema: JSON(with: ["code": "string"]), + inputSchema: JSON(with: .object(["code": .string("string")])), description: "toolDescription" ) let id = "toolId" @@ -167,7 +171,7 @@ extension BedrockServiceTests { @Test("Tool result without tools") func converseToolResultWithoutTools() async throws { let id = "toolId" - let toolUse = ToolUseBlock(id: id, name: "toolName", input: JSON(with: ["code": "abc"])) + let toolUse = ToolUseBlock(id: id, name: "toolName", input: JSON(with: .object(["code": .string("string")]))) let history = [Message("Use tool"), Message(toolUse)] #expect(throws: BedrockLibraryError.self) { let _ = try ConverseRequestBuilder(with: .nova_lite) @@ -180,11 +184,11 @@ extension BedrockServiceTests { func converseToolResultInvalidModel() async throws { let tool = try Tool( name: "toolName", - inputSchema: JSON(with: ["code": "string"]), + inputSchema: JSON(with: .object(["code": .string("string")])), description: "toolDescription" ) let id = "toolId" - let toolUse = ToolUseBlock(id: id, name: "toolName", input: JSON(with: ["code": "abc"])) + let toolUse = ToolUseBlock(id: id, name: "toolName", input: JSON(with: .object(["code": .string("string")]))) let history = [Message("Use tool"), Message(toolUse)] #expect(throws: BedrockLibraryError.self) { let _ = try ConverseRequestBuilder(with: .titan_text_g1_express) @@ -197,7 +201,7 @@ extension BedrockServiceTests { @Test("Tool result with invalid model without tools") func converseToolResultInvalidModelWithoutTools() async throws { let id = "toolId" - let toolUse = ToolUseBlock(id: id, name: "toolName", input: JSON(with: ["code": "abc"])) + let toolUse = ToolUseBlock(id: id, name: "toolName", input: JSON(with: .object(["code": .string("abc")]))) let history = [Message("Use tool"), Message(toolUse)] #expect(throws: BedrockLibraryError.self) { @@ -211,7 +215,7 @@ extension BedrockServiceTests { func converseToolResultInvalidModelWithoutToolUse() async throws { let tool = try Tool( name: "toolName", - inputSchema: JSON(with: ["code": "string"]), + inputSchema: JSON(with: .object(["code": .string("string")])), description: "toolDescription" ) let history = [Message("Use tool"), Message(from: .assistant, content: [.text("No need for a tool")])] diff --git a/Tests/Converse/JSONTests.swift b/Tests/Converse/JSONTests.swift index 7515f03..b16fa26 100644 --- a/Tests/Converse/JSONTests.swift +++ b/Tests/Converse/JSONTests.swift @@ -21,131 +21,149 @@ import Testing @Suite("JSONTests") struct JSONTests { - @Test("JSON getValue 1") - func jsonGetValue1() async throws { - let json = JSON(with: [ - "name": JSON(with: "Jane Doe"), - "age": JSON(with: 30), - "isMember": JSON(with: true), - ]) + @Test("JSON getValue from valid JSON string") + func jsonGetValueFromValidJSONString() async throws { + + let json = try jsonFromString() #expect(json.getValue("name") == "Jane Doe") #expect(json.getValue("age") == 30) #expect(json.getValue("isMember") == true) - #expect(json.getValue("nonExistentKey") == nil) + let t: String? = json.getValue("nonExistentKey") + #expect(t == nil) } - - @Test("JSON getValue 2") - func jsonGetValue2() async throws { - let json = JSON(with: [ - "name": JSONValue("Jane Doe"), - "age": JSONValue(30), - "isMember": JSONValue(true), - ]) + + @Test("JSON getValue from [String:JSONValue]") + func jsonGetValueFromDictionary() async throws { + let json = try jsonFromDictionary() + #expect(json.getValue("name") == "Jane Doe") #expect(json.getValue("age") == 30) #expect(json.getValue("isMember") == true) - #expect(json.getValue("nonExistentKey") == nil) + let t: String? = json.getValue("nonExistentKey") + #expect(t == nil) } -// @Test("JSON getValue nested") -// func jsonGetValueNested() async throws { -// let json = JSON(with: [ -// "name": JSON(with: "Jane Doe"), -// "age": JSON(with: 30), -// "isMember": JSON(with: true), -// "address": JSON(with: [ -// "street": JSON(with: "123 Main St"), -// "city": JSON(with: "Anytown"), -// "state": JSON(with: "CA"), -// "zip": JSON(with: "12345"), -// "isSomething": JSON(with: true), -// ]), -// ]) -// #expect(json.getValue("name") == "Jane Doe") -// #expect(json.getValue("age") == 30) -// #expect(json.getValue("isMember") == true) -// #expect(json.getValue("nonExistentKey") == nil) -// #expect(json["address"]?.getValue("street") == "123 Main St") -// #expect(json["address"]?.getValue("city") == "Anytown") -// #expect(json["address"]?.getValue("state") == "CA") -// #expect(json["address"]?.getValue("zip") == "12345") -// #expect(json["address"]?.getValue("isSomething") == true) -// #expect(json["address"]?.getValue("nonExistentKey") == nil) -// } -// -// @Test("JSON Subscript") -// func jsonSubscript() async throws { -// let json = JSON(with: [ -// "name": JSON(with: "Jane Doe"), -// "age": JSON(with: 30), -// "isMember": JSON(with: true), -// ]) -// #expect(json["name"] == "Jane Doe") -// #expect(json["age"] == 30) -// #expect(json["isMember"] == true) -// #expect(json["nonExistentKey"] == nil) -// } -// -// @Test("JSON Subscript nested") -// func jsonSubscriptNested() async throws { -// let json = JSON(with: [ -// "name": JSON(with: "Jane Doe"), -// "age": JSON(with: 30), -// "isMember": JSON(with: true), -// "address": JSON(with: [ -// "street": JSON(with: "123 Main St"), -// "city": JSON(with: "Anytown"), -// "state": JSON(with: "CA"), -// "zip": JSON(with: 12345), -// "isSomething": JSON(with: true), -// ]), -// ]) -// #expect(json["name"] == "Jane Doe") -// #expect(json["age"] == 30) -// #expect(json["isMember"] == true) -// #expect(json["nonExistentKey"] == nil) -// #expect(json["address"]?["street"] == "123 Main St") -// #expect(json["address"]?["city"] == "Anytown") -// #expect(json["address"]?["state"] == "CA") -// #expect(json["address"]?["zip"] == 12345) -// #expect(json["address"]?["isSomething"] == true) -// #expect(json["address"]?.getValue("nonExistentKey") == nil) -// } -// -// @Test("JSON String Initializer with Valid String") -// func jsonStringInitializer() async throws { -// let validJSONString = """ -// { -// "name": "Jane Doe", -// "age": 30, -// "isMember": true -// } -// """ -// -// let json = try JSON(from: validJSONString) -// #expect(json.getValue("name") == "Jane Doe") -// #expect(json.getValue("age") == 30) -// #expect(json.getValue("isMember") == true) -// } -// -// @Test("JSON String Initializer with Invalid String") -// func jsonInvalidStringInitializer() async throws { -// let invalidJSONString = """ -// { -// "name": "Jane Doe", -// "age": 30, -// "isMember": true, -// """ // Note: trailing comma, making this invalid -// #expect(throws: BedrockLibraryError.self) { -// let _ = try JSON(from: invalidJSONString) -// } -// } -// -// @Test("Empty JSON") -// func emptyJSON() async throws { -// #expect(throws: Never.self) { -// let json = try JSON(from: "") -// #expect(json.getValue("nonExistentKey") == nil) -// } -// } + @Test("JSON getValue nested") + func jsonGetValueNested() async throws { + + let json = try jsonFromDictionaryWithNested() + #expect(json.getValue("name") == "Jane Doe") + #expect(json.getValue("age") == 30) + #expect(json.getValue("isMember") == true) + let t: String? = json.getValue("nonExistentKey") + #expect(t == nil) + #expect(json["address"]?["street"] == "123 Main St") + #expect(json["address"]?["city"] == "Anytown") + #expect(json["address"]?["state"] == "CA") + #expect(json["address"]?["zip"] == 12345) + #expect(json["address"]?["isSomething"] == true) + let t2: String? = json["address"]?["nonExistentKey"] + #expect(t2 == nil) + } + // + @Test("JSON Subscript") + func jsonSubscript() async throws { + let json = try JSON( + from: """ + { + "name": "Jane Doe", + "age": 30, + "isMember": true + } + """ + ) + #expect(json["name"] == "Jane Doe") + #expect(json["age"] == 30) + #expect(json["isMember"] == true) + let t: String? = json["nonExistentKey"] + #expect(t == nil) + } + + @Test("JSON Subscript nested") + func jsonSubscriptNested() async throws { + let json = try jsonFromDictionaryWithNested() + #expect(json["name"] == "Jane Doe") + #expect(json["age"] == 30) + #expect(json["isMember"] == true) + let t: String? = json["nonExistentKey"] + #expect(t == nil) + #expect(json["address"]?["street"] == "123 Main St") + #expect(json["address"]?["city"] == "Anytown") + #expect(json["address"]?["state"] == "CA") + #expect(json["address"]?["zip"] == 12345) + #expect(json["address"]?["isSomething"] == true) + let t2: String? = json["address"]?["nonExistentKey"] + #expect(t2 == nil) + } + + @Test("JSON String Initializer with Invalid String") + func jsonInvalidStringInitializer() async throws { + let invalidJSONString = """ + { + "name": "Jane Doe", + "age": 30, + "isMember": true, + + """ // Note: trailing comma and no closing brace, making this invalid + #expect(throws: BedrockLibraryError.self) { + let _ = try JSON(from: invalidJSONString) + } + } + + @Test("Empty JSON") + func emptyJSON() async throws { + #expect(throws: Never.self) { + let json = try JSON(from: "") + let t: String? = json.getValue("nonExistentKey") + #expect(t == nil) + } + } + + @Test("Nested JSONValue") + func nestedJSONValue() { + JSON( + with: JSONValue([ + "name": JSONValue("Jane Doe"), + "age": JSONValue(30), + "isMember": JSONValue(true), + ]) + ) + + } +} + +extension JSONTests { + private func jsonFromString() throws -> JSON { + try JSON( + from: """ + { + "name": "Jane Doe", + "age": 30, + "isMember": true + } + """ + ) + } + + private func jsonFromDictionary() throws -> JSON { + let value: [String: JSONValue] = ["name": .string("Jane Doe"), "age": .int(30), "isMember": .bool(true)] + return JSON(with: .object(value)) + } + + private func jsonFromDictionaryWithNested() throws -> JSON { + JSON( + with: JSONValue([ + "name": JSONValue("Jane Doe"), + "age": JSONValue(30), + "isMember": JSONValue(true), + "address": JSONValue([ + "street": JSONValue("123 Main St"), + "city": JSONValue("Anytown"), + "state": JSONValue("CA"), + "zip": JSONValue(12345), + "isSomething": JSONValue(true), + ]), + ]) + ) + } } diff --git a/Tests/Converse/ToolResultBlockTests.swift b/Tests/Converse/ToolResultBlockTests.swift index 927394a..f0c632b 100644 --- a/Tests/Converse/ToolResultBlockTests.swift +++ b/Tests/Converse/ToolResultBlockTests.swift @@ -39,7 +39,7 @@ extension BedrockServiceTests { @Test("ToolResultBlock Initializer with ID and JSON Content") func toolResultBlockInitializerWithJSON() async throws { - let json = JSON(with: ["key": JSON(with: "value")]) + let json = JSON(with: .object(["code": .string("string")])) let block = ToolResultBlock(json, id: "block2") #expect(block.id == "block2") #expect(block.content.count == 1) diff --git a/Tests/ConverseStream/ConverseStreamToolTests.swift b/Tests/ConverseStream/ConverseStreamToolTests.swift index d02d2f9..1547417 100644 --- a/Tests/ConverseStream/ConverseStreamToolTests.swift +++ b/Tests/ConverseStream/ConverseStreamToolTests.swift @@ -24,7 +24,7 @@ extension ConverseReplyStreamTests { func converseStreamWithToolUse() async throws { let tool = try Tool( name: "toolName", - inputSchema: JSON(with: ["code": "string"]), + inputSchema: JSON(with: .object(["code": .string("string")])), description: "toolDescription" ) var builder = try ConverseRequestBuilder(with: .nova_lite) diff --git a/Tests/Mock/MockBedrockRuntimeClient.swift b/Tests/Mock/MockBedrockRuntimeClient.swift index 844e1b8..764caf7 100644 --- a/Tests/Mock/MockBedrockRuntimeClient.swift +++ b/Tests/Mock/MockBedrockRuntimeClient.swift @@ -36,7 +36,7 @@ public struct MockBedrockRuntimeClient: BedrockRuntimeClientProtocol { var maxReasoningTokens: Int? if let additionalModelRequestFields = input.additionalModelRequestFields { - let json: JSON = JSON(with: additionalModelRequestFields) + let json = try additionalModelRequestFields.toJSON() reasoningEnabled = json["thinking"]?["enabled"] maxReasoningTokens = json["thinking"]?["budget_tokens"] } @@ -117,7 +117,7 @@ public struct MockBedrockRuntimeClient: BedrockRuntimeClientProtocol { switch content { case .text(let prompt): if prompt == "Use tool", let _ = input.toolConfig?.tools { - let toolInputJson = JSON(with: ["code": "abc"]) + let toolInputJson = JSON(with: .object(["code": .string("string")])) let toolInput = try? toolInputJson.toDocument() replyContent.append( .tooluse( From 0f965482b31732e81b48b4865fcb5a390bb94d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Stormacq?= Date: Tue, 30 Sep 2025 15:28:10 +0100 Subject: [PATCH 3/4] fix remaining tests --- Sources/Converse/ContentBlocks/JSON.swift | 21 ++------------ Tests/Converse/ConverseToolTests.swift | 35 +++++++---------------- Tests/Converse/JSONTests.swift | 26 ++++++++--------- Tests/Converse/ToolResultBlockTests.swift | 18 ++++-------- 4 files changed, 31 insertions(+), 69 deletions(-) diff --git a/Sources/Converse/ContentBlocks/JSON.swift b/Sources/Converse/ContentBlocks/JSON.swift index 14141b4..a6e76a6 100644 --- a/Sources/Converse/ContentBlocks/JSON.swift +++ b/Sources/Converse/ContentBlocks/JSON.swift @@ -56,6 +56,9 @@ public enum JSONValue: Codable, Sendable { case let v as [JSONValue]: self = .array(v) break + case let v as JSONValue: + self = v + break default: fatalError("JSONValue: Unsupported type: \(type(of: value))") } @@ -146,24 +149,6 @@ public struct JSON: Codable, Sendable { } } - public func getValue(_ key: String) -> T? { - if case let .object(dictionary) = value { - guard let v = dictionary[key] else { - return nil - } - switch v { - case .int(let val): return val as? T - case .double(let val): return val as? T - case .string(let val): return val as? T - case .bool(let val): return val as? T - case .array(let val): return val as? T - case .object(let val): return val as? T - case .null: return nil - } - } - return nil - } - public func getValue() -> T? { switch value { case .int(let v): return v as? T diff --git a/Tests/Converse/ConverseToolTests.swift b/Tests/Converse/ConverseToolTests.swift index 3499778..68a0222 100644 --- a/Tests/Converse/ConverseToolTests.swift +++ b/Tests/Converse/ConverseToolTests.swift @@ -32,21 +32,14 @@ extension BedrockServiceTests { .withTool(tool) let reply = try await bedrock.converse(with: builder) #expect(reply.textReply == nil) - let id: String - let name: String - let input: JSON + if let toolUse = reply.toolUse { - id = toolUse.id - name = toolUse.name - input = toolUse.input + #expect(toolUse.id == "toolId") + #expect(toolUse.name == "toolName") + #expect(toolUse.input["value"]?["code"] == "string") } else { - id = "" - name = "" - input = JSON(with: .object(["code": .string("wrong")])) + Issue.record("Tool use is nil") } - #expect(id == "toolId") - #expect(name == "toolName") - #expect(input.getValue("code") == "abc") } @Test("Request tool usage with reused builder") @@ -67,23 +60,15 @@ extension BedrockServiceTests { #expect(reply.textReply == nil) - let id: String - let name: String - let input: JSON if let toolUse = reply.toolUse { - id = toolUse.id - name = toolUse.name - input = toolUse.input + print(toolUse) + #expect(toolUse.id == "toolId") + #expect(toolUse.name == "toolName") + #expect(toolUse.input["value"]?["code"] == "string") } else { - id = "" - name = "" - input = JSON(with: .object(["code": .string("wrong")])) + Issue.record("ToolUse is nil") } - #expect(id == "toolId") - #expect(name == "toolName") - #expect(input.getValue("code") == "abc") - builder = try ConverseRequestBuilder(from: builder, with: reply) .withToolResult("Information from Tool") diff --git a/Tests/Converse/JSONTests.swift b/Tests/Converse/JSONTests.swift index b16fa26..707ac2f 100644 --- a/Tests/Converse/JSONTests.swift +++ b/Tests/Converse/JSONTests.swift @@ -25,10 +25,10 @@ struct JSONTests { func jsonGetValueFromValidJSONString() async throws { let json = try jsonFromString() - #expect(json.getValue("name") == "Jane Doe") - #expect(json.getValue("age") == 30) - #expect(json.getValue("isMember") == true) - let t: String? = json.getValue("nonExistentKey") + #expect(json["name"] == "Jane Doe") + #expect(json["age"] == 30) + #expect(json["isMember"] == true) + let t: String? = json["nonExistentKey"] #expect(t == nil) } @@ -36,10 +36,10 @@ struct JSONTests { func jsonGetValueFromDictionary() async throws { let json = try jsonFromDictionary() - #expect(json.getValue("name") == "Jane Doe") - #expect(json.getValue("age") == 30) - #expect(json.getValue("isMember") == true) - let t: String? = json.getValue("nonExistentKey") + #expect(json["name"] == "Jane Doe") + #expect(json["age"] == 30) + #expect(json["isMember"] == true) + let t: String? = json["nonExistentKey"] #expect(t == nil) } @@ -47,10 +47,10 @@ struct JSONTests { func jsonGetValueNested() async throws { let json = try jsonFromDictionaryWithNested() - #expect(json.getValue("name") == "Jane Doe") - #expect(json.getValue("age") == 30) - #expect(json.getValue("isMember") == true) - let t: String? = json.getValue("nonExistentKey") + #expect(json["name"] == "Jane Doe") + #expect(json["age"] == 30) + #expect(json["isMember"] == true) + let t: String? = json["nonExistentKey"] #expect(t == nil) #expect(json["address"]?["street"] == "123 Main St") #expect(json["address"]?["city"] == "Anytown") @@ -114,7 +114,7 @@ struct JSONTests { func emptyJSON() async throws { #expect(throws: Never.self) { let json = try JSON(from: "") - let t: String? = json.getValue("nonExistentKey") + let t: String? = json["nonExistentKey"] #expect(t == nil) } } diff --git a/Tests/Converse/ToolResultBlockTests.swift b/Tests/Converse/ToolResultBlockTests.swift index f0c632b..797906e 100644 --- a/Tests/Converse/ToolResultBlockTests.swift +++ b/Tests/Converse/ToolResultBlockTests.swift @@ -39,15 +39,13 @@ extension BedrockServiceTests { @Test("ToolResultBlock Initializer with ID and JSON Content") func toolResultBlockInitializerWithJSON() async throws { - let json = JSON(with: .object(["code": .string("string")])) + let json = JSON(with: .object(["key": .string("string")])) let block = ToolResultBlock(json, id: "block2") #expect(block.id == "block2") #expect(block.content.count == 1) - var value = "" if case .json(let json) = block.content.first { - value = json.getValue("key") ?? "" + #expect(json["key"] == "string") } - #expect(value == "value") #expect(block.status == .success) } @@ -90,11 +88,9 @@ extension BedrockServiceTests { #expect(block.id == "block5") #expect(block.content.count == 1) - var value = "" if case .json(let json) = block.content.first { - value = json.getValue("key") ?? "" + #expect(json["key"] == "value") } - #expect(value == "value") #expect(block.status == block.status) } @@ -108,14 +104,10 @@ extension BedrockServiceTests { let block = try ToolResultBlock(object, id: "block6") #expect(block.id == "block6") #expect(block.content.count == 1) - var name = "" - var age = 0 if case .json(let jsonContent) = block.content.first { - name = jsonContent.getValue("name") ?? "" - age = jsonContent.getValue("age") ?? 0 + #expect(jsonContent["name"] == "Jane") + #expect(jsonContent["age"] == 30) } - #expect(name == "Jane") - #expect(age == 30) } @Test("ToolResultBlock Initializer with Invalid Data Throws Error") From 8760ce320313b49db6b735ba138887eb2d218603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Stormacq?= Date: Tue, 30 Sep 2025 15:39:31 +0100 Subject: [PATCH 4/4] [ci] run builds and test in parallel --- .github/workflows/pull_request.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index d2df255..e178d7e 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -3,13 +3,19 @@ name: Build, tests & soundness checks on: [pull_request, workflow_dispatch] jobs: - swift-bedrock-library: + swift-bedrock-library-build: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Build run: swift build --configuration release + + swift-bedrock-library-test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 - name: Tests run: swift test