Skip to content

Commit

Permalink
feat: [API] Merge non-GraphQL spec error fields into GraphQLError.ext…
Browse files Browse the repository at this point in the history
…ensions
  • Loading branch information
lawmicha committed Apr 23, 2020
1 parent 365ab65 commit 042391e
Show file tree
Hide file tree
Showing 10 changed files with 323 additions and 32 deletions.
10 changes: 10 additions & 0 deletions Amplify/Categories/API/Response/GraphQLError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ public struct GraphQLError: Decodable {
/// Additional map of of errors
public let extensions: [String: JSONValue]?

public init(message: String, locations: [Location]?, path: [JSONValue]?, extensions: [String: JSONValue]?) {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -336,6 +338,8 @@
21D7A0D6237B54D90057D00D /* IAMURLRequestInterceptor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IAMURLRequestInterceptor.swift; sourceTree = "<group>"; };
21D7A0D7237B54D90057D00D /* AWSAPICategoryPluginError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AWSAPICategoryPluginError.swift; sourceTree = "<group>"; };
21D7A0DE237B54D90057D00D /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
21E2E2272451E66A007D7767 /* GraphQLResponseDecoder+DecodeError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GraphQLResponseDecoder+DecodeError.swift"; sourceTree = "<group>"; };
21E2E2292451E6B5007D7767 /* GraphQLResponseDecoderDecodeErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLResponseDecoderDecodeErrorTests.swift; sourceTree = "<group>"; };
21F40A2923A0423C0074678E /* GraphQLSyncBasedTests-amplifyconfiguration.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "GraphQLSyncBasedTests-amplifyconfiguration.json"; sourceTree = "<group>"; };
21F40A2D23A0707E0074678E /* GraphQLModelBasedTests-amplifyconfiguration.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "GraphQLModelBasedTests-amplifyconfiguration.json"; sourceTree = "<group>"; };
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 = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// 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 mergedExtensions = [String: JSONValue]()
if let graphQLErrorExtensions = graphQLError.extensions {
mergedExtensions = graphQLErrorExtensions
}

guard case let .object(graphQLErrorObject) = graphQLErrorJSON else {
return graphQLError
}

graphQLErrorObject.forEach { key, value in
if key == "message" ||
key == "locations" ||
key == "path" ||
key == "extensions" ||
mergedExtensions.keys.contains(key) {
return
}

mergedExtensions[key] = value
}

return GraphQLError(message: graphQLError.message,
locations: graphQLError.locations,
path: graphQLError.path,
extensions: !mergedExtensions.isEmpty ? mergedExtensions : nil)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import XCTest
@testable import AWSAPICategoryPluginTestCommon
import AWSPluginsCore

// swiftlint:disable type_body_length
class GraphQLSyncBasedTests: XCTestCase {

static let amplifyConfiguration = "GraphQLSyncBasedTests-amplifyconfiguration"
Expand Down Expand Up @@ -280,11 +281,15 @@ 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(errorType) = extensions["errorType"] else {
XCTFail("Failed to get errorType from extensions of the GraphQL error")
return
}
XCTAssertEqual(errorType, "ConditionalCheckFailedException")
conditionalFailedError.fulfill()
case .partial(let model, let errors):
XCTFail("partial: \(model), \(errors)")
case .transformationError(let rawResponse, let apiError):
Expand All @@ -295,6 +300,103 @@ 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<MutationSyncResult>.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<MutationSync<AnyModel>>?
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
}
XCTAssertEqual(errorTypeValue, "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<AmplifyTestCommon.Post>.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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading

0 comments on commit 042391e

Please sign in to comment.