From b87811c5d1230c4d1f0a809192266a18bb3d7949 Mon Sep 17 00:00:00 2001 From: Michael Law <1365977+lawmicha@users.noreply.github.com> Date: Thu, 30 Apr 2020 13:18:43 -0400 Subject: [PATCH] feat: [API] Merge non-GraphQL spec error fields into GraphQLError.extensions (#401) * feat: [API] Merge non-GraphQL spec error fields into GraphQLError.extensions * add AppSyncErrorType * clean up mergeExtensions --- Amplify.xcodeproj/project.pbxproj | 14 ++- .../API/Response/GraphQLError.swift | 13 ++ .../project.pbxproj | 8 ++ .../GraphQLResponseDecoder+DecodeError.swift | 68 ++++++++++ .../Utils/GraphQLResponseDecoder.swift | 23 ---- .../GraphQLSyncBasedTests.swift | 113 ++++++++++++++++- .../GraphQLSyncBased/README.md | 2 +- ...aphQLResponseDecoderDecodeErrorTests.swift | 118 ++++++++++++++++++ .../AWSPluginsCore/API/AppSyncErrorType.swift | 45 +++++++ ...ocessMutationErrorFromCloudOperation.swift | 7 +- ...MutationErrorFromCloudOperationTests.swift | 6 +- 11 files changed, 383 insertions(+), 34 deletions(-) create mode 100644 AmplifyPlugins/API/AWSAPICategoryPlugin/Support/Utils/GraphQLResponseDecoder+DecodeError.swift create mode 100644 AmplifyPlugins/API/AWSAPICategoryPluginTests/Support/Utils/GraphQLResponseDecoderDecodeErrorTests.swift create mode 100644 AmplifyPlugins/Core/AWSPluginsCore/API/AppSyncErrorType.swift diff --git a/Amplify.xcodeproj/project.pbxproj b/Amplify.xcodeproj/project.pbxproj index 7cd4687d1b..8bee9695cb 100644 --- a/Amplify.xcodeproj/project.pbxproj +++ b/Amplify.xcodeproj/project.pbxproj @@ -92,6 +92,7 @@ 219A88ED23F3309800BBC5F2 /* Tree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 219A88EC23F3309800BBC5F2 /* Tree.swift */; }; 219A88EF23F3358F00BBC5F2 /* TreeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 219A88EE23F3358F00BBC5F2 /* TreeTests.swift */; }; 219A88F123F3379900BBC5F2 /* GraphQLDocumentInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 219A88F023F3379900BBC5F2 /* GraphQLDocumentInput.swift */; }; + 21C395B3245729EC00597EA2 /* AppSyncErrorType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21C395B2245729EC00597EA2 /* AppSyncErrorType.swift */; }; 21D1CE8C2334233F0003BAA8 /* AuthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D1CE8B2334233F0003BAA8 /* AuthError.swift */; }; 21D79FDA237617C60057D00D /* SubscriptionEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D79FD9237617C60057D00D /* SubscriptionEvent.swift */; }; 21D79FE12377BF4B0057D00D /* AuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D79FE02377BF4B0057D00D /* AuthProvider.swift */; }; @@ -622,6 +623,7 @@ 219A88EC23F3309800BBC5F2 /* Tree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tree.swift; sourceTree = ""; }; 219A88EE23F3358F00BBC5F2 /* TreeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreeTests.swift; sourceTree = ""; }; 219A88F023F3379900BBC5F2 /* GraphQLDocumentInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLDocumentInput.swift; sourceTree = ""; }; + 21C395B2245729EC00597EA2 /* AppSyncErrorType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSyncErrorType.swift; sourceTree = ""; }; 21D1CE8B2334233F0003BAA8 /* AuthError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthError.swift; sourceTree = ""; }; 21D79FD9237617C60057D00D /* SubscriptionEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionEvent.swift; sourceTree = ""; }; 21D79FE02377BF4B0057D00D /* AuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthProvider.swift; sourceTree = ""; }; @@ -1398,6 +1400,14 @@ path = Error; sourceTree = ""; }; + 21C395B4245729F100597EA2 /* API */ = { + isa = PBXGroup; + children = ( + 21C395B2245729EC00597EA2 /* AppSyncErrorType.swift */, + ); + path = API; + sourceTree = ""; + }; 21FFF999230C96E0005878EA /* Operation */ = { isa = PBXGroup; children = ( @@ -1977,9 +1987,10 @@ FA131AAB2360FE070008381C /* AWSPluginsCore */ = { isa = PBXGroup; children = ( + 21C395B4245729F100597EA2 /* API */, + FA131ACB2360FE470008381C /* Auth */, FA131AAC2360FE070008381C /* AWSPluginsCore.h */, FA131AAD2360FE070008381C /* Info.plist */, - FA131ACB2360FE470008381C /* Auth */, 2129BE0223947FA3006363A1 /* Model */, 6BBECD6F23ADA7C100C8DFBE /* ServiceConfiguration */, 2129BE3F23948909006363A1 /* Sync */, @@ -3499,6 +3510,7 @@ 21D79FE12377BF4B0057D00D /* AuthProvider.swift in Sources */, 21420AA1237222A900FA140C /* AWSMobileClientBehavior.swift in Sources */, 212CE71123E9EA6A007D8E71 /* ModelField+GraphQL.swift in Sources */, + 21C395B3245729EC00597EA2 /* AppSyncErrorType.swift in Sources */, 212CE70523E9E967007D8E71 /* GraphQLQuery.swift in Sources */, 212CE70C23E9E991007D8E71 /* ConflictResolutionDecorator.swift in Sources */, 212CE71323E9F2ED007D8E71 /* DirectiveNameDecorator.swift in Sources */, diff --git a/Amplify/Categories/API/Response/GraphQLError.swift b/Amplify/Categories/API/Response/GraphQLError.swift index b0161156bc..240b000655 100644 --- a/Amplify/Categories/API/Response/GraphQLError.swift +++ b/Amplify/Categories/API/Response/GraphQLError.swift @@ -20,6 +20,19 @@ public struct GraphQLError: Decodable { /// Additional map of of errors public let extensions: [String: JSONValue]? + public init(message: String, + locations: [Location]? = nil, + path: [JSONValue]? = nil, + extensions: [String: JSONValue]? = nil) { + self.message = message + self.locations = locations + self.path = path + self.extensions = extensions + } +} + +extension GraphQLError { + /// Both `line` and `column` are positive numbers describing the beginning of an associated syntax element public struct Location: Decodable { public let line: Int diff --git a/AmplifyPlugins/API/APICategoryPlugin.xcodeproj/project.pbxproj b/AmplifyPlugins/API/APICategoryPlugin.xcodeproj/project.pbxproj index 86bbca01d4..b3860712c4 100644 --- a/AmplifyPlugins/API/APICategoryPlugin.xcodeproj/project.pbxproj +++ b/AmplifyPlugins/API/APICategoryPlugin.xcodeproj/project.pbxproj @@ -77,6 +77,8 @@ 21D7A118237B54D90057D00D /* APIKeyURLRequestInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D7A0D5237B54D90057D00D /* APIKeyURLRequestInterceptor.swift */; }; 21D7A119237B54D90057D00D /* IAMURLRequestInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D7A0D6237B54D90057D00D /* IAMURLRequestInterceptor.swift */; }; 21D7A11A237B54D90057D00D /* AWSAPICategoryPluginError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D7A0D7237B54D90057D00D /* AWSAPICategoryPluginError.swift */; }; + 21E2E2282451E66A007D7767 /* GraphQLResponseDecoder+DecodeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21E2E2272451E66A007D7767 /* GraphQLResponseDecoder+DecodeError.swift */; }; + 21E2E22A2451E6B5007D7767 /* GraphQLResponseDecoderDecodeErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21E2E2292451E6B5007D7767 /* GraphQLResponseDecoderDecodeErrorTests.swift */; }; 21F40A2B23A0423C0074678E /* GraphQLSyncBasedTests-amplifyconfiguration.json in Resources */ = {isa = PBXBuildFile; fileRef = 21F40A2923A0423C0074678E /* GraphQLSyncBasedTests-amplifyconfiguration.json */; }; 21F40A2E23A0707E0074678E /* GraphQLModelBasedTests-amplifyconfiguration.json in Resources */ = {isa = PBXBuildFile; fileRef = 21F40A2D23A0707E0074678E /* GraphQLModelBasedTests-amplifyconfiguration.json */; }; 241355B5778C3B2C3826CE96 /* Pods_HostApp_AWSAPICategoryPluginTestCommon_RESTWithIAMIntegrationTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2D57635393A9898E665C00A1 /* Pods_HostApp_AWSAPICategoryPluginTestCommon_RESTWithIAMIntegrationTests.framework */; }; @@ -336,6 +338,8 @@ 21D7A0D6237B54D90057D00D /* IAMURLRequestInterceptor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IAMURLRequestInterceptor.swift; sourceTree = ""; }; 21D7A0D7237B54D90057D00D /* AWSAPICategoryPluginError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AWSAPICategoryPluginError.swift; sourceTree = ""; }; 21D7A0DE237B54D90057D00D /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 21E2E2272451E66A007D7767 /* GraphQLResponseDecoder+DecodeError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GraphQLResponseDecoder+DecodeError.swift"; sourceTree = ""; }; + 21E2E2292451E6B5007D7767 /* GraphQLResponseDecoderDecodeErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLResponseDecoderDecodeErrorTests.swift; sourceTree = ""; }; 21F40A2923A0423C0074678E /* GraphQLSyncBasedTests-amplifyconfiguration.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "GraphQLSyncBasedTests-amplifyconfiguration.json"; sourceTree = ""; }; 21F40A2D23A0707E0074678E /* GraphQLModelBasedTests-amplifyconfiguration.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "GraphQLModelBasedTests-amplifyconfiguration.json"; sourceTree = ""; }; 226F79D02FF47C0A8AE75467 /* Pods-HostApp-AWSAPICategoryPluginTestCommon-GraphQLWithUserPoolIntegrationTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HostApp-AWSAPICategoryPluginTestCommon-GraphQLWithUserPoolIntegrationTests.release.xcconfig"; path = "Target Support Files/Pods-HostApp-AWSAPICategoryPluginTestCommon-GraphQLWithUserPoolIntegrationTests/Pods-HostApp-AWSAPICategoryPluginTestCommon-GraphQLWithUserPoolIntegrationTests.release.xcconfig"; sourceTree = ""; }; @@ -795,6 +799,7 @@ 21D7A0CB237B54D90057D00D /* GraphQLOperationRequestUtils.swift */, 21D7A0CD237B54D90057D00D /* GraphQLOperationRequestUtils+Validator.swift */, 21D7A0CE237B54D90057D00D /* GraphQLResponseDecoder.swift */, + 21E2E2272451E66A007D7767 /* GraphQLResponseDecoder+DecodeError.swift */, 21409C5F2384DF17000A53C9 /* RESTOperationRequest+RESTRequest.swift */, 21D7A0C7237B54D90057D00D /* RESTOperationRequest+Validate.swift */, 21D7A0C8237B54D90057D00D /* RESTOperationRequestUtils.swift */, @@ -968,6 +973,7 @@ children = ( B4DFA5D0237A611D0013E17B /* GraphQLRequestUtils+ValidatorTests.swift */, B4DFA5D3237A611D0013E17B /* GraphQLRequestUtilsTests.swift */, + 21E2E2292451E6B5007D7767 /* GraphQLResponseDecoderDecodeErrorTests.swift */, B4DFA5D1237A611D0013E17B /* GraphQLResponseDecoderTests.swift */, B4DFA5D4237A611D0013E17B /* RESTRequestUtils+ValidatorTests.swift */, B4DFA5D2237A611D0013E17B /* RESTRequestUtilsTests.swift */, @@ -2093,6 +2099,7 @@ buildActionMask = 2147483647; files = ( 21D7A102237B54D90057D00D /* URLSessionBehaviorDelegate.swift in Sources */, + 21E2E2282451E66A007D7767 /* GraphQLResponseDecoder+DecodeError.swift in Sources */, 21D7A0FD237B54D90057D00D /* URLSession+URLSessionBehavior.swift in Sources */, 21D7A113237B54D90057D00D /* GraphQLResponseDecoder.swift in Sources */, 21D7A0FF237B54D90057D00D /* URLSessionBehavior.swift in Sources */, @@ -2165,6 +2172,7 @@ 6B2E465A23AAA6AF0066EDCE /* NetworkReachabilityNotifierTests.swift in Sources */, B4DFA5E1237A611D0013E17B /* MockURLSessionTask.swift in Sources */, B4DFA5F8237A611D0013E17B /* AWSAPICategoryPlugin+ConfigureTests.swift in Sources */, + 21E2E22A2451E6B5007D7767 /* GraphQLResponseDecoderDecodeErrorTests.swift in Sources */, B4DFA5F1237A611D0013E17B /* AWSAPICategoryPlugin+URLSessionBehaviorDelegateTests.swift in Sources */, B4DFA5E7237A611D0013E17B /* AWSAPICategoryPlugin+InterceptorBehaviorTests.swift in Sources */, 6B33896E23AABEEE00561E5B /* MockReachability.swift in Sources */, diff --git a/AmplifyPlugins/API/AWSAPICategoryPlugin/Support/Utils/GraphQLResponseDecoder+DecodeError.swift b/AmplifyPlugins/API/AWSAPICategoryPlugin/Support/Utils/GraphQLResponseDecoder+DecodeError.swift new file mode 100644 index 0000000000..c3fa27855b --- /dev/null +++ b/AmplifyPlugins/API/AWSAPICategoryPlugin/Support/Utils/GraphQLResponseDecoder+DecodeError.swift @@ -0,0 +1,68 @@ +// +// Copyright 2018-2020 Amazon.com, +// Inc. or its affiliates. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Amplify + +extension GraphQLResponseDecoder { + + static func decodeErrors(graphQLErrors: [JSONValue]) throws -> [GraphQLError] { + var responseErrors = [GraphQLError]() + for error in graphQLErrors { + do { + let responseError = try decode(graphQLErrorJSON: error) + responseErrors.append(responseError) + } catch let decodingError as DecodingError { + throw APIError(error: decodingError) + } catch { + throw APIError.unknown(""" + Unexpected failure while decoding GraphQL response containing errors: + \(String(describing: graphQLErrors)) + """, "", error) + } + } + + return responseErrors + } + + static func decode(graphQLErrorJSON: JSONValue) throws -> GraphQLError { + let serializedJSON = try JSONEncoder().encode(graphQLErrorJSON) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = ModelDateFormatting.decodingStrategy + let graphQLError = try decoder.decode(GraphQLError.self, from: serializedJSON) + return mergeExtensions(from: graphQLErrorJSON, graphQLError: graphQLError) + } + + /// Merge fields which are not in the generic GraphQL error json over into the `GraphQLError.extensions` + /// This is the opinionated implementation of the plugin to store service errors which do not conform to the + /// GraphQL Error spec (https://spec.graphql.org/June2018/#sec-Errors) + private static func mergeExtensions(from graphQLErrorJSON: JSONValue, graphQLError: GraphQLError) -> GraphQLError { + var keys = ["message", "locations", "path", "extensions"] + var mergedExtensions = [String: JSONValue]() + if let graphQLErrorExtensions = graphQLError.extensions { + mergedExtensions = graphQLErrorExtensions + keys += mergedExtensions.keys + } + + guard case let .object(graphQLErrorObject) = graphQLErrorJSON else { + return graphQLError + } + + graphQLErrorObject.forEach { key, value in + if keys.contains(key) { + return + } + + mergedExtensions[key] = value + } + + return GraphQLError(message: graphQLError.message, + locations: graphQLError.locations, + path: graphQLError.path, + extensions: mergedExtensions.isEmpty ? nil : mergedExtensions) + } +} diff --git a/AmplifyPlugins/API/AWSAPICategoryPlugin/Support/Utils/GraphQLResponseDecoder.swift b/AmplifyPlugins/API/AWSAPICategoryPlugin/Support/Utils/GraphQLResponseDecoder.swift index 4413131620..1b8278cd18 100644 --- a/AmplifyPlugins/API/AWSAPICategoryPlugin/Support/Utils/GraphQLResponseDecoder.swift +++ b/AmplifyPlugins/API/AWSAPICategoryPlugin/Support/Utils/GraphQLResponseDecoder.swift @@ -165,29 +165,6 @@ struct GraphQLResponseDecoder { return try decoder.decode(responseType, from: serializedJSON) } - private static func decodeErrors(graphQLErrors: [JSONValue]) throws -> [GraphQLError] { - var responseErrors = [GraphQLError]() - for error in graphQLErrors { - do { - let responseError = try decode(graphQLError: error) - responseErrors.append(responseError) - } catch let decodingError as DecodingError { - throw APIError(error: decodingError) - } catch { - throw APIError.operationError("", "", error) - } - } - - return responseErrors - } - - private static func decode(graphQLError: JSONValue) throws -> GraphQLError { - let serializedJSON = try JSONEncoder().encode(graphQLError) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = ModelDateFormatting.decodingStrategy - return try decoder.decode(GraphQLError.self, from: serializedJSON) - } - private static func serialize(graphQLData: JSONValue, at decodePath: String?) throws -> Data { let modelJSON = try getModelJSONValue(from: graphQLData, at: decodePath) diff --git a/AmplifyPlugins/API/AWSAPICategoryPluginFunctionalTests/GraphQLSyncBased/GraphQLSyncBasedTests.swift b/AmplifyPlugins/API/AWSAPICategoryPluginFunctionalTests/GraphQLSyncBased/GraphQLSyncBasedTests.swift index 5bfe7d6a6b..bbd5d7ba61 100644 --- a/AmplifyPlugins/API/AWSAPICategoryPluginFunctionalTests/GraphQLSyncBased/GraphQLSyncBasedTests.swift +++ b/AmplifyPlugins/API/AWSAPICategoryPluginFunctionalTests/GraphQLSyncBased/GraphQLSyncBasedTests.swift @@ -12,6 +12,7 @@ import XCTest @testable import AWSAPICategoryPluginTestCommon import AWSPluginsCore +// swiftlint:disable type_body_length class GraphQLSyncBasedTests: XCTestCase { static let amplifyConfiguration = "GraphQLSyncBasedTests-amplifyconfiguration" @@ -280,11 +281,16 @@ class GraphQLSyncBasedTests: XCTestCase { case .failure(let error): switch error { case .error(let errors): - errors.forEach { error in - if error.message.contains("conditional request failed") { - conditionalFailedError.fulfill() - } + XCTAssertEqual(errors.count, 1) + guard let error = errors.first, + let extensions = error.extensions, + case let .string(errorTypeValue) = extensions["errorType"] else { + XCTFail("Failed to get errorType from extensions of the GraphQL error") + return } + let errorType = AppSyncErrorType(errorTypeValue) + XCTAssertEqual(errorType, .conditionalCheck) + conditionalFailedError.fulfill() case .partial(let model, let errors): XCTFail("partial: \(model), \(errors)") case .transformationError(let rawResponse, let apiError): @@ -295,6 +301,105 @@ class GraphQLSyncBasedTests: XCTestCase { wait(for: [conditionalFailedError], timeout: TestCommonConstants.networkTimeout) } + // Given: A newly created post + // When: Call update mutation, with updated title and version 1, twice + // Then: The first mutation is successful, and second returns conflict unhandled exception due to older version. + func testCreatePostThenUpdateTwiceWithConflictUnhandledException() throws { + let uuid = UUID().uuidString + let testMethodName = String("\(#function)".dropLast(2)) + let title = testMethodName + "Title" + let post = Post.keys + guard let createdPost = createPost(id: uuid, title: title) else { + XCTFail("Failed to create post with version 1") + return + } + let updatedTitle = title + "Updated" + let modifiedPost = Post(id: createdPost.model["id"] as? String ?? "", + title: updatedTitle, + content: createdPost.model["content"] as? String ?? "", + createdAt: Date()) + let firstUpdateSuccess = expectation(description: "first update mutation should be successful") + + let request = GraphQLRequest.updateMutation(of: modifiedPost, + version: 1) + _ = Amplify.API.mutate(request: request) { event in + switch event { + case .completed(let graphQLResponse): + firstUpdateSuccess.fulfill() + case .failed(let apiError): + XCTFail("\(apiError)") + default: + XCTFail("Could not get data back") + } + } + wait(for: [firstUpdateSuccess], timeout: TestCommonConstants.networkTimeout) + + var responseFromOperation: GraphQLResponse>? + let secondUpdateFailed = expectation( + description: "second update mutatiion request should failed with ConflictUnhandled errorType") + + _ = Amplify.API.mutate(request: request) { event in + defer { + secondUpdateFailed.fulfill() + } + switch event { + case .completed(let graphQLResponse): + responseFromOperation = graphQLResponse + case .failed(let apiError): + XCTFail("\(apiError)") + default: + XCTFail("Could not get data back") + } + } + wait(for: [secondUpdateFailed], timeout: TestCommonConstants.networkTimeout) + + guard let response = responseFromOperation else { + XCTAssertNotNil(responseFromOperation) + return + } + + let conflictUnhandledError = expectation(description: "error should be conditional request failed") + switch response { + case .success(let mutationSync): + XCTFail("success: \(mutationSync)") + case .failure(let error): + switch error { + case .error(let errors): + XCTAssertEqual(errors.count, 1) + guard let error = errors.first, let extensions = error.extensions else { + XCTFail("Failed to get extensions of the GraphQL error") + return + } + guard case let .string(errorTypeValue) = extensions["errorType"] else { + XCTFail("Missing errorType") + return + } + let errorType = AppSyncErrorType(errorTypeValue) + XCTAssertEqual(errorType, .conflictUnhandled) + + guard case let .object(dataObject) = extensions["data"] else { + XCTFail("Missing data") + return + } + + let serializedJSON = try JSONEncoder().encode(dataObject) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = ModelDateFormatting.decodingStrategy + let mutationSync = try decoder.decode(MutationSync.self, from: serializedJSON) + XCTAssertEqual(mutationSync.model.title, updatedTitle) + XCTAssertEqual(mutationSync.model.content, createdPost.model["content"] as? String) + XCTAssertEqual(mutationSync.syncMetadata.version, 2) + conflictUnhandledError.fulfill() + case .partial(let model, let errors): + XCTFail("partial: \(model), \(errors)") + case .transformationError(let rawResponse, let apiError): + XCTFail("transformationError: \(rawResponse), \(apiError)") + } + } + + wait(for: [conflictUnhandledError], timeout: TestCommonConstants.networkTimeout) + } + // Given: Two newly created posts // When: Call sync query with limit of 1, to ensure that we get a nextToken back // Then: The result should be a PaginatedList contain all fields populated (items, startedAt, nextToken) diff --git a/AmplifyPlugins/API/AWSAPICategoryPluginFunctionalTests/GraphQLSyncBased/README.md b/AmplifyPlugins/API/AWSAPICategoryPluginFunctionalTests/GraphQLSyncBased/README.md index 9c61026058..b016fed331 100644 --- a/AmplifyPlugins/API/AWSAPICategoryPluginFunctionalTests/GraphQLSyncBased/README.md +++ b/AmplifyPlugins/API/AWSAPICategoryPluginFunctionalTests/GraphQLSyncBased/README.md @@ -19,7 +19,7 @@ The following steps demonstrate how to set up an GraphQL endpoint with AppSync t ? Do you want to configure advanced settings for the GraphQL API `Yes, I want to make some additional changes.` ? Configure additional auth types? `No` ? Configure conflict detection? `Yes` -? Select the default resolution strategy `Auto Merge` +? Select the default resolution strategy `Optimistic Concurrency` ? Do you want to override default per model settings? `No` ? Do you have an annotated GraphQL schema? `Yes` ? Provide your schema file path: `schema.graphql` diff --git a/AmplifyPlugins/API/AWSAPICategoryPluginTests/Support/Utils/GraphQLResponseDecoderDecodeErrorTests.swift b/AmplifyPlugins/API/AWSAPICategoryPluginTests/Support/Utils/GraphQLResponseDecoderDecodeErrorTests.swift new file mode 100644 index 0000000000..9d9e35cb32 --- /dev/null +++ b/AmplifyPlugins/API/AWSAPICategoryPluginTests/Support/Utils/GraphQLResponseDecoderDecodeErrorTests.swift @@ -0,0 +1,118 @@ +// +// Copyright 2018-2020 Amazon.com, +// Inc. or its affiliates. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +import Amplify +@testable import AWSAPICategoryPlugin + +class GraphQLResponseDecoderDecodeErrorTests: XCTestCase { + + func testDecodeErrors() throws { + let graphQLErrorJSON: JSONValue = [ + "message": "Name for character with ID 1002 could not be fetched.", + "locations": [["line": 6, "column": 7]], + "path": ["hero", "heroFriends", 1, "name"] + ] + let graphQLErrorJSON2: JSONValue = [ + "message": "Name for character with ID 1002 could not be fetched.", + "locations": [["line": 6, "column": 7]], + "path": ["hero", "heroFriends", 1, "name"], + "extensions": [ + "code": "CAN_NOT_FETCH_BY_ID", + "timestamp": "Fri Feb 9 14:33:09 UTC 2018" + ] + ] + + let graphQLErrors = try GraphQLResponseDecoder.decodeErrors(graphQLErrors: [graphQLErrorJSON, graphQLErrorJSON2]) + + XCTAssertEqual(graphQLErrors.count, 2) + let result = graphQLErrors[0] + XCTAssertEqual(result.message, "Name for character with ID 1002 could not be fetched.") + XCTAssertNotNil(result.locations) + XCTAssertNotNil(result.path) + XCTAssertNil(result.extensions) + + let result2 = graphQLErrors[1] + XCTAssertEqual(result2.message, "Name for character with ID 1002 could not be fetched.") + XCTAssertNotNil(result2.locations) + XCTAssertNotNil(result2.path) + XCTAssertNotNil(result2.extensions) + } + + /// Decoding the graphQL error into `GraphQLError` will merge fields which do not meet the GraphQL spec for error + /// fields ("message", "locations", "path", and "extensions") will be merged into extensions, without overwriting + /// what is currently there + /// + /// - Given: GraphQL error JSON with extra fields ("errorInfo", "data", "errorType", "code"). "code" is duplicated + /// in extensions. + /// - When: + /// - Decode into `GraphQLError` + /// - Then: + /// - Extra fields are merged under `GraphQLError.extensions` without overwriting data, such as the "code" field + func testDecodeErrorWithExtensions() throws { + let graphQLErrorJSON: JSONValue = [ + "message": "Name for character with ID 1002 could not be fetched.", + "locations": [["line": 6, "column": 7]], + "path": ["hero", "heroFriends", 1, "name"], + "extensions": [ + "code": "CAN_NOT_FETCH_BY_ID", + "timestamp": "Fri Feb 9 14:33:09 UTC 2018" + ], + "errorInfo": nil, + "data": [ + "id": "EF48518C-92EB-4F7A-A64E-D1B9325205CF", + "title": "new3", + "content": "Original content from DataStoreEndToEndTests at 2020-03-26 21:55:47 +0000", + "_version": 2 + ], + "errorType": "ConflictUnhandled", + "code": 123 + ] + let graphQLErrors = try GraphQLResponseDecoder.decodeErrors(graphQLErrors: [graphQLErrorJSON]) + + XCTAssertEqual(graphQLErrors.count, 1) + let result = graphQLErrors[0] + XCTAssertEqual(result.message, "Name for character with ID 1002 could not be fetched.") + XCTAssertNotNil(result.locations) + XCTAssertNotNil(result.path) + guard let extensions = result.extensions else { + XCTFail("Missing extensions in result") + return + } + XCTAssertEqual(extensions.count, 5) + guard case let .string(code) = extensions["code"] else { + XCTFail("Missing code") + return + } + XCTAssertEqual(code, "CAN_NOT_FETCH_BY_ID") + guard case let .string(timeStamp) = extensions["timestamp"] else { + XCTFail("Missing timeStamp") + return + } + XCTAssertEqual(timeStamp, "Fri Feb 9 14:33:09 UTC 2018") + guard case .null = extensions["errorInfo"] else { + XCTFail("Missing errorInfo") + return + } + guard case let .object(data) = extensions["data"] else { + XCTFail("Missing data") + return + } + XCTAssertEqual(data["id"], "EF48518C-92EB-4F7A-A64E-D1B9325205CF") + XCTAssertEqual(data["title"], "new3") + XCTAssertEqual(data["content"], + "Original content from DataStoreEndToEndTests at 2020-03-26 21:55:47 +0000") + XCTAssertEqual(data["_version"], 2) + + guard case let .string(errorType) = extensions["errorType"] else { + XCTFail("Missing errorType") + return + } + + XCTAssertEqual(errorType, "ConflictUnhandled") + } +} diff --git a/AmplifyPlugins/Core/AWSPluginsCore/API/AppSyncErrorType.swift b/AmplifyPlugins/Core/AWSPluginsCore/API/AppSyncErrorType.swift new file mode 100644 index 0000000000..f9d0b3a2d1 --- /dev/null +++ b/AmplifyPlugins/Core/AWSPluginsCore/API/AppSyncErrorType.swift @@ -0,0 +1,45 @@ +// +// Copyright 2018-2020 Amazon.com, +// Inc. or its affiliates. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Common AppSync error types +public enum AppSyncErrorType: Equatable { + + private static let conditionalCheckFailedErrorString = "ConditionalCheckFailedException" + private static let conflictUnhandledErrorString = "ConflictUnhandled" + + /// Conflict detection finds a version mismatch and the conflict handler rejects the mutation. + /// See https://docs.aws.amazon.com/appsync/latest/devguide/conflict-detection-and-sync.html for more information + case conflictUnhandled + + case conditionalCheck + + case unknown(String) + + public init(_ value: String) { + switch value { + case AppSyncErrorType.conditionalCheckFailedErrorString: + self = .conditionalCheck + case AppSyncErrorType.conflictUnhandledErrorString: + self = .conflictUnhandled + default: + self = .unknown(value) + } + } + + public var rawValue: String { + switch self { + case .conditionalCheck: + return AppSyncErrorType.conditionalCheckFailedErrorString + case .conflictUnhandled: + return AppSyncErrorType.conflictUnhandledErrorString + case .unknown(let value): + return value + } + } +} diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/ProcessMutationErrorFromCloudOperation.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/ProcessMutationErrorFromCloudOperation.swift index 79181fb635..eb40fe7679 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/ProcessMutationErrorFromCloudOperation.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/ProcessMutationErrorFromCloudOperation.swift @@ -44,10 +44,13 @@ class ProcessMutationErrorFromCloudOperation: Operation { private func processConditionalRequestFailed() { if case let .error(graphQLErrors) = error { - // TODO: Check for 'ConflictUnhandled', execute conflict handler configurated + // TODO: Check for 'ConflictUnhandled', execute conflict handler let hasConditionalRequestFailed = graphQLErrors.contains { (error) -> Bool in - error.message.contains("conditional request failed") + if let extensions = error.extensions, case let .string(errorTypeValue) = extensions["errorType"] { + return AppSyncErrorType(errorTypeValue) == .conditionalCheck + } + return false } if hasConditionalRequestFailed { diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/ProcessMutationErrorFromCloudOperationTests.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/ProcessMutationErrorFromCloudOperationTests.swift index 91f8e94184..f5ae02725e 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/ProcessMutationErrorFromCloudOperationTests.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/ProcessMutationErrorFromCloudOperationTests.swift @@ -41,9 +41,9 @@ class ProcessMutationErrorFromCloudOperationTests: XCTestCase { let post1 = Post(title: "post1", content: "content1", createdAt: Date()) let mutationEvent = try MutationEvent(model: post1, mutationType: .create) let graphQLError = GraphQLError(message: "conditional request failed", - locations: nil, - path: nil, - extensions: nil) + locations: nil, + path: nil, + extensions: ["errorType": .string(AppSyncErrorType.conditionalCheck.rawValue)]) let graphQLResponseError = GraphQLResponseError>.error([graphQLError]) let operation = ProcessMutationErrorFromCloudOperation(mutationEvent: mutationEvent,