diff --git a/packages/amplify_datastore/android/src/main/kotlin/com/amazonaws/amplify/amplify_datastore/pigeons/NativePluginBindings.kt b/packages/amplify_datastore/android/src/main/kotlin/com/amazonaws/amplify/amplify_datastore/pigeons/NativePluginBindings.kt index 9a832cdd1a..9dd0e1ee4e 100644 --- a/packages/amplify_datastore/android/src/main/kotlin/com/amazonaws/amplify/amplify_datastore/pigeons/NativePluginBindings.kt +++ b/packages/amplify_datastore/android/src/main/kotlin/com/amazonaws/amplify/amplify_datastore/pigeons/NativePluginBindings.kt @@ -158,22 +158,19 @@ data class NativeAWSCredentials ( /** Generated class from Pigeon that represents data sent in messages. */ data class NativeGraphQLResponse ( - val payloadJson: String? = null, - val errorsJson: String? = null + val payloadJson: String? = null ) { companion object { @Suppress("UNCHECKED_CAST") fun fromList(list: List): NativeGraphQLResponse { val payloadJson = list[0] as String? - val errorsJson = list[1] as String? - return NativeGraphQLResponse(payloadJson, errorsJson) + return NativeGraphQLResponse(payloadJson) } } fun toList(): List { return listOf( payloadJson, - errorsJson, ) } } @@ -210,7 +207,8 @@ data class NativeGraphQLRequest ( val variablesJson: String? = null, val responseType: String? = null, val decodePath: String? = null, - val options: String? = null + val options: String? = null, + val authMode: String? = null ) { companion object { @@ -222,7 +220,8 @@ data class NativeGraphQLRequest ( val responseType = list[3] as String? val decodePath = list[4] as String? val options = list[5] as String? - return NativeGraphQLRequest(document, apiName, variablesJson, responseType, decodePath, options) + val authMode = list[6] as String? + return NativeGraphQLRequest(document, apiName, variablesJson, responseType, decodePath, options, authMode) } } fun toList(): List { @@ -233,6 +232,7 @@ data class NativeGraphQLRequest ( responseType, decodePath, options, + authMode, ) } } diff --git a/packages/amplify_datastore/example/ios/unit_tests/GraphQLResponse+DecodeTests.swift b/packages/amplify_datastore/example/ios/unit_tests/GraphQLResponse+DecodeTests.swift index 3c5d1071af..a08f53f8c9 100644 --- a/packages/amplify_datastore/example/ios/unit_tests/GraphQLResponse+DecodeTests.swift +++ b/packages/amplify_datastore/example/ios/unit_tests/GraphQLResponse+DecodeTests.swift @@ -116,7 +116,7 @@ class GraphQLResponseDecodeTests: XCTestCase { "a": ["b"] ] let expectedJson = "{\"a\":[\"b\"]}" - let result: Result = GraphQLResponse.decodeDataPayload(json, modelName: nil) + let result: Result = GraphQLResponse.decodeDataPayload(json, modelName: "Post") XCTAssertNoThrow(try result.get()) XCTAssertEqual(expectedJson, try result.get()) } @@ -137,7 +137,7 @@ class GraphQLResponseDecodeTests: XCTestCase { "author": "authorId", ], modelName: "Post")) - let result: Result = GraphQLResponse.decodeDataPayload(json, modelName: nil) + let result: Result = GraphQLResponse.decodeDataPayload(json, modelName: "Post") XCTAssertNoThrow(try result.get()) XCTAssertEqual(expectedModel.modelName, try result.get().modelName) XCTAssertEqual(expectedModel.id, try result.get().id) @@ -209,14 +209,16 @@ class GraphQLResponseDecodeTests: XCTestCase { func testFromAppSyncResponse_withOnlyData_decodePayload() { SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) let json: JSONValue = [ - "onCreatePost": [ - "__typename": "Post", - "id": "123", - "title": "test", - "author": "authorId", - "_version": 1, - "_deleted": nil, - "_lastChangedAt": 1707773705221 + "data": [ + "onCreatePost": [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + "_version": 1, + "_deleted": nil, + "_lastChangedAt": 1707773705221 + ] ] ] @@ -227,7 +229,11 @@ class GraphQLResponseDecodeTests: XCTestCase { "author": "authorId", ], modelName: "Post")) - let result: Result>, APIError> = .fromAppSyncResponse(json: json, decodePath: "onCreatePost", modelName: nil) + let result: Result>, APIError> = .fromAppSyncResponse( + json: json, + decodePath: "onCreatePost", + modelName: "Post" + ) XCTAssertNoThrow(try result.get()) let mutationSync = try! result.get() XCTAssertNoThrow(try mutationSync.get()) @@ -244,7 +250,11 @@ class GraphQLResponseDecodeTests: XCTestCase { ] ] - let result: Result>, APIError> = .fromAppSyncResponse(json: json, decodePath: "onCreatePost", modelName: nil) + let result: Result>, APIError> = .fromAppSyncResponse( + json: json, + decodePath: "onCreatePost", + modelName: "Post" + ) XCTAssertNoThrow(try result.get()) let mutationSync = try! result.get() XCTAssertThrowsError(try mutationSync.get()) { error in @@ -261,14 +271,16 @@ class GraphQLResponseDecodeTests: XCTestCase { func testFromAppSyncResponse_withDataAndErrors_decodePayload() { SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) let json: JSONValue = [ - "onCreatePost": [ - "__typename": "Post", - "id": "123", - "title": "test", - "author": "authorId", - "_version": 1, - "_deleted": nil, - "_lastChangedAt": 1707773705221 + "data": [ + "onCreatePost": [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + "_version": 1, + "_deleted": nil, + "_lastChangedAt": 1707773705221 + ], ], "errors": [ ["message": "error1"], @@ -283,7 +295,11 @@ class GraphQLResponseDecodeTests: XCTestCase { "author": "authorId", ], modelName: "Post")) - let result: Result>, APIError> = .fromAppSyncResponse(json: json, decodePath: "onCreatePost", modelName: nil) + let result: Result>, APIError> = .fromAppSyncResponse( + json: json, + decodePath: "onCreatePost", + modelName: "Post" + ) XCTAssertNoThrow(try result.get()) let mutationSync = try! result.get() XCTAssertThrowsError(try mutationSync.get()) { error in @@ -305,7 +321,11 @@ class GraphQLResponseDecodeTests: XCTestCase { "a": "b" ] - let result: Result>, APIError> = .fromAppSyncResponse(json: json, decodePath: "onCreatePost", modelName: nil) + let result: Result>, APIError> = .fromAppSyncResponse( + json: json, + decodePath: "onCreatePost", + modelName: "Post" + ) XCTAssertThrowsError(try result.get()) { error in if case .unknown(let description, _, _) = (error as! APIError) { XCTAssertEqual("Failed to get data object or errors from GraphQL response", description) @@ -325,7 +345,7 @@ class GraphQLResponseDecodeTests: XCTestCase { ] let jsonString = String(data: try! JSONEncoder().encode(json), encoding: .utf8)! - let response: GraphQLResponse = .fromAppSyncResponse(string: jsonString, decodePath: nil) + let response: GraphQLResponse = .fromAppSyncResponse(string: jsonString, decodePath: nil, modelName: "Post") XCTAssertNoThrow(try response.get()) XCTAssertEqual(json.id?.stringValue, try response.get().identifier) XCTAssertEqual(json.__typename?.stringValue, try response.get().modelName) @@ -334,7 +354,7 @@ class GraphQLResponseDecodeTests: XCTestCase { func testFromAppSyncResponse_withBrokenJsonString_failWithTransformationError() { SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) let jsonString = "{" - let response: GraphQLResponse = .fromAppSyncResponse(string: jsonString, decodePath: nil) + let response: GraphQLResponse = .fromAppSyncResponse(string: jsonString, decodePath: nil, modelName: "Post") XCTAssertThrowsError(try response.get()) { error in guard case .transformationError = error as! GraphQLResponseError else { XCTFail("Should failed with transformationError") @@ -346,29 +366,39 @@ class GraphQLResponseDecodeTests: XCTestCase { func testFromAppSyncSubscriptionResponse_withJsonString_decodeCorrectly() { SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) let payloadJson: JSONValue = [ - "onCreatePost": [ - "__typename": "Post", - "id": "123", - "title": "test", - "author": "authorId", - "_version": 1, - "_deleted": nil, - "_lastChangedAt": 1707773705221 + "data": [ + "onCreatePost": [ + "__typename": "Post", + "id": "123", + "title": "test", + "author": "authorId", + "_version": 1, + "_deleted": nil, + "_lastChangedAt": 1707773705221 + ] ] ] let jsonString = String(data: try! JSONEncoder().encode(payloadJson), encoding: .utf8)! - let response: GraphQLResponse> = .fromAppSyncResponse(string: jsonString, decodePath: "onCreatePost") + let response: GraphQLResponse> = .fromAppSyncResponse( + string: jsonString, + decodePath: "onCreatePost", + modelName: "Post" + ) XCTAssertNoThrow(try response.get()) let mutationSync = try! response.get() - XCTAssertEqual(payloadJson.onCreatePost?.id?.stringValue, mutationSync.model.identifier) - XCTAssertEqual(payloadJson.onCreatePost?.__typename?.stringValue, mutationSync.model.modelName) + XCTAssertEqual(payloadJson.data?.onCreatePost?.id?.stringValue, mutationSync.model.identifier) + XCTAssertEqual(payloadJson.data?.onCreatePost?.__typename?.stringValue, mutationSync.model.modelName) } func testFromAppSyncSubscriptionResponse_withWrongJsonString_failWithTransformationError() { SchemaData.modelSchemaRegistry.registerModels(registry: ModelRegistry.self) let jsonString = "{" - let response: GraphQLResponse> = .fromAppSyncSubscriptionResponse(string: jsonString, decodePath: nil) + let response: GraphQLResponse> = .fromAppSyncSubscriptionResponse( + string: jsonString, + decodePath: nil, + modelName: "Post" + ) XCTAssertThrowsError(try response.get()) { error in guard case .transformationError = error as! GraphQLResponseError> else { XCTFail("Should failed with transformationError") diff --git a/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift b/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift index bc447bc77e..bd3fa4c122 100644 --- a/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift +++ b/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift @@ -2,15 +2,13 @@ import Foundation import Flutter import Combine - - public class FlutterApiPlugin: APICategoryPlugin { public var key: PluginKey = "awsAPIPlugin" private let apiAuthFactory: APIAuthProviderFactory private let nativeApiPlugin: NativeApiPlugin private let nativeSubscriptionEvents: PassthroughSubject - private var cancellables = Set() + private var cancellables = AtomicDictionary() init( apiAuthProviderFactory: APIAuthProviderFactory, @@ -24,6 +22,7 @@ public class FlutterApiPlugin: APICategoryPlugin public func query(request: GraphQLRequest) async throws -> GraphQLTask.Success where R : Decodable { let response = await asyncQuery(nativeRequest: request.toNativeGraphQLRequest()) + return try decodeGraphQLPayloadJson(request: request, payload: response.payloadJson) } @@ -49,6 +48,7 @@ public class FlutterApiPlugin: APICategoryPlugin // TODO: shouldn't there be a timeout if there is no start_ack returned in a certain period of time let (sequence, cancellable) = nativeSubscriptionEvents + .setFailureType(to: Error.self) .receive(on: DispatchQueue.global()) .filter { $0.subscriptionId == subscriptionId } .handleEvents(receiveCompletion: {_ in @@ -81,8 +81,19 @@ public class FlutterApiPlugin: APICategoryPlugin return nil } } + .flatMap { (event: GraphQLSubscriptionEvent) -> AnyPublisher, Error> in + if case .data(.failure(let graphQLResponseError)) = event, + case .error(let errors) = graphQLResponseError, + errors.contains(where: self.isUnauthorizedError(graphQLError:)) { + return Fail(error: APIError.operationError("Unauthorized", "", nil)).eraseToAnyPublisher() + } + return Just(event).setFailureType(to: Error.self).eraseToAnyPublisher() + } + .eraseToAnyPublisher() .toAmplifyAsyncThrowingSequence() - cancellables.insert(cancellable) // the subscription is bind with class instance lifecycle, it should be released when stream is finished or unsubscribed + + cancellables.set(value: (), forKey: cancellable) // the subscription is bind with class instance lifecycle, it should be released when stream is finished or unsubscribed + sequence.send(.connection(.connecting)) DispatchQueue.main.async { self.nativeApiPlugin.subscribe(request: request.toNativeGraphQLRequest()) { response in @@ -99,10 +110,15 @@ public class FlutterApiPlugin: APICategoryPlugin guard let payload else { throw DataStoreError.decodingError("Request payload could not be empty", "") } + + guard let datastoreOptions = request.options?.pluginOptions as? AWSAPIPluginDataStoreOptions else { + throw DataStoreError.decodingError("Failed to decode the GraphQLRequest due to a missing options field.", "") + } return GraphQLResponse.fromAppSyncResponse( string: payload, - decodePath: request.decodePath + decodePath: request.decodePath, + modelName: datastoreOptions.modelName ) } @@ -113,13 +129,25 @@ public class FlutterApiPlugin: APICategoryPlugin guard let payload else { throw DataStoreError.decodingError("Request payload could not be empty", "") } + + guard let datastoreOptions = request.options?.pluginOptions as? AWSAPIPluginDataStoreOptions else { + throw DataStoreError.decodingError("Failed to decode the GraphQLRequest due to a missing options field.", "") + } return GraphQLResponse.fromAppSyncSubscriptionResponse( string: payload, - decodePath: request.decodePath + decodePath: request.decodePath, + modelName: datastoreOptions.modelName ) } - + + private func isUnauthorizedError(graphQLError: GraphQLError) -> Bool { + guard case let .string(errorTypeValue) = graphQLError.extensions?["errorType"] else { + return false + } + return errorTypeValue == "Unauthorized" + } + func asyncQuery(nativeRequest: NativeGraphQLRequest) async -> NativeGraphQLResponse { await withCheckedContinuation { continuation in DispatchQueue.main.async { diff --git a/packages/amplify_datastore/ios/Classes/api/GraphQLRequest+Extension.swift b/packages/amplify_datastore/ios/Classes/api/GraphQLRequest+Extension.swift index 9aedf6fc22..dd5b23da39 100644 --- a/packages/amplify_datastore/ios/Classes/api/GraphQLRequest+Extension.swift +++ b/packages/amplify_datastore/ios/Classes/api/GraphQLRequest+Extension.swift @@ -13,13 +13,16 @@ extension GraphQLRequest { let variablesJson = self.variables .flatMap { try? JSONSerialization.data(withJSONObject: $0, options: []) } .flatMap { String(data: $0, encoding: .utf8) } + + let datastoreOptions = self.options?.pluginOptions as? AWSAPIPluginDataStoreOptions return NativeGraphQLRequest( document: self.document, apiName: self.apiName, variablesJson: variablesJson ?? "{}", responseType: String(describing: self.responseType), - decodePath: self.decodePath + decodePath: self.decodePath, + authMode: datastoreOptions?.authType.map { String(describing: $0) } ) } } diff --git a/packages/amplify_datastore/ios/Classes/api/GraphQLResponse+Decode.swift b/packages/amplify_datastore/ios/Classes/api/GraphQLResponse+Decode.swift index ebc8a68819..289fa1d2e6 100644 --- a/packages/amplify_datastore/ios/Classes/api/GraphQLResponse+Decode.swift +++ b/packages/amplify_datastore/ios/Classes/api/GraphQLResponse+Decode.swift @@ -22,7 +22,7 @@ extension GraphQLResponse { public static func fromAppSyncResponse( string: String, decodePath: String?, - modelName: String? = nil + modelName: String ) -> GraphQLResponse { guard let data = string.data(using: .utf8) else { return .failure(.transformationError( @@ -44,7 +44,7 @@ extension GraphQLResponse { public static func fromAppSyncSubscriptionResponse( string: String, decodePath: String?, - modelName: String? = nil + modelName: String ) -> GraphQLResponse { guard let data = string.data(using: .utf8) else { return .failure(.transformationError( @@ -98,10 +98,12 @@ extension GraphQLResponse { static func fromAppSyncResponse( json: JSONValue, decodePath: String?, - modelName: String? + modelName: String ) -> Result, APIError> { - let data = decodePath != nil ? json.value(at: decodePath!) : json - let errors = json.errors?.asArray + var data = decodePath != nil ? json.data?.value(at: decodePath!) : json + let errorsArray = json.errors?.asArray + let errors = errorsArray.isEmpty ? nil : errorsArray + data = data?.isNull == true ? nil : data switch (data, errors) { case (.some(let data), .none): return decodeDataPayload(data, modelName: modelName).map { .success($0) } @@ -128,11 +130,18 @@ extension GraphQLResponse { return nil } - let extensions = errorObject.enumerated().filter { !["message", "locations", "path", "extensions"].contains($0.element.key) } + var extensions = errorObject.enumerated().filter { !["message", "locations", "path", "extensions"].contains($0.element.key) } .reduce([String: JSONValue]()) { partialResult, item in partialResult.merging([item.element.key: item.element.value]) { $1 } } + if error.message?.stringValue?.contains("Unauthorized") == true { + extensions = extensions.merging( + ["errorType": "Unauthorized"], + uniquingKeysWith: { _, a in a } + ) + } + return (try? jsonEncoder.encode(error)) .flatMap { try? jsonDecoder.decode(GraphQLError.self, from: $0) } .map { @@ -147,17 +156,18 @@ extension GraphQLResponse { static func decodeDataPayload( _ dataPayload: JSONValue, - modelName: String? + modelName: String ) -> Result { if R.self == String.self { return encodeDataPayloadToString(dataPayload).map { $0 as! R } } - - let dataPayloadWithTypeName = modelName.flatMap { - dataPayload.asObject?.merging( - ["__typename": .string($0)] - ) { a, _ in a } - }.map { JSONValue.object($0) } ?? dataPayload + + /// This allows multi-platform support. Not all platform requests include `__typename` + /// in the selection set. This adds it to the response based on the model name for proper decoding. + let dataPayloadWithTypeName = (dataPayload.asObject?.merging( + ["__typename": .string(modelName)], + uniquingKeysWith: { a, _ in a } + )).map { JSONValue.object($0) } ?? dataPayload if R.self == AnyModel.self { return decodeDataPayloadToAnyModel(dataPayloadWithTypeName).map { $0 as! R } @@ -174,16 +184,16 @@ extension GraphQLResponse { _ dataPayload: JSONValue ) -> Result { guard let typeName = dataPayload.__typename?.stringValue else { - return .failure(.operationError( - "Could not retrieve __typename from object", - """ - Could not retrieve the `__typename` attribute from the return value. Be sure to include __typename in \ - the selection set of the GraphQL operation. GraphQL: - \(dataPayload) - """ - )) - } - + return .failure(.operationError( + "Could not retrieve __typename from object", + """ + Could not retrieve the `__typename` attribute from the return value. Be sure to include __typename in \ + the selection set of the GraphQL operation. GraphQL: + \(dataPayload) + """ + )) + } + return encodeDataPayloadToString(dataPayload).flatMap { underlyingModelString in do { return .success(.init(try ModelRegistry.decode( diff --git a/packages/amplify_datastore/ios/Classes/pigeons/NativePluginBindings.swift b/packages/amplify_datastore/ios/Classes/pigeons/NativePluginBindings.swift index be5850a1a8..a619cbb998 100644 --- a/packages/amplify_datastore/ios/Classes/pigeons/NativePluginBindings.swift +++ b/packages/amplify_datastore/ios/Classes/pigeons/NativePluginBindings.swift @@ -162,21 +162,17 @@ struct NativeAWSCredentials { /// Generated class from Pigeon that represents data sent in messages. struct NativeGraphQLResponse { var payloadJson: String? = nil - var errorsJson: String? = nil static func fromList(_ list: [Any?]) -> NativeGraphQLResponse? { let payloadJson: String? = nilOrValue(list[0]) - let errorsJson: String? = nilOrValue(list[1]) return NativeGraphQLResponse( - payloadJson: payloadJson, - errorsJson: errorsJson + payloadJson: payloadJson ) } func toList() -> [Any?] { return [ payloadJson, - errorsJson, ] } } @@ -215,6 +211,7 @@ struct NativeGraphQLRequest { var responseType: String? = nil var decodePath: String? = nil var options: String? = nil + var authMode: String? = nil static func fromList(_ list: [Any?]) -> NativeGraphQLRequest? { let document = list[0] as! String @@ -223,6 +220,7 @@ struct NativeGraphQLRequest { let responseType: String? = nilOrValue(list[3]) let decodePath: String? = nilOrValue(list[4]) let options: String? = nilOrValue(list[5]) + let authMode: String? = nilOrValue(list[6]) return NativeGraphQLRequest( document: document, @@ -230,7 +228,8 @@ struct NativeGraphQLRequest { variablesJson: variablesJson, responseType: responseType, decodePath: decodePath, - options: options + options: options, + authMode: authMode ) } func toList() -> [Any?] { @@ -241,6 +240,7 @@ struct NativeGraphQLRequest { responseType, decodePath, options, + authMode, ] } } diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventToAnyModelMapper.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventToAnyModelMapper.swift index cf33218e63..3c28e60aee 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventToAnyModelMapper.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventToAnyModelMapper.swift @@ -81,7 +81,7 @@ final class IncomingAsyncSubscriptionEventToAnyModelMapper: Subscriber, AmplifyC case .success(let mutationSync): modelsFromSubscription.send(.payload(mutationSync)) case .failure(let failure): - log.error(error: failure) + log.error(failure.errorDescription) } } diff --git a/packages/amplify_datastore/lib/amplify_datastore.dart b/packages/amplify_datastore/lib/amplify_datastore.dart index 40c2ad1579..fa58a61dca 100644 --- a/packages/amplify_datastore/lib/amplify_datastore.dart +++ b/packages/amplify_datastore/lib/amplify_datastore.dart @@ -321,16 +321,26 @@ class _NativeAmplifyApi @override Future mutate(NativeGraphQLRequest request) async { - final flutterRequest = nativeRequestToGraphQLRequest(request); - final response = await Amplify.API.mutate(request: flutterRequest).response; - return graphQLResponseToNativeResponse(response); + try { + final flutterRequest = nativeRequestToGraphQLRequest(request); + final response = + await Amplify.API.mutate(request: flutterRequest).response; + return graphQLResponseToNativeResponse(response); + } on Exception catch (e) { + return handleGraphQLOperationException(e, request); + } } @override Future query(NativeGraphQLRequest request) async { - final flutterRequest = nativeRequestToGraphQLRequest(request); - final response = await Amplify.API.query(request: flutterRequest).response; - return graphQLResponseToNativeResponse(response); + try { + final flutterRequest = nativeRequestToGraphQLRequest(request); + final response = + await Amplify.API.query(request: flutterRequest).response; + return graphQLResponseToNativeResponse(response); + } on Exception catch (e) { + return handleGraphQLOperationException(e, request); + } } @override @@ -343,11 +353,9 @@ class _NativeAmplifyApi final subscription = operation.listen( (GraphQLResponse event) => - sendNativeDataEvent(flutterRequest.id, event.data), - onError: (error) { - // TODO(equartey): verify that error.toString() is the correct payload format. Should match AppSync - final errorPayload = error.toString(); - sendNativeErrorEvent(flutterRequest.id, errorPayload); + sendSubscriptionEvent(flutterRequest.id, event), + onError: (Object error) { + sendSubscriptionStreamErrorEvent(flutterRequest.id, error); }, onDone: () => sendNativeCompleteEvent(flutterRequest.id)); diff --git a/packages/amplify_datastore/lib/src/native_plugin.g.dart b/packages/amplify_datastore/lib/src/native_plugin.g.dart index f06960829b..327c28c658 100644 --- a/packages/amplify_datastore/lib/src/native_plugin.g.dart +++ b/packages/amplify_datastore/lib/src/native_plugin.g.dart @@ -152,17 +152,13 @@ class NativeAWSCredentials { class NativeGraphQLResponse { NativeGraphQLResponse({ this.payloadJson, - this.errorsJson, }); String? payloadJson; - String? errorsJson; - Object encode() { return [ payloadJson, - errorsJson, ]; } @@ -170,7 +166,6 @@ class NativeGraphQLResponse { result as List; return NativeGraphQLResponse( payloadJson: result[0] as String?, - errorsJson: result[1] as String?, ); } } @@ -214,6 +209,7 @@ class NativeGraphQLRequest { this.responseType, this.decodePath, this.options, + this.authMode, }); String document; @@ -228,6 +224,8 @@ class NativeGraphQLRequest { String? options; + String? authMode; + Object encode() { return [ document, @@ -236,6 +234,7 @@ class NativeGraphQLRequest { responseType, decodePath, options, + authMode, ]; } @@ -248,6 +247,7 @@ class NativeGraphQLRequest { responseType: result[3] as String?, decodePath: result[4] as String?, options: result[5] as String?, + authMode: result[6] as String?, ); } } diff --git a/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart b/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart index f283f4a435..b59aceb856 100644 --- a/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart +++ b/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart @@ -11,18 +11,97 @@ GraphQLRequest nativeRequestToGraphQLRequest( document: request.document, variables: jsonDecode(request.variablesJson ?? '{}'), apiName: request.apiName, + authorizationMode: nativeToApiAuthorizationType(request.authMode), ); } +/// Converts the Amplify Swift type [AWSAuthorizationType.value] to [APIAuthorizationType] +APIAuthorizationType? nativeToApiAuthorizationType(String? authMode) { + switch (authMode) { + case 'apiKey': + return APIAuthorizationType.apiKey; + case 'awsIAM': + return APIAuthorizationType.iam; + case 'openIDConnect': + return APIAuthorizationType.oidc; + case 'amazonCognitoUserPools': + return APIAuthorizationType.userPools; + case 'function': + return APIAuthorizationType.function; + case 'none': + return APIAuthorizationType.none; + default: + return null; + } +} + +// TODO(equartey): Migrate string matching to use status codes when available on +// exceptions to more closely match the behavior of Amplify Swift. +// In addition to Unauthorized errors, Amplify Swift checks for status codes 401 & 403 for silent failures. +// https://github.com/aws-amplify/amplify-swift/blob/8534d75277701bb6cb9844cf66d1e2ef2a78c37e/AmplifyPlugins/API/Sources/AWSAPIPlugin/APIError%2BUnauthorized.swift#L41 +// +/// Transform a exception to an error payload json. +/// And tag the error to fail silently, allowing DataStore sync to continue. +String _transformExceptionToErrorPayloadJson(Object e) { + final _silentFailExceptions = ["SignedOutException"]; + + Map error = { + 'message': "${e.toString()}", + }; + if (e is AmplifyException) { + final isUnAuthorized = _silentFailExceptions + .any((x) => e.underlyingException?.toString().contains(x) ?? false); + // preface the error message with "Unauthorized" if the exception should be silent + error['message'] = isUnAuthorized + ? "Unauthorized - ${e.message} - ${e.underlyingException}" + : error['message']; + } + var errorPayload = { + 'errors': [error] + }; + return jsonEncode(errorPayload); +} + +/// Handle GraphQL operation Exceptions and return a [NativeGraphQLResponse] +NativeGraphQLResponse handleGraphQLOperationException( + Exception e, NativeGraphQLRequest request) { + final errorPayload = _transformExceptionToErrorPayloadJson(e); + return NativeGraphQLResponse(payloadJson: errorPayload); +} + /// Convert a [GraphQLResponse] to a [NativeGraphQLResponse] NativeGraphQLResponse graphQLResponseToNativeResponse( GraphQLResponse response) { - final errorJson = jsonEncode( - response.errors.whereNotNull().map((e) => e.toJson()).toList()); - return NativeGraphQLResponse( - payloadJson: response.data, - errorsJson: errorJson, - ); + var payload = ""; + try { + payload = _buildPayloadJson(response); + } on Exception catch (e) { + payload = _handlePayloadException(e); + } + return NativeGraphQLResponse(payloadJson: payload); +} + +/// Build payloadJson for a [NativeGraphQLResponse] and [NativeGraphQLSubscriptionResponse] +/// from a [GraphQLResponse] +String _buildPayloadJson(GraphQLResponse response) { + final data = jsonDecode(response.data ?? '{}'); + final errors = response.errors.whereNotNull().map((e) => e.toJson()).toList(); + return jsonEncode({ + 'data': data, + 'errors': errors, + }); +} + +/// Handle payload json parsing exceptions +String _handlePayloadException(Exception e) { + return jsonEncode({ + 'data': {}, + 'errors': [ + { + 'message': 'Error parsing payload json: ${e.toString()}', + } + ], + }); } /// Returns a connecting event [NativeGraphQLResponse] for the given [subscriptionId] @@ -48,18 +127,31 @@ void sendNativeStartAckEvent(String subscriptionId) { _sendSubscriptionEvent(event); } -/// Send a data event for the given [subscriptionId] and [payloadJson] -void sendNativeDataEvent(String subscriptionId, String? payloadJson) { +/// Send a subscription event for the given [subscriptionId] and [GraphQLResponse] +/// If the response has errors, the event type will be `error`, otherwise `data` +void sendSubscriptionEvent( + String subscriptionId, GraphQLResponse response) { + var payload = ""; + var hasErrors = response.hasErrors; + + try { + payload = _buildPayloadJson(response); + } on Exception catch (e) { + payload = _handlePayloadException(e); + hasErrors = true; + } + final event = NativeGraphQLSubscriptionResponse( subscriptionId: subscriptionId, - payloadJson: payloadJson, - type: 'data', + payloadJson: payload, + type: hasErrors ? 'error' : "data", ); _sendSubscriptionEvent(event); } /// Send an error event for the given [subscriptionId] and [errorPayload] -void sendNativeErrorEvent(String subscriptionId, String errorPayload) { +void sendSubscriptionStreamErrorEvent(String subscriptionId, Object e) { + final errorPayload = _transformExceptionToErrorPayloadJson(e); final event = NativeGraphQLSubscriptionResponse( subscriptionId: subscriptionId, payloadJson: errorPayload, diff --git a/packages/amplify_datastore/pigeons/native_plugin.dart b/packages/amplify_datastore/pigeons/native_plugin.dart index 25584aa1ce..d44a729c8f 100644 --- a/packages/amplify_datastore/pigeons/native_plugin.dart +++ b/packages/amplify_datastore/pigeons/native_plugin.dart @@ -110,7 +110,6 @@ class LegacyCredentialStoreData { class NativeGraphQLResponse { String? payloadJson; - String? errorsJson; } class NativeGraphQLSubscriptionResponse { @@ -126,4 +125,5 @@ class NativeGraphQLRequest { String? responseType; String? decodePath; String? options; + String? authMode; }