diff --git a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginFunctionalTests/API.swift b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginFunctionalTests/API.swift index 4b17109a40..9073d43111 100644 --- a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginFunctionalTests/API.swift +++ b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginFunctionalTests/API.swift @@ -87,11 +87,45 @@ public extension GraphQLSelectionSet { let jsonData = try encoder.encode(jsonObject) let decodedDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) as! [String: Any] let optionalDictionary = decodedDictionary.mapValues { $0 as Any? } - self.init(snapshot: optionalDictionary) + let convertedDictionary = Self.convertFieldValues( + in: optionalDictionary, using: Self.selections + ) + self.init(snapshot: convertedDictionary) } else { self.init(snapshot: [:]) } } + + private static func convertFieldValues( + in snapshot: Snapshot, using selections: [GraphQLSelection] + ) -> Snapshot { + var result = snapshot + for selection in selections { + guard let field = selection as? GraphQLField else { continue } + let key = field.responseKey + guard let value = result[key], let unwrapped = value else { continue } + result[key] = convertValue(unwrapped, for: field.type) + } + return result + } + + private static func convertValue(_ value: Any, for outputType: GraphQLOutputType) -> Any? { + switch outputType { + case .scalar(let decodableType): + if type(of: value) == decodableType { + return value + } + return (try? decodableType.init(jsonValue: value)) ?? value + case .nonNull(let innerType): + return convertValue(value, for: innerType) + case .list(let innerType): + guard let array = value as? [Any] else { return value } + return array.map { convertValue($0, for: innerType) ?? $0 } + case .object(let selections): + guard let dict = value as? [String: Any] else { return value } + return convertFieldValues(in: dict.mapValues { $0 as Any? }, using: selections) + } + } } enum APISwiftJSONValue: Codable { case array([APISwiftJSONValue]) diff --git a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginGraphQLIAMTests/API.swift b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginGraphQLIAMTests/API.swift index ae4c776f91..90615b2a83 100644 --- a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginGraphQLIAMTests/API.swift +++ b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginGraphQLIAMTests/API.swift @@ -87,11 +87,45 @@ public extension GraphQLSelectionSet { let jsonData = try encoder.encode(jsonObject) let decodedDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) as! [String: Any] let optionalDictionary = decodedDictionary.mapValues { $0 as Any? } - self.init(snapshot: optionalDictionary) + let convertedDictionary = Self.convertFieldValues( + in: optionalDictionary, using: Self.selections + ) + self.init(snapshot: convertedDictionary) } else { self.init(snapshot: [:]) } } + + private static func convertFieldValues( + in snapshot: Snapshot, using selections: [GraphQLSelection] + ) -> Snapshot { + var result = snapshot + for selection in selections { + guard let field = selection as? GraphQLField else { continue } + let key = field.responseKey + guard let value = result[key], let unwrapped = value else { continue } + result[key] = convertValue(unwrapped, for: field.type) + } + return result + } + + private static func convertValue(_ value: Any, for outputType: GraphQLOutputType) -> Any? { + switch outputType { + case .scalar(let decodableType): + if type(of: value) == decodableType { + return value + } + return (try? decodableType.init(jsonValue: value)) ?? value + case .nonNull(let innerType): + return convertValue(value, for: innerType) + case .list(let innerType): + guard let array = value as? [Any] else { return value } + return array.map { convertValue($0, for: innerType) ?? $0 } + case .object(let selections): + guard let dict = value as? [String: Any] else { return value } + return convertFieldValues(in: dict.mapValues { $0 as Any? }, using: selections) + } + } } enum APISwiftJSONValue: Codable { case array([APISwiftJSONValue]) diff --git a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginGraphQLUserPoolTests/API.swift b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginGraphQLUserPoolTests/API.swift index bf7bf16d35..bd93dc7096 100644 --- a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginGraphQLUserPoolTests/API.swift +++ b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginGraphQLUserPoolTests/API.swift @@ -87,11 +87,45 @@ public extension GraphQLSelectionSet { let jsonData = try encoder.encode(jsonObject) let decodedDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) as! [String: Any] let optionalDictionary = decodedDictionary.mapValues { $0 as Any? } - self.init(snapshot: optionalDictionary) + let convertedDictionary = Self.convertFieldValues( + in: optionalDictionary, using: Self.selections + ) + self.init(snapshot: convertedDictionary) } else { self.init(snapshot: [:]) } } + + private static func convertFieldValues( + in snapshot: Snapshot, using selections: [GraphQLSelection] + ) -> Snapshot { + var result = snapshot + for selection in selections { + guard let field = selection as? GraphQLField else { continue } + let key = field.responseKey + guard let value = result[key], let unwrapped = value else { continue } + result[key] = convertValue(unwrapped, for: field.type) + } + return result + } + + private static func convertValue(_ value: Any, for outputType: GraphQLOutputType) -> Any? { + switch outputType { + case .scalar(let decodableType): + if type(of: value) == decodableType { + return value + } + return (try? decodableType.init(jsonValue: value)) ?? value + case .nonNull(let innerType): + return convertValue(value, for: innerType) + case .list(let innerType): + guard let array = value as? [Any] else { return value } + return array.map { convertValue($0, for: innerType) ?? $0 } + case .object(let selections): + guard let dict = value as? [String: Any] else { return value } + return convertFieldValues(in: dict.mapValues { $0 as Any? }, using: selections) + } + } } enum APISwiftJSONValue: Codable { case array([APISwiftJSONValue]) diff --git a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/APISwiftCompatibility/API.swift b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/APISwiftCompatibility/API.swift index 22f33121b8..5f09e39bb4 100644 --- a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/APISwiftCompatibility/API.swift +++ b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/APISwiftCompatibility/API.swift @@ -88,11 +88,45 @@ extension GraphQLSelectionSet { let jsonData = try encoder.encode(jsonObject) let decodedDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) as! [String: Any] let optionalDictionary = decodedDictionary.mapValues { $0 as Any? } - self.init(snapshot: optionalDictionary) + let convertedDictionary = Self.convertFieldValues( + in: optionalDictionary, using: Self.selections + ) + self.init(snapshot: convertedDictionary) } else { self.init(snapshot: [:]) } } + + private static func convertFieldValues( + in snapshot: Snapshot, using selections: [GraphQLSelection] + ) -> Snapshot { + var result = snapshot + for selection in selections { + guard let field = selection as? GraphQLField else { continue } + let key = field.responseKey + guard let value = result[key], let unwrapped = value else { continue } + result[key] = convertValue(unwrapped, for: field.type) + } + return result + } + + private static func convertValue(_ value: Any, for outputType: GraphQLOutputType) -> Any? { + switch outputType { + case .scalar(let decodableType): + if type(of: value) == decodableType { + return value + } + return (try? decodableType.init(jsonValue: value)) ?? value + case .nonNull(let innerType): + return convertValue(value, for: innerType) + case .list(let innerType): + guard let array = value as? [Any] else { return value } + return array.map { convertValue($0, for: innerType) ?? $0 } + case .object(let selections): + guard let dict = value as? [String: Any] else { return value } + return convertFieldValues(in: dict.mapValues { $0 as Any? }, using: selections) + } + } } enum APISwiftJSONValue: Codable { case array([APISwiftJSONValue]) @@ -9223,3 +9257,217 @@ public struct S3Object: GraphQLFragment { } } } + +// MARK: - Test types for enum decoding + +public enum PostStatus: RawRepresentable, Equatable, JSONDecodable, JSONEncodable { + public typealias RawValue = String + case draft + case published + case archived + /// Auto generated constant for unknown enum values + case unknown(RawValue) + + public init?(rawValue: RawValue) { + switch rawValue { + case "DRAFT": self = .draft + case "PUBLISHED": self = .published + case "ARCHIVED": self = .archived + default: self = .unknown(rawValue) + } + } + + public var rawValue: RawValue { + switch self { + case .draft: return "DRAFT" + case .published: return "PUBLISHED" + case .archived: return "ARCHIVED" + case .unknown(let value): return value + } + } + +} + +public final class GetPostWithStatusQuery: GraphQLQuery { + public static let operationString = + "query GetPostWithStatus($id: ID!) {\n getPost(id: $id) {\n __typename\n id\n title\n status\n }\n}" + + public var id: GraphQLID + + public init(id: GraphQLID) { + self.id = id + } + + public var variables: GraphQLMap? { + return ["id": id] + } + + public struct Data: GraphQLSelectionSet { + nonisolated(unsafe) public static let selections: [GraphQLSelection] = [ + GraphQLField("getPost", arguments: ["id": GraphQLVariable("id")], type: .object(GetPost.selections)), + ] + public var snapshot: Snapshot + + public init(snapshot: Snapshot) { + self.snapshot = snapshot + } + + public var getPost: GetPost? { + get { + return (snapshot["getPost"] as? Snapshot).flatMap { GetPost(snapshot: $0) } + } + } + + public struct GetPost: GraphQLSelectionSet { + nonisolated(unsafe) public static let selections: [GraphQLSelection] = [ + GraphQLField("__typename", type: .nonNull(.scalar(String.self))), + GraphQLField("id", type: .nonNull(.scalar(GraphQLID.self))), + GraphQLField("title", type: .nonNull(.scalar(String.self))), + GraphQLField("status", type: .nonNull(.scalar(PostStatus.self))), + ] + public var snapshot: Snapshot + + public init(snapshot: Snapshot) { + self.snapshot = snapshot + } + + public var id: GraphQLID { + get { return snapshot["id"]! as! GraphQLID } + } + + public var title: String { + get { return snapshot["title"]! as! String } + } + + public var status: PostStatus { + get { return snapshot["status"]! as! PostStatus } + } + } + } +} + +public final class GetPostWithOptionalStatusQuery: GraphQLQuery { + public static let operationString = + "query GetPostWithOptionalStatus($id: ID!) {\n getPost(id: $id) {\n __typename\n id\n title\n status\n }\n}" + + public var id: GraphQLID + + public init(id: GraphQLID) { + self.id = id + } + + public var variables: GraphQLMap? { + return ["id": id] + } + + public struct Data: GraphQLSelectionSet { + nonisolated(unsafe) public static let selections: [GraphQLSelection] = [ + GraphQLField("getPost", arguments: ["id": GraphQLVariable("id")], type: .object(GetPost.selections)), + ] + public var snapshot: Snapshot + + public init(snapshot: Snapshot) { + self.snapshot = snapshot + } + + public var getPost: GetPost? { + get { + return (snapshot["getPost"] as? Snapshot).flatMap { GetPost(snapshot: $0) } + } + } + + public struct GetPost: GraphQLSelectionSet { + nonisolated(unsafe) public static let selections: [GraphQLSelection] = [ + GraphQLField("__typename", type: .nonNull(.scalar(String.self))), + GraphQLField("id", type: .nonNull(.scalar(GraphQLID.self))), + GraphQLField("title", type: .nonNull(.scalar(String.self))), + GraphQLField("status", type: .scalar(PostStatus.self)), + ] + public var snapshot: Snapshot + + public init(snapshot: Snapshot) { + self.snapshot = snapshot + } + + public var id: GraphQLID { + get { return snapshot["id"]! as! GraphQLID } + } + + public var title: String { + get { return snapshot["title"]! as! String } + } + + public var status: PostStatus? { + get { return snapshot["status"]! as? PostStatus } + } + } + } +} + +public final class ListPostsWithStatusQuery: GraphQLQuery { + public static let operationString = + "query ListPostsWithStatus {\n listPosts {\n __typename\n items {\n __typename\n id\n title\n status\n }\n }\n}" + + public init() {} + + public struct Data: GraphQLSelectionSet { + nonisolated(unsafe) public static let selections: [GraphQLSelection] = [ + GraphQLField("listPosts", type: .object(ListPosts.selections)), + ] + public var snapshot: Snapshot + + public init(snapshot: Snapshot) { + self.snapshot = snapshot + } + + public var listPosts: ListPosts? { + get { + return (snapshot["listPosts"] as? Snapshot).flatMap { ListPosts(snapshot: $0) } + } + } + + public struct ListPosts: GraphQLSelectionSet { + nonisolated(unsafe) public static let selections: [GraphQLSelection] = [ + GraphQLField("__typename", type: .nonNull(.scalar(String.self))), + GraphQLField("items", type: .list(.nonNull(.object(Item.selections)))), + ] + public var snapshot: Snapshot + + public init(snapshot: Snapshot) { + self.snapshot = snapshot + } + + public var items: [Item]? { + get { + return (snapshot["items"] as? [Snapshot])?.map { Item(snapshot: $0) } + } + } + + public struct Item: GraphQLSelectionSet { + nonisolated(unsafe) public static let selections: [GraphQLSelection] = [ + GraphQLField("__typename", type: .nonNull(.scalar(String.self))), + GraphQLField("id", type: .nonNull(.scalar(GraphQLID.self))), + GraphQLField("title", type: .nonNull(.scalar(String.self))), + GraphQLField("status", type: .nonNull(.scalar(PostStatus.self))), + ] + public var snapshot: Snapshot + + public init(snapshot: Snapshot) { + self.snapshot = snapshot + } + + public var id: GraphQLID { + get { return snapshot["id"]! as! GraphQLID } + } + + public var title: String { + get { return snapshot["title"]! as! String } + } + + public var status: PostStatus { + get { return snapshot["status"]! as! PostStatus } + } + } + } + } +} diff --git a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/APISwiftCompatibility/APISwiftTests.swift b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/APISwiftCompatibility/APISwiftTests.swift index 046654cba3..02a631bb73 100644 --- a/AmplifyPlugins/API/Tests/AWSAPIPluginTests/APISwiftCompatibility/APISwiftTests.swift +++ b/AmplifyPlugins/API/Tests/AWSAPIPluginTests/APISwiftCompatibility/APISwiftTests.swift @@ -10,6 +10,80 @@ import XCTest final class APISwiftTests: XCTestCase { + // MARK: - Enum Decoding Tests + + /// Tests that a non-optional enum field is correctly decoded from a JSON string value + func testEnumFieldDecoding() throws { + let jsonString = """ + {"getPost": {"__typename": "Post", "id": "1", "title": "Hello", "status": "PUBLISHED"}} + """ + let data = jsonString.data(using: .utf8)! + let decoded = try JSONDecoder().decode( + GetPostWithStatusQuery.Data.self, from: data + ) + let post = try XCTUnwrap(decoded.getPost) + XCTAssertEqual(post.status, PostStatus.published) + XCTAssertEqual(post.id, "1") + XCTAssertEqual(post.title, "Hello") + } + + /// Tests that an optional enum field is correctly decoded from a JSON string value + func testOptionalEnumFieldDecoding() throws { + let jsonString = """ + {"getPost": {"__typename": "Post", "id": "2", "title": "World", "status": "DRAFT"}} + """ + let data = jsonString.data(using: .utf8)! + let decoded = try JSONDecoder().decode( + GetPostWithOptionalStatusQuery.Data.self, from: data + ) + let post = try XCTUnwrap(decoded.getPost) + XCTAssertEqual(post.status, PostStatus.draft) + } + + /// Tests that an optional enum field with null value decodes as nil + func testOptionalEnumFieldDecodingNull() throws { + let jsonString = """ + {"getPost": {"__typename": "Post", "id": "3", "title": "Null Status", "status": null}} + """ + let data = jsonString.data(using: .utf8)! + let decoded = try JSONDecoder().decode( + GetPostWithOptionalStatusQuery.Data.self, from: data + ) + let post = try XCTUnwrap(decoded.getPost) + XCTAssertNil(post.status) + } + + /// Tests that enum fields inside nested list objects are correctly decoded + func testEnumFieldInNestedListDecoding() throws { + let jsonString = """ + {"listPosts": {"__typename": "ModelPostConnection", "items": [{"__typename": "Post", "id": "1", "title": "First", "status": "DRAFT"}, {"__typename": "Post", "id": "2", "title": "Second", "status": "ARCHIVED"}]}} + """ + let data = jsonString.data(using: .utf8)! + let decoded = try JSONDecoder().decode( + ListPostsWithStatusQuery.Data.self, from: data + ) + let listPosts = try XCTUnwrap(decoded.listPosts) + let items = try XCTUnwrap(listPosts.items) + XCTAssertEqual(items.count, 2) + XCTAssertEqual(items[0].status, PostStatus.draft) + XCTAssertEqual(items[1].status, PostStatus.archived) + } + + /// Tests that unknown enum raw values are handled correctly + func testUnknownEnumValueDecoding() throws { + let jsonString = """ + {"getPost": {"__typename": "Post", "id": "4", "title": "Unknown", "status": "SOME_NEW_STATUS"}} + """ + let data = jsonString.data(using: .utf8)! + let decoded = try JSONDecoder().decode( + GetPostWithStatusQuery.Data.self, from: data + ) + let post = try XCTUnwrap(decoded.getPost) + XCTAssertEqual(post.status, PostStatus.unknown("SOME_NEW_STATUS")) + } + + // MARK: - Existing Tests + func testCreateBlogMutation() { let file = S3ObjectInput(bucket: "bucket", key: "let", region: "region") let input = CreateBlogInput(name: "name", file: file)