diff --git a/Amplify/Categories/API/Operation/RetryableGraphQLOperation.swift b/Amplify/Categories/API/Operation/RetryableGraphQLOperation.swift index ed2a6e2753..f1f3e0b547 100644 --- a/Amplify/Categories/API/Operation/RetryableGraphQLOperation.swift +++ b/Amplify/Categories/API/Operation/RetryableGraphQLOperation.swift @@ -12,6 +12,7 @@ public protocol AnyGraphQLOperation { associatedtype Success associatedtype Failure: Error typealias ResultListener = (Result) -> Void + typealias ErrorListener = (Failure) -> Void } /// Abastraction for a retryable GraphQLOperation. @@ -24,6 +25,7 @@ public protocol RetryableGraphQLOperationBehavior: Operation, DefaultLogger { typealias RequestFactory = () async -> GraphQLRequest typealias OperationFactory = (GraphQLRequest, @escaping OperationResultListener) -> OperationType typealias OperationResultListener = OperationType.ResultListener + typealias OperationErrorListener = OperationType.ErrorListener /// Operation unique identifier var id: UUID { get } @@ -45,9 +47,12 @@ public protocol RetryableGraphQLOperationBehavior: Operation, DefaultLogger { var operationFactory: OperationFactory { get } var resultListener: OperationResultListener { get } + + var errorListener: OperationErrorListener { get } init(requestFactory: @escaping RequestFactory, maxRetries: Int, + errorListener: @escaping OperationErrorListener, resultListener: @escaping OperationResultListener, _ operationFactory: @escaping OperationFactory) @@ -71,6 +76,11 @@ extension RetryableGraphQLOperationBehavior { attempts += 1 log.debug("[\(id)] - Try [\(attempts)/\(maxRetries)]") let wrappedResultListener: OperationResultListener = { result in + if case let .failure(error) = result { + // Give an operation a chance to prepare itself for a retry after a failure + self.errorListener(error) + } + if case let .failure(error) = result, self.shouldRetry(error: error as? APIError) { self.log.debug("\(error)") Task { @@ -103,17 +113,20 @@ public final class RetryableGraphQLOperation: Operation, Ret public var attempts: Int = 0 public var requestFactory: RequestFactory public var underlyingOperation: AtomicValue?> = AtomicValue(initialValue: nil) + public var errorListener: OperationErrorListener public var resultListener: OperationResultListener public var operationFactory: OperationFactory public init(requestFactory: @escaping RequestFactory, maxRetries: Int, + errorListener: @escaping OperationErrorListener, resultListener: @escaping OperationResultListener, _ operationFactory: @escaping OperationFactory) { self.id = UUID() self.maxRetries = max(1, maxRetries) self.requestFactory = requestFactory self.operationFactory = operationFactory + self.errorListener = errorListener self.resultListener = resultListener } @@ -154,17 +167,21 @@ public final class RetryableGraphQLSubscriptionOperation: Op public var attempts: Int = 0 public var underlyingOperation: AtomicValue?> = AtomicValue(initialValue: nil) public var requestFactory: RequestFactory + public var errorListener: OperationErrorListener public var resultListener: OperationResultListener public var operationFactory: OperationFactory - + private var retriedRTFErrors: [RTFError: Bool] = [:] + public init(requestFactory: @escaping RequestFactory, maxRetries: Int, + errorListener: @escaping OperationErrorListener, resultListener: @escaping OperationResultListener, _ operationFactory: @escaping OperationFactory) { self.id = UUID() self.maxRetries = max(1, maxRetries) self.requestFactory = requestFactory self.operationFactory = operationFactory + self.errorListener = errorListener self.resultListener = resultListener } public override func main() { @@ -178,9 +195,35 @@ public final class RetryableGraphQLSubscriptionOperation: Op } public func shouldRetry(error: APIError?) -> Bool { - return attempts < maxRetries - } + guard case let .operationError(_, _, underlyingError) = error else { + return false + } + + if let authError = underlyingError as? AuthError { + switch authError { + case .signedOut, .notAuthorized: + return attempts < maxRetries + default: + return false + } + } + if let rtfError = RTFError(description: error.debugDescription) { + + // Do not retry the same RTF error more than once + guard retriedRTFErrors[rtfError] == nil else { return false } + retriedRTFErrors[rtfError] = true + + // maxRetries represent the number of auth types to attempt. + // (maxRetries is set to the number of auth types to attempt in multi-auth rules scenarios) + // Increment by 1 to account for that as this is not a "change auth" retry attempt + maxRetries += 1 + + return true + } + + return false + } } // MARK: GraphQLOperation - GraphQLSubscriptionOperation + AnyGraphQLOperation diff --git a/Amplify/Categories/DataStore/Subscribe/DataStoreSubscriptionRTFError.swift b/Amplify/Categories/DataStore/Subscribe/DataStoreSubscriptionRTFError.swift new file mode 100644 index 0000000000..d3ef978de1 --- /dev/null +++ b/Amplify/Categories/DataStore/Subscribe/DataStoreSubscriptionRTFError.swift @@ -0,0 +1,45 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public enum RTFError: CaseIterable { + case unknownField + case maxAttributes + case maxCombinations + case repeatedFieldname + case notGroup + case fieldNotInType + + private var uniqueMessagePart: String { + switch self { + case .unknownField: + return "UnknownArgument: Unknown field argument filter" + case .maxAttributes: + return "Filters exceed maximum attributes limit" + case .maxCombinations: + return "Filters combination exceed maximum limit" + case .repeatedFieldname: + return "filter uses same fieldName multiple time" + case .notGroup: + return "The variables input contains a field name 'not'" + case .fieldNotInType: + return "The variables input contains a field that is not defined for input object type" + } + } + + /// Init RTF error based on error's debugDescription value + public init?(description: String) { + guard + let rtfError = RTFError.allCases.first(where: { description.contains($0.uniqueMessagePart) }) + else { + return nil + } + + self = rtfError + } +} diff --git a/AmplifyPlugins/API/Tests/APIHostApp/APIHostApp.xcodeproj/project.pbxproj b/AmplifyPlugins/API/Tests/APIHostApp/APIHostApp.xcodeproj/project.pbxproj index e44cd6e59a..bf22901ba9 100644 --- a/AmplifyPlugins/API/Tests/APIHostApp/APIHostApp.xcodeproj/project.pbxproj +++ b/AmplifyPlugins/API/Tests/APIHostApp/APIHostApp.xcodeproj/project.pbxproj @@ -353,6 +353,17 @@ 97914C1E29558AF2002000EA /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 97914C1D29558AF2002000EA /* README.md */; }; 97D4946D2981AF9900397C75 /* AuthSignInHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97D4946B2981AF9900397C75 /* AuthSignInHelper.swift */; }; 97D4946E2981AF9900397C75 /* TestConfigHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97D4946C2981AF9900397C75 /* TestConfigHelper.swift */; }; + D86FBA2B2B95FEBA00024BAC /* GraphQLSubscriptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86FBA2A2B95FEBA00024BAC /* GraphQLSubscriptionsTests.swift */; }; + D86FBA362B96216000024BAC /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = D86FBA352B96216000024BAC /* README.md */; }; + D86FBA382B9624B800024BAC /* schema.graphql in Resources */ = {isa = PBXBuildFile; fileRef = D86FBA372B9624B800024BAC /* schema.graphql */; }; + D86FBA562B9634D400024BAC /* TestConfigHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86FBA542B9634D300024BAC /* TestConfigHelper.swift */; }; + D86FBA5E2B96350D00024BAC /* Comment+Schema.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86FBA572B96350D00024BAC /* Comment+Schema.swift */; }; + D86FBA5F2B96350D00024BAC /* Comment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86FBA582B96350D00024BAC /* Comment.swift */; }; + D86FBA602B96350D00024BAC /* Blog+Schema.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86FBA592B96350D00024BAC /* Blog+Schema.swift */; }; + D86FBA612B96350D00024BAC /* Blog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86FBA5A2B96350D00024BAC /* Blog.swift */; }; + D86FBA622B96350D00024BAC /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86FBA5B2B96350D00024BAC /* Post.swift */; }; + D86FBA632B96350D00024BAC /* Post+Schema.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86FBA5C2B96350D00024BAC /* Post+Schema.swift */; }; + D86FBA642B96350D00024BAC /* AmplifyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86FBA5D2B96350D00024BAC /* AmplifyModels.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -440,6 +451,13 @@ remoteGlobalIDString = 21E73E6A28898D7800D7DB7E; remoteInfo = APIHostApp; }; + D86FBA2C2B95FEBA00024BAC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 21E73E6328898D7800D7DB7E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 21E73E6A28898D7800D7DB7E; + remoteInfo = APIHostApp; + }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ @@ -692,6 +710,18 @@ 97914C1D29558AF2002000EA /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 97D4946B2981AF9900397C75 /* AuthSignInHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthSignInHelper.swift; sourceTree = ""; }; 97D4946C2981AF9900397C75 /* TestConfigHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestConfigHelper.swift; sourceTree = ""; }; + D86FBA282B95FEBA00024BAC /* AWSAPIPluginV2Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AWSAPIPluginV2Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + D86FBA2A2B95FEBA00024BAC /* GraphQLSubscriptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLSubscriptionsTests.swift; sourceTree = ""; }; + D86FBA352B96216000024BAC /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + D86FBA372B9624B800024BAC /* schema.graphql */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = schema.graphql; sourceTree = ""; }; + D86FBA542B9634D300024BAC /* TestConfigHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestConfigHelper.swift; sourceTree = ""; }; + D86FBA572B96350D00024BAC /* Comment+Schema.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Comment+Schema.swift"; sourceTree = ""; }; + D86FBA582B96350D00024BAC /* Comment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Comment.swift; sourceTree = ""; }; + D86FBA592B96350D00024BAC /* Blog+Schema.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Blog+Schema.swift"; sourceTree = ""; }; + D86FBA5A2B96350D00024BAC /* Blog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Blog.swift; sourceTree = ""; }; + D86FBA5B2B96350D00024BAC /* Post.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; + D86FBA5C2B96350D00024BAC /* Post+Schema.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Post+Schema.swift"; sourceTree = ""; }; + D86FBA5D2B96350D00024BAC /* AmplifyModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AmplifyModels.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -810,6 +840,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D86FBA252B95FEBA00024BAC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -1164,8 +1201,10 @@ 395906C128AC63A9004B96B1 /* AWSAPIPluginRESTUserPoolTests */, 97914BC429558714002000EA /* GraphQLAPIStressTests */, 21EA887428F9BC600000BA75 /* AWSAPIPluginLazyLoadTests */, + D86FBA292B95FEBA00024BAC /* AWSAPIPluginV2Tests */, 21E73E6C28898D7900D7DB7E /* Products */, 21698BD728899EBB004BD994 /* Frameworks */, + 39AC502FEC3F482AF1545BE2 /* AmplifyConfig */, ); sourceTree = ""; }; @@ -1186,6 +1225,7 @@ 681B35892A43962D0074F369 /* AWSAPIPluginFunctionalTestsWatch.xctest */, 681B35A12A4396CF0074F369 /* AWSAPIPluginGraphQLLambdaAuthTestsWatch.xctest */, 681B35C52A43970A0074F369 /* AWSAPIPluginRESTIAMTestsWatch.xctest */, + D86FBA282B95FEBA00024BAC /* AWSAPIPluginV2Tests.xctest */, ); name = Products; sourceTree = ""; @@ -1405,6 +1445,40 @@ path = Base; sourceTree = ""; }; + D86FBA292B95FEBA00024BAC /* AWSAPIPluginV2Tests */ = { + isa = PBXGroup; + children = ( + D86FBA532B9634D300024BAC /* Base */, + D86FBA552B9634D400024BAC /* Models */, + D86FBA372B9624B800024BAC /* schema.graphql */, + D86FBA352B96216000024BAC /* README.md */, + D86FBA2A2B95FEBA00024BAC /* GraphQLSubscriptionsTests.swift */, + ); + path = AWSAPIPluginV2Tests; + sourceTree = ""; + }; + D86FBA532B9634D300024BAC /* Base */ = { + isa = PBXGroup; + children = ( + D86FBA542B9634D300024BAC /* TestConfigHelper.swift */, + ); + path = Base; + sourceTree = ""; + }; + D86FBA552B9634D400024BAC /* Models */ = { + isa = PBXGroup; + children = ( + D86FBA5D2B96350D00024BAC /* AmplifyModels.swift */, + D86FBA5A2B96350D00024BAC /* Blog.swift */, + D86FBA592B96350D00024BAC /* Blog+Schema.swift */, + D86FBA582B96350D00024BAC /* Comment.swift */, + D86FBA572B96350D00024BAC /* Comment+Schema.swift */, + D86FBA5B2B96350D00024BAC /* Post.swift */, + D86FBA5C2B96350D00024BAC /* Post+Schema.swift */, + ); + path = Models; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -1696,6 +1770,24 @@ productReference = 97914C182955872A002000EA /* GraphQLAPIStressTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + D86FBA272B95FEBA00024BAC /* AWSAPIPluginV2Tests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D86FBA302B95FEBA00024BAC /* Build configuration list for PBXNativeTarget "AWSAPIPluginV2Tests" */; + buildPhases = ( + D86FBA242B95FEBA00024BAC /* Sources */, + D86FBA252B95FEBA00024BAC /* Frameworks */, + D86FBA262B95FEBA00024BAC /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + D86FBA2D2B95FEBA00024BAC /* PBXTargetDependency */, + ); + name = AWSAPIPluginV2Tests; + productName = AWSAPIPluginV2Tests; + productReference = D86FBA282B95FEBA00024BAC /* AWSAPIPluginV2Tests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1703,7 +1795,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1430; + LastSwiftUpdateCheck = 1520; LastUpgradeCheck = 1340; TargetAttributes = { 213DBC7428A6C47000B30280 = { @@ -1755,6 +1847,10 @@ 681B35B62A43970A0074F369 = { TestTargetID = 681B35282A4395730074F369; }; + D86FBA272B95FEBA00024BAC = { + CreatedOnToolsVersion = 15.2; + TestTargetID = 21E73E6A28898D7800D7DB7E; + }; }; }; buildConfigurationList = 21E73E6628898D7800D7DB7E /* Build configuration list for PBXProject "APIHostApp" */; @@ -1787,6 +1883,7 @@ 681B353E2A43962D0074F369 /* AWSAPIPluginFunctionalTestsWatch */, 681B35912A4396CF0074F369 /* AWSAPIPluginGraphQLLambdaAuthTestsWatch */, 681B35B62A43970A0074F369 /* AWSAPIPluginRESTIAMTestsWatch */, + D86FBA272B95FEBA00024BAC /* AWSAPIPluginV2Tests */, ); }; /* End PBXProject section */ @@ -1893,6 +1990,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D86FBA262B95FEBA00024BAC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D86FBA382B9624B800024BAC /* schema.graphql in Resources */, + D86FBA362B96216000024BAC /* README.md in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -2539,6 +2645,22 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D86FBA242B95FEBA00024BAC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D86FBA2B2B95FEBA00024BAC /* GraphQLSubscriptionsTests.swift in Sources */, + D86FBA562B9634D400024BAC /* TestConfigHelper.swift in Sources */, + D86FBA602B96350D00024BAC /* Blog+Schema.swift in Sources */, + D86FBA612B96350D00024BAC /* Blog.swift in Sources */, + D86FBA642B96350D00024BAC /* AmplifyModels.swift in Sources */, + D86FBA5E2B96350D00024BAC /* Comment+Schema.swift in Sources */, + D86FBA622B96350D00024BAC /* Post.swift in Sources */, + D86FBA632B96350D00024BAC /* Post+Schema.swift in Sources */, + D86FBA5F2B96350D00024BAC /* Comment.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -2602,6 +2724,11 @@ target = 21E73E6A28898D7800D7DB7E /* APIHostApp */; targetProxy = 97914BCE2955872A002000EA /* PBXContainerItemProxy */; }; + D86FBA2D2B95FEBA00024BAC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 21E73E6A28898D7800D7DB7E /* APIHostApp */; + targetProxy = D86FBA2C2B95FEBA00024BAC /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -3409,6 +3536,53 @@ }; name = Release; }; + D86FBA2E2B95FEBA00024BAC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.aws.amplify.api.AWSAPIPluginV2Tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/APIHostApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/APIHostApp"; + }; + name = Debug; + }; + D86FBA2F2B95FEBA00024BAC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.aws.amplify.api.AWSAPIPluginV2Tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/APIHostApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/APIHostApp"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -3547,6 +3721,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + D86FBA302B95FEBA00024BAC /* Build configuration list for PBXNativeTarget "AWSAPIPluginV2Tests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D86FBA2E2B95FEBA00024BAC /* Debug */, + D86FBA2F2B95FEBA00024BAC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/AmplifyPlugins/API/Tests/APIHostApp/APIHostApp.xcodeproj/xcshareddata/xcschemes/APIHostApp.xcscheme b/AmplifyPlugins/API/Tests/APIHostApp/APIHostApp.xcodeproj/xcshareddata/xcschemes/APIHostApp.xcscheme index 5e6d13047d..9aad6b919d 100644 --- a/AmplifyPlugins/API/Tests/APIHostApp/APIHostApp.xcodeproj/xcshareddata/xcschemes/APIHostApp.xcscheme +++ b/AmplifyPlugins/API/Tests/APIHostApp/APIHostApp.xcodeproj/xcshareddata/xcschemes/APIHostApp.xcscheme @@ -49,6 +49,17 @@ ReferencedContainer = "container:APIHostApp.xcodeproj"> + + + + AmplifyConfiguration { + + let data = try retrieve(forResource: forResource) + return try AmplifyConfiguration.decodeAmplifyConfiguration(from: data) + } + + static func retrieveCredentials(forResource: String) throws -> [String: String] { + let data = try retrieve(forResource: forResource) + + let jsonOptional = try JSONSerialization.jsonObject(with: data, options: []) as? [String: String] + guard let json = jsonOptional else { + throw "Could not deserialize `\(forResource)` into JSON object" + } + + return json + } + + static func retrieve(forResource: String) throws -> Data { + guard let path = Bundle(for: self).path(forResource: forResource, ofType: "json") else { + throw "Could not retrieve configuration file: \(forResource)" + } + + let url = URL(fileURLWithPath: path) + return try Data(contentsOf: url) + } +} + +extension String { + var withUUID: String { + "\(self)-\(UUID().uuidString)" + } +} diff --git a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/GraphQLSubscriptionsTests.swift b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/GraphQLSubscriptionsTests.swift new file mode 100644 index 0000000000..635c2e73a1 --- /dev/null +++ b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/GraphQLSubscriptionsTests.swift @@ -0,0 +1,135 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +@testable import AWSAPIPlugin +import AWSPluginsCore +@testable import Amplify +@testable import APIHostApp + +final class GraphQLSubscriptionsTests: XCTestCase { + static let amplifyConfiguration = "AWSAPIPluginV2Tests-amplifyconfiguration" + + override func setUp() async throws { + await Amplify.reset() + Amplify.Logging.logLevel = .verbose + + let plugin = AWSAPIPlugin(modelRegistration: AmplifyModels()) + + do { + try Amplify.add(plugin: plugin) + + let amplifyConfig = try TestConfigHelper.retrieveAmplifyConfiguration( + forResource: GraphQLSubscriptionsTests.amplifyConfiguration) + try Amplify.configure(amplifyConfig) + + } catch { + XCTFail("Error during setup: \(error)") + } + } + + override func tearDown() async throws { + await Amplify.reset() + } + + /// Given: GraphQL onCreate subscription request with filter + /// When: Adding models - one not matching and two matching the filter + /// Then: Receive mutation syncs only for matching models + func testOnCreatePostSubscriptionWithFilter() async throws { + let incorrectTitle = "other_title" + let incorrectPost1Id = UUID().uuidString + + let correctTitle = "correct" + let correctPost1Id = UUID().uuidString + let correctPost2Id = UUID().uuidString + + let connectedInvoked = expectation(description: "Connection established") + let onCreateCorrectPost1 = expectation(description: "Receioved onCreate for correctPost1") + let onCreateCorrectPost2 = expectation(description: "Receioved onCreate for correctPost2") + + let modelType = Post.self + let filter: QueryPredicate = modelType.keys.title.eq(correctTitle) + let request = GraphQLRequest.subscription(to: modelType, where: filter, subscriptionType: .onCreate) + + let subscription = Amplify.API.subscribe(request: request) + Task { + do { + for try await subscriptionEvent in subscription { + switch subscriptionEvent { + case .connection(let state): + switch state { + case .connected: + connectedInvoked.fulfill() + + case .connecting, .disconnected: + break + } + + case .data(let graphQLResponse): + switch graphQLResponse { + case .success(let mutationSync): + if mutationSync.model.id == correctPost1Id { + onCreateCorrectPost1.fulfill() + + } else if mutationSync.model.id == correctPost2Id { + onCreateCorrectPost2.fulfill() + + } else if mutationSync.model.id == incorrectPost1Id { + XCTFail("We should not receive onCreate for filtered out model!") + } + + case .failure(let error): + XCTFail(error.errorDescription) + } + } + } + + } catch { + XCTFail("Unexpected subscription failure: \(error)") + } + } + + await fulfillment(of: [connectedInvoked], timeout: TestCommonConstants.networkTimeout) + + guard try await createPost(id: incorrectPost1Id, title: incorrectTitle) != nil else { + XCTFail("Failed to create post"); return + } + + guard try await createPost(id: correctPost1Id, title: correctTitle) != nil else { + XCTFail("Failed to create post"); return + } + + guard try await createPost(id: correctPost2Id, title: correctTitle) != nil else { + XCTFail("Failed to create post"); return + } + + await fulfillment( + of: [onCreateCorrectPost1, onCreateCorrectPost2], + timeout: TestCommonConstants.networkTimeout, + enforceOrder: true + ) + + subscription.cancel() + } + + // MARK: Helpers + + func createPost(id: String, title: String) async throws -> Post? { + let post = Post(id: id, title: title, createdAt: .now()) + return try await createPost(post: post) + } + + func createPost(post: Post) async throws -> Post? { + let data = try await Amplify.API.mutate(request: .createMutation(of: post, version: 0)) + switch data { + case .success(let post): + return post.model.instance as? Post + case .failure(let error): + throw error + } + } +} diff --git a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/AmplifyModels.swift b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/AmplifyModels.swift new file mode 100644 index 0000000000..8a7eaae2e4 --- /dev/null +++ b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/AmplifyModels.swift @@ -0,0 +1,15 @@ +// swiftlint:disable all +import Amplify +import Foundation + +// Contains the set of classes that conforms to the `Model` protocol. + +final public class AmplifyModels: AmplifyModelRegistration { + public let version: String = "165944a36979cd395e3b22145bbfeff0" + + public func registerModels(registry: ModelRegistry.Type) { + ModelRegistry.register(modelType: Blog.self) + ModelRegistry.register(modelType: Post.self) + ModelRegistry.register(modelType: Comment.self) + } +} diff --git a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Blog+Schema.swift b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Blog+Schema.swift new file mode 100644 index 0000000000..154bee9921 --- /dev/null +++ b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Blog+Schema.swift @@ -0,0 +1,40 @@ +// swiftlint:disable all +import Amplify +import Foundation + +extension Blog { + // MARK: - CodingKeys + public enum CodingKeys: String, ModelKey { + case id + case name + case posts + case createdAt + case updatedAt + } + + public static let keys = CodingKeys.self + // MARK: - ModelSchema + + public static let schema = defineSchema { model in + let blog = Blog.keys + + model.pluralName = "Blogs" + + model.attributes( + .primaryKey(fields: [blog.id]) + ) + + model.fields( + .field(blog.id, is: .required, ofType: .string), + .field(blog.name, is: .required, ofType: .string), + .hasMany(blog.posts, is: .optional, ofType: Post.self, associatedWith: Post.keys.blog), + .field(blog.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), + .field(blog.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) + ) + } +} + +extension Blog: ModelIdentifiable { + public typealias IdentifierFormat = ModelIdentifierFormat.Default + public typealias IdentifierProtocol = DefaultModelIdentifier +} diff --git a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Blog.swift b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Blog.swift new file mode 100644 index 0000000000..0f4b439bfd --- /dev/null +++ b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Blog.swift @@ -0,0 +1,32 @@ +// swiftlint:disable all +import Amplify +import Foundation + +public struct Blog: Model { + public let id: String + public var name: String + public var posts: List? + public var createdAt: Temporal.DateTime? + public var updatedAt: Temporal.DateTime? + + public init(id: String = UUID().uuidString, + name: String, + posts: List? = []) { + self.init(id: id, + name: name, + posts: posts, + createdAt: nil, + updatedAt: nil) + } + internal init(id: String = UUID().uuidString, + name: String, + posts: List? = [], + createdAt: Temporal.DateTime? = nil, + updatedAt: Temporal.DateTime? = nil) { + self.id = id + self.name = name + self.posts = posts + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Comment+Schema.swift b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Comment+Schema.swift new file mode 100644 index 0000000000..a13f3b9803 --- /dev/null +++ b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Comment+Schema.swift @@ -0,0 +1,40 @@ +// swiftlint:disable all +import Amplify +import Foundation + +extension Comment { + // MARK: - CodingKeys + public enum CodingKeys: String, ModelKey { + case id + case post + case content + case createdAt + case updatedAt + } + + public static let keys = CodingKeys.self + // MARK: - ModelSchema + + public static let schema = defineSchema { model in + let comment = Comment.keys + + model.pluralName = "Comments" + + model.attributes( + .primaryKey(fields: [comment.id]) + ) + + model.fields( + .field(comment.id, is: .required, ofType: .string), + .belongsTo(comment.post, is: .optional, ofType: Post.self, targetNames: ["postCommentsId"]), + .field(comment.content, is: .required, ofType: .string), + .field(comment.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), + .field(comment.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) + ) + } +} + +extension Comment: ModelIdentifiable { + public typealias IdentifierFormat = ModelIdentifierFormat.Default + public typealias IdentifierProtocol = DefaultModelIdentifier +} diff --git a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Comment.swift b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Comment.swift new file mode 100644 index 0000000000..eb85015113 --- /dev/null +++ b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Comment.swift @@ -0,0 +1,32 @@ +// swiftlint:disable all +import Amplify +import Foundation + +public struct Comment: Model { + public let id: String + public var post: Post? + public var content: String + public var createdAt: Temporal.DateTime? + public var updatedAt: Temporal.DateTime? + + public init(id: String = UUID().uuidString, + post: Post? = nil, + content: String) { + self.init(id: id, + post: post, + content: content, + createdAt: nil, + updatedAt: nil) + } + internal init(id: String = UUID().uuidString, + post: Post? = nil, + content: String, + createdAt: Temporal.DateTime? = nil, + updatedAt: Temporal.DateTime? = nil) { + self.id = id + self.post = post + self.content = content + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Post+Schema.swift b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Post+Schema.swift new file mode 100644 index 0000000000..a2522fd899 --- /dev/null +++ b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Post+Schema.swift @@ -0,0 +1,42 @@ +// swiftlint:disable all +import Amplify +import Foundation + +extension Post { + // MARK: - CodingKeys + public enum CodingKeys: String, ModelKey { + case id + case title + case blog + case comments + case createdAt + case updatedAt + } + + public static let keys = CodingKeys.self + // MARK: - ModelSchema + + public static let schema = defineSchema { model in + let post = Post.keys + + model.pluralName = "Posts" + + model.attributes( + .primaryKey(fields: [post.id]) + ) + + model.fields( + .field(post.id, is: .required, ofType: .string), + .field(post.title, is: .required, ofType: .string), + .belongsTo(post.blog, is: .optional, ofType: Blog.self, targetNames: ["blogPostsId"]), + .hasMany(post.comments, is: .optional, ofType: Comment.self, associatedWith: Comment.keys.post), + .field(post.createdAt, is: .optional, isReadOnly: true, ofType: .dateTime), + .field(post.updatedAt, is: .optional, isReadOnly: true, ofType: .dateTime) + ) + } +} + +extension Post: ModelIdentifiable { + public typealias IdentifierFormat = ModelIdentifierFormat.Default + public typealias IdentifierProtocol = DefaultModelIdentifier +} diff --git a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Post.swift b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Post.swift new file mode 100644 index 0000000000..8f3c33670d --- /dev/null +++ b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/Models/Post.swift @@ -0,0 +1,37 @@ +// swiftlint:disable all +import Amplify +import Foundation + +public struct Post: Model { + public let id: String + public var title: String + public var blog: Blog? + public var comments: List? + public var createdAt: Temporal.DateTime? + public var updatedAt: Temporal.DateTime? + + public init(id: String = UUID().uuidString, + title: String, + blog: Blog? = nil, + comments: List? = []) { + self.init(id: id, + title: title, + blog: blog, + comments: comments, + createdAt: nil, + updatedAt: nil) + } + internal init(id: String = UUID().uuidString, + title: String, + blog: Blog? = nil, + comments: List? = [], + createdAt: Temporal.DateTime? = nil, + updatedAt: Temporal.DateTime? = nil) { + self.id = id + self.title = title + self.blog = blog + self.comments = comments + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/README.md b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/README.md new file mode 100644 index 0000000000..e67ee2fa73 --- /dev/null +++ b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/README.md @@ -0,0 +1,40 @@ +## GraphQL API Integration V2 Tests + +### Prerequisites +- AWS CLI +- Version used: `amplify -v` => `10.3.1` + +### Set-up + +1. `amplify init` and choose `iOS` for type of app you are building + +2. `amplify add api` + +```perl +? Select from one of the below mentioned services: `GraphQL` +? Enable conflict detection? Yes +? Select the default resolution strategy `Optimistic Concurrency` +? Here is the GraphQL API that we will create. Select a setting to edit or continue Authorization modes: `API key (default, expiration time: 7 days from now)` +? Choose the default authorization type for the API API key +? Enter a description for the API key: +? After how many days from now the API key should expire (1-365): `365` +? Configure additional auth types? `No` +? Here is the GraphQL API that we will create. Select a setting to edit or continue Continue +? Choose a schema template: `Single object with fields (e.g., “Todo” with ID, name, description)` + +Then edit your schema and replace it with **amplify-swift/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/schema.graphql** + +3. `amplify push` + +4. Verify that the changes were pushed with the transformer V2 feature flags enabled. In `amplify/cli.json`, the feature flags values should be the following +``` +features.graphqltransformer.transformerversion: 2 +features.graphqltransformer.useexperimentalpipelinedtransformer: true +``` + +5. Copy `amplifyconfiguration.json` to a new file named `AWSAPIPluginV2Tests-amplifyconfiguration.json` inside `~/.aws-amplify/amplify-ios/testconfiguration/` +``` +cp amplifyconfiguration.json ~/.aws-amplify/amplify-ios/testconfiguration/AWSAPIPluginV2Tests-amplifyconfiguration.json +``` + +You should now be able to run all of the tests under the AWSAPIPluginV2Tests folder diff --git a/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/schema.graphql b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/schema.graphql new file mode 100644 index 0000000000..9f1985e3bb --- /dev/null +++ b/AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginV2Tests/schema.graphql @@ -0,0 +1,22 @@ +# This "input" configures a global authorization rule to enable public access to +# all models in this schema. Learn more about authorization rules here: https://docs.amplify.aws/cli/graphql/authorization-rules +input AMPLIFY { globalAuthRule: AuthRule = { allow: public } } # FOR TESTING ONLY! + +type Blog @model { + id: ID! + name: String! + posts: [Post] @hasMany +} + +type Post @model { + id: ID! + title: String! + blog: Blog @belongsTo + comments: [Comment] @hasMany +} + +type Comment @model { + id: ID! + post: Post @belongsTo + content: String! +} diff --git a/AmplifyPlugins/Core/AWSPluginsCore/Model/Decorator/FilterDecorator.swift b/AmplifyPlugins/Core/AWSPluginsCore/Model/Decorator/FilterDecorator.swift index bd78aad37f..ad7cc770c9 100644 --- a/AmplifyPlugins/Core/AWSPluginsCore/Model/Decorator/FilterDecorator.swift +++ b/AmplifyPlugins/Core/AWSPluginsCore/Model/Decorator/FilterDecorator.swift @@ -37,6 +37,9 @@ public struct FilterDecorator: ModelBasedGraphQLDocumentDecorator { } else if case .query = document.operationType { inputs["filter"] = GraphQLDocumentInput(type: "Model\(modelName)FilterInput", value: .object(filter)) + } else if case .subscription = document.operationType { + inputs["filter"] = GraphQLDocumentInput(type: "ModelSubscription\(modelName)FilterInput", + value: .object(filter)) } return document.copy(inputs: inputs) diff --git a/AmplifyPlugins/Core/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+AnyModelWithSync.swift b/AmplifyPlugins/Core/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+AnyModelWithSync.swift index 44af846765..f4e8ca03b3 100644 --- a/AmplifyPlugins/Core/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+AnyModelWithSync.swift +++ b/AmplifyPlugins/Core/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+AnyModelWithSync.swift @@ -37,10 +37,12 @@ protocol ModelSyncGraphQLRequestFactory { authType: AWSAuthorizationType?) -> GraphQLRequest static func subscription(to modelSchema: ModelSchema, + where predicate: QueryPredicate?, subscriptionType: GraphQLSubscriptionType, authType: AWSAuthorizationType?) -> GraphQLRequest static func subscription(to modelSchema: ModelSchema, + where predicate: QueryPredicate?, subscriptionType: GraphQLSubscriptionType, claims: IdentityClaimsDictionary, authType: AWSAuthorizationType?) -> GraphQLRequest @@ -94,16 +96,18 @@ extension GraphQLRequest: ModelSyncGraphQLRequestFactory { } public static func subscription(to modelType: Model.Type, + where predicate: QueryPredicate? = nil, subscriptionType: GraphQLSubscriptionType, authType: AWSAuthorizationType? = nil) -> GraphQLRequest { - subscription(to: modelType.schema, subscriptionType: subscriptionType, authType: authType) + subscription(to: modelType.schema, where: predicate, subscriptionType: subscriptionType, authType: authType) } public static func subscription(to modelType: Model.Type, + where predicate: QueryPredicate? = nil, subscriptionType: GraphQLSubscriptionType, claims: IdentityClaimsDictionary, authType: AWSAuthorizationType? = nil) -> GraphQLRequest { - subscription(to: modelType.schema, subscriptionType: subscriptionType, claims: claims, authType: authType) + subscription(to: modelType.schema, where: predicate, subscriptionType: subscriptionType, claims: claims, authType: authType) } public static func syncQuery(modelType: Model.Type, @@ -169,13 +173,18 @@ extension GraphQLRequest: ModelSyncGraphQLRequestFactory { } public static func subscription(to modelSchema: ModelSchema, + where predicate: QueryPredicate? = nil, subscriptionType: GraphQLSubscriptionType, authType: AWSAuthorizationType? = nil) -> GraphQLRequest { var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelSchema, operationType: .subscription, primaryKeysOnly: true) + documentBuilder.add(decorator: DirectiveNameDecorator(type: subscriptionType)) + if let predicate = optimizePredicate(predicate) { + documentBuilder.add(decorator: FilterDecorator(filter: predicate.graphQLFilter(for: modelSchema))) + } documentBuilder.add(decorator: ConflictResolutionDecorator(graphQLType: .subscription, primaryKeysOnly: true)) documentBuilder.add(decorator: AuthRuleDecorator(.subscription(subscriptionType, nil), authType: authType)) let document = documentBuilder.build() @@ -190,6 +199,7 @@ extension GraphQLRequest: ModelSyncGraphQLRequestFactory { } public static func subscription(to modelSchema: ModelSchema, + where predicate: QueryPredicate? = nil, subscriptionType: GraphQLSubscriptionType, claims: IdentityClaimsDictionary, authType: AWSAuthorizationType? = nil) -> GraphQLRequest { @@ -197,7 +207,11 @@ extension GraphQLRequest: ModelSyncGraphQLRequestFactory { var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelSchema, operationType: .subscription, primaryKeysOnly: true) + documentBuilder.add(decorator: DirectiveNameDecorator(type: subscriptionType)) + if let predicate = optimizePredicate(predicate) { + documentBuilder.add(decorator: FilterDecorator(filter: predicate.graphQLFilter(for: modelSchema))) + } documentBuilder.add(decorator: ConflictResolutionDecorator(graphQLType: .subscription, primaryKeysOnly: true)) documentBuilder.add(decorator: AuthRuleDecorator(.subscription(subscriptionType, claims), authType: authType)) let document = documentBuilder.build() diff --git a/AmplifyPlugins/Core/AWSPluginsCoreTests/Model/GraphQLRequest/GraphQLRequestAnyModelWithSyncTests.swift b/AmplifyPlugins/Core/AWSPluginsCoreTests/Model/GraphQLRequest/GraphQLRequestAnyModelWithSyncTests.swift index 7af997bad4..bca410e82d 100644 --- a/AmplifyPlugins/Core/AWSPluginsCoreTests/Model/GraphQLRequest/GraphQLRequestAnyModelWithSyncTests.swift +++ b/AmplifyPlugins/Core/AWSPluginsCoreTests/Model/GraphQLRequest/GraphQLRequestAnyModelWithSyncTests.swift @@ -16,7 +16,7 @@ class GraphQLRequestAnyModelWithSyncTests: XCTestCase { override func setUp() { ModelRegistry.register(modelType: Comment.self) ModelRegistry.register(modelType: Post.self) - + ModelRegistry.register(modelType: ModelWithOwnerField.self) } override func tearDown() { @@ -419,4 +419,122 @@ class GraphQLRequestAnyModelWithSyncTests: XCTestCase { } XCTAssertEqual(conditionValue["eq"], "myTitle") } + + func testCreateSubscriptionGraphQLRequestWithFilter() throws { + let modelType = Post.self as Model.Type + let modelSchema = modelType.schema + let predicate: QueryPredicate = Post.keys.rating > 0 + let filter = QueryPredicateGroup(type: .and, predicates: [predicate]).graphQLFilter(for: modelSchema) + + var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelSchema, + operationType: .subscription) + + documentBuilder.add(decorator: DirectiveNameDecorator(type: .onCreate)) + documentBuilder.add(decorator: FilterDecorator(filter: filter)) + documentBuilder.add(decorator: ConflictResolutionDecorator(graphQLType: .subscription)) + let document = documentBuilder.build() + + let documentStringValue = """ + subscription OnCreatePost($filter: ModelSubscriptionPostFilterInput) { + onCreatePost(filter: $filter) { + id + content + createdAt + draft + rating + status + title + updatedAt + __typename + _version + _deleted + _lastChangedAt + } + } + """ + let request = GraphQLRequest.subscription(to: modelSchema, + where: predicate, + subscriptionType: .onCreate) + + XCTAssertEqual(document.stringValue, request.document) + XCTAssertEqual(documentStringValue, request.document) + XCTAssert(request.responseType == MutationSyncResult.self) + + guard let variables = request.variables else { + XCTFail("The request doesn't contain variables") + return + } + guard + let filter = variables["filter"] as? [String: [[String: [String: Int]]]], + filter == ["and": [["rating": ["gt": 0]]]] + else { + XCTFail("The document variables property doesn't contain a valid filter") + return + } + } + + func testCreateSubscriptionGraphQLRequestWithFilterAndClaims() throws { + let modelType = ModelWithOwnerField.self as Model.Type + let modelSchema = modelType.schema + let author = "MuniekMg" + let username = "user1" + let predicate: QueryPredicate = ModelWithOwnerField.keys.author.eq(author) + let filter = QueryPredicateGroup(type: .and, predicates: [predicate]).graphQLFilter(for: modelSchema) + let claims = [ + "username": username, + "sub": "123e4567-dead-beef-a456-426614174000" + ] as IdentityClaimsDictionary + + var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelSchema, + operationType: .subscription) + + documentBuilder.add(decorator: DirectiveNameDecorator(type: .onCreate)) + documentBuilder.add(decorator: FilterDecorator(filter: filter)) + documentBuilder.add(decorator: ConflictResolutionDecorator(graphQLType: .subscription)) + documentBuilder.add(decorator: AuthRuleDecorator(.subscription(.onCreate, claims))) + let document = documentBuilder.build() + + let documentStringValue = """ + subscription OnCreateModelWithOwnerField($author: String!, $filter: ModelSubscriptionModelWithOwnerFieldFilterInput) { + onCreateModelWithOwnerField(author: $author, filter: $filter) { + id + author + content + __typename + _version + _deleted + _lastChangedAt + } + } + """ + let request = GraphQLRequest.subscription(to: modelSchema, + where: predicate, + subscriptionType: .onCreate, + claims: claims) + + XCTAssertEqual(document.stringValue, request.document) + XCTAssertEqual(documentStringValue, request.document) + XCTAssert(request.responseType == MutationSyncResult.self) + + guard let variables = request.variables else { + XCTFail("The request doesn't contain variables") + return + } + + guard + let filter = variables["filter"] as? [String: [[String: [String: String]]]], + filter == ["and": [["author": ["eq": author]]]] + else { + XCTFail("The document variables property doesn't contain a valid filter") + return + } + + guard + let author = variables["author"] as? String, + author == username + else { + XCTFail("The document variables property doesn't contain a valid claims") + return + } + } } diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperation.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperation.swift index e01f235b88..90856e75d7 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperation.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperation.swift @@ -194,7 +194,8 @@ final class InitialSyncOperation: AsynchronousOperation { lastSync: lastSyncTime, authType: authTypes.next()) }, - maxRetries: authTypes.count, + maxRetries: authTypes.count, + errorListener: { _ in }, resultListener: completionListener) { nextRequest, wrappedCompletionListener in api.query(request: nextRequest, listener: wrappedCompletionListener) }.main() diff --git a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventPublisher.swift b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventPublisher.swift index d5dae69b37..b119246e2b 100644 --- a/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventPublisher.swift +++ b/AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventPublisher.swift @@ -73,18 +73,36 @@ final class IncomingAsyncSubscriptionEventPublisher: AmplifyCancellable { // onCreate operation let onCreateValueListener = onCreateValueListenerHandler(event:) - let onCreateAuthTypeProvider = await authModeStrategy.authTypesFor(schema: modelSchema, + var onCreateAuthTypeProvider = await authModeStrategy.authTypesFor(schema: modelSchema, operations: [.create, .read]) + var onCreateAuthType: AWSAuthorizationType? = onCreateAuthTypeProvider.next() + var onCreateModelPredicate = modelPredicate + self.onCreateValueListener = onCreateValueListener self.onCreateOperation = RetryableGraphQLSubscriptionOperation( requestFactory: IncomingAsyncSubscriptionEventPublisher.apiRequestFactoryFor( - for: modelSchema, + for: modelSchema, + where: { onCreateModelPredicate }, subscriptionType: .onCreate, api: api, auth: auth, awsAuthService: self.awsAuthService, - authTypeProvider: onCreateAuthTypeProvider), + authTypeProvider: { onCreateAuthType }), maxRetries: onCreateAuthTypeProvider.count, + errorListener: { error in + + if let _ = RTFError(description: error.debugDescription) { + onCreateModelPredicate = nil + + } else if case let .operationError(_, _, underlyingError) = error, let authError = underlyingError as? AuthError { + switch authError { + case .signedOut, .notAuthorized: + onCreateAuthType = onCreateAuthTypeProvider.next() + default: + break + } + } + }, resultListener: genericCompletionListenerHandler) { nextRequest, wrappedCompletion in api.subscribe(request: nextRequest, valueListener: onCreateValueListener, @@ -94,18 +112,36 @@ final class IncomingAsyncSubscriptionEventPublisher: AmplifyCancellable { // onUpdate operation let onUpdateValueListener = onUpdateValueListenerHandler(event:) - let onUpdateAuthTypeProvider = await authModeStrategy.authTypesFor(schema: modelSchema, + var onUpdateAuthTypeProvider = await authModeStrategy.authTypesFor(schema: modelSchema, operations: [.update, .read]) + var onUpdateAuthType: AWSAuthorizationType? = onUpdateAuthTypeProvider.next() + var onUpdateModelPredicate = modelPredicate + self.onUpdateValueListener = onUpdateValueListener self.onUpdateOperation = RetryableGraphQLSubscriptionOperation( requestFactory: IncomingAsyncSubscriptionEventPublisher.apiRequestFactoryFor( for: modelSchema, + where: { onUpdateModelPredicate }, subscriptionType: .onUpdate, api: api, auth: auth, awsAuthService: self.awsAuthService, - authTypeProvider: onUpdateAuthTypeProvider), + authTypeProvider: { onUpdateAuthType }), maxRetries: onUpdateAuthTypeProvider.count, + errorListener: { error in + + if let _ = RTFError(description: error.debugDescription) { + onUpdateModelPredicate = nil + + } else if case let .operationError(_, _, underlyingError) = error, let authError = underlyingError as? AuthError { + switch authError { + case .signedOut, .notAuthorized: + onUpdateAuthType = onUpdateAuthTypeProvider.next() + default: + break + } + } + }, resultListener: genericCompletionListenerHandler) { nextRequest, wrappedCompletion in api.subscribe(request: nextRequest, valueListener: onUpdateValueListener, @@ -115,18 +151,36 @@ final class IncomingAsyncSubscriptionEventPublisher: AmplifyCancellable { // onDelete operation let onDeleteValueListener = onDeleteValueListenerHandler(event:) - let onDeleteAuthTypeProvider = await authModeStrategy.authTypesFor(schema: modelSchema, + var onDeleteAuthTypeProvider = await authModeStrategy.authTypesFor(schema: modelSchema, operations: [.delete, .read]) + var onDeleteAuthType: AWSAuthorizationType? = onDeleteAuthTypeProvider.next() + var onDeleteModelPredicate = modelPredicate + self.onDeleteValueListener = onDeleteValueListener self.onDeleteOperation = RetryableGraphQLSubscriptionOperation( requestFactory: IncomingAsyncSubscriptionEventPublisher.apiRequestFactoryFor( - for: modelSchema, + for: modelSchema, + where: { onDeleteModelPredicate }, subscriptionType: .onDelete, api: api, auth: auth, awsAuthService: self.awsAuthService, - authTypeProvider: onDeleteAuthTypeProvider), + authTypeProvider: { onDeleteAuthType }), maxRetries: onUpdateAuthTypeProvider.count, + errorListener: { error in + + if let _ = RTFError(description: error.debugDescription) { + onDeleteModelPredicate = nil + + } else if case let .operationError(_, _, underlyingError) = error, let authError = underlyingError as? AuthError { + switch authError { + case .signedOut, .notAuthorized: + onDeleteAuthType = onDeleteAuthTypeProvider.next() + default: + break + } + } + }, resultListener: genericCompletionListenerHandler) { nextRequest, wrappedCompletion in api.subscribe(request: nextRequest, valueListener: onDeleteValueListener, @@ -195,17 +249,20 @@ final class IncomingAsyncSubscriptionEventPublisher: AmplifyCancellable { } static func makeAPIRequest(for modelSchema: ModelSchema, + where predicate: QueryPredicate?, subscriptionType: GraphQLSubscriptionType, api: APICategoryGraphQLBehaviorExtended, auth: AuthCategoryBehavior?, authType: AWSAuthorizationType?, awsAuthService: AWSAuthServiceBehavior) async -> GraphQLRequest { + let request: GraphQLRequest if modelSchema.hasAuthenticationRules, let _ = auth, let tokenString = try? await awsAuthService.getUserPoolAccessToken(), case .success(let claims) = awsAuthService.getTokenClaims(tokenString: tokenString) { - request = GraphQLRequest.subscription(to: modelSchema, + request = GraphQLRequest.subscription(to: modelSchema, + where: predicate, subscriptionType: subscriptionType, claims: claims, authType: authType) @@ -213,12 +270,14 @@ final class IncomingAsyncSubscriptionEventPublisher: AmplifyCancellable { let oidcAuthProvider = hasOIDCAuthProviderAvailable(api: api), let tokenString = try? await oidcAuthProvider.getLatestAuthToken(), case .success(let claims) = awsAuthService.getTokenClaims(tokenString: tokenString) { - request = GraphQLRequest.subscription(to: modelSchema, + request = GraphQLRequest.subscription(to: modelSchema, + where: predicate, subscriptionType: subscriptionType, claims: claims, authType: authType) } else { request = GraphQLRequest.subscription(to: modelSchema, + where: predicate, subscriptionType: subscriptionType, authType: authType) } @@ -296,18 +355,20 @@ final class IncomingAsyncSubscriptionEventPublisher: AmplifyCancellable { // MARK: - IncomingAsyncSubscriptionEventPublisher + API request factory extension IncomingAsyncSubscriptionEventPublisher { static func apiRequestFactoryFor(for modelSchema: ModelSchema, + where predicate: @escaping () -> QueryPredicate?, subscriptionType: GraphQLSubscriptionType, api: APICategoryGraphQLBehaviorExtended, auth: AuthCategoryBehavior?, awsAuthService: AWSAuthServiceBehavior, - authTypeProvider: AWSAuthorizationTypeIterator) -> RetryableGraphQLOperation.RequestFactory { - var authTypes = authTypeProvider + authTypeProvider: @escaping () -> AWSAuthorizationType?) -> RetryableGraphQLOperation.RequestFactory { + return { - return await IncomingAsyncSubscriptionEventPublisher.makeAPIRequest(for: modelSchema, + await IncomingAsyncSubscriptionEventPublisher.makeAPIRequest(for: modelSchema, + where: predicate(), subscriptionType: subscriptionType, api: api, auth: auth, - authType: authTypes.next(), + authType: authTypeProvider(), awsAuthService: awsAuthService) } } diff --git a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventPublisherTests.swift b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventPublisherTests.swift index 48100ba687..4f5d5a0f46 100644 --- a/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventPublisherTests.swift +++ b/AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventPublisherTests.swift @@ -106,4 +106,76 @@ final class IncomingAsyncSubscriptionEventPublisherTests: XCTestCase { XCTAssertEqual(expectedOrder.get(), actualOrder.get()) sink.cancel() } + + /// Given: IncomingAsyncSubscriptionEventPublisher initilized with modelPredicate + /// When: IncomingAsyncSubscriptionEventPublisher subscribes to onCreate, onUpdate, onDelete events + /// Then: IncomingAsyncSubscriptionEventPublisher provides correct filters in subscriptions request + func testModelPredicateAsSubscribtionsFilter() async throws { + + let id1 = UUID().uuidString + let id2 = UUID().uuidString + + let correctFilterOnCreate = expectation(description: "Correct filter in onCreate request") + let correctFilterOnUpdate = expectation(description: "Correct filter in onUpdate request") + let correctFilterOnDelete = expectation(description: "Correct filter in onDelete request") + + func validateVariables(_ variables: [String: Any]?) -> Bool { + guard let variables = variables else { + XCTFail("The request doesn't contain variables") + return false + } + + guard + let filter = variables["filter"] as? [String: [[String: [String: String]]]], + filter == ["or": [ + ["id": ["eq": id1]], + ["id": ["eq": id2]] + ]] + + else { + XCTFail("The document variables property doesn't contain a valid filter") + return false + } + + return true + } + + let responder = SubscribeRequestListenerResponder> { request, _, _ in + if request.document.contains("onCreatePost") { + if validateVariables(request.variables) { + correctFilterOnCreate.fulfill() + } + + } else if request.document.contains("onUpdatePost") { + if validateVariables(request.variables) { + correctFilterOnUpdate.fulfill() + } + + } else if request.document.contains("onDeletePost") { + if validateVariables(request.variables) { + correctFilterOnDelete.fulfill() + } + + } else { + XCTFail("Unexpected request: \(request.document)") + } + + return nil + } + + apiPlugin.responders[.subscribeRequestListener] = responder + + _ = await IncomingAsyncSubscriptionEventPublisher( + modelSchema: Post.schema, + api: apiPlugin, + modelPredicate: QueryPredicateGroup(type: .or, predicates: [ + Post.keys.id.eq(id1), + Post.keys.id.eq(id2) + ]), + auth: nil, + authModeStrategy: AWSDefaultAuthModeStrategy(), + awsAuthService: nil) + + await fulfillment(of: [correctFilterOnCreate, correctFilterOnUpdate, correctFilterOnDelete], timeout: 1) + } } diff --git a/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginIntegrationTests/SubscriptionEndToEndTests.swift b/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginIntegrationTests/SubscriptionEndToEndTests.swift index 3642428f98..6b5493f529 100644 --- a/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginIntegrationTests/SubscriptionEndToEndTests.swift +++ b/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginIntegrationTests/SubscriptionEndToEndTests.swift @@ -97,6 +97,91 @@ class SubscriptionEndToEndTests: SyncEngineIntegrationTestBase { let deleteSyncData = await getMutationSync(forPostWithId: id) XCTAssertNil(deleteSyncData) } + + /// Given: DataStore configured with syncExpressions which causes error "Validation error of type UnknownArgument: Unknown field argument filter @ \'onCreatePost\'" when connecting to sync subscriptions + /// When: Adding, editing, removing model + /// Then: Receives create, update, delete mutation + func testRestartsSubscriptionAfterFailureAndReceivesCreateMutateDelete() async throws { + + // Filter all events to ensure they have this ID. This prevents us from overfulfilling on + // unrelated subscriptions + let id = UUID().uuidString + + let originalContent = "Original content from SubscriptionTests at \(Date())" + let updatedContent = "UPDATED CONTENT from SubscriptionTests at \(Date())" + + let createReceived = expectation(description: "createReceived") + let updateReceived = expectation(description: "updateReceived") + let deleteReceived = expectation(description: "deleteReceived") + + let syncExpressions: [DataStoreSyncExpression] = [ + .syncExpression(Post.schema) { + QueryPredicateGroup(type: .or, predicates: [ + Post.keys.id.eq(id) + ]) + } + ] + + #if os(watchOS) + let dataStoreConfiguration = DataStoreConfiguration.custom(syncMaxRecords: 100, syncExpressions: syncExpressions, disableSubscriptions: { false }) + #else + let dataStoreConfiguration = DataStoreConfiguration.custom(syncMaxRecords: 100, syncExpressions: syncExpressions) + #endif + + await setUp(withModels: TestModelRegistration(), dataStoreConfiguration: dataStoreConfiguration) + try await startAmplifyAndWaitForSync() + + var cancellables = Set() + Amplify.Hub.publisher(for: .dataStore) + .filter { $0.eventName == HubPayload.EventName.DataStore.syncReceived } + .compactMap { $0.data as? MutationEvent } + .filter { $0.modelId == id } + .map(\.mutationType) + .sink { + switch $0 { + case GraphQLMutationType.create.rawValue: + createReceived.fulfill() + case GraphQLMutationType.update.rawValue: + updateReceived.fulfill() + case GraphQLMutationType.delete.rawValue: + deleteReceived.fulfill() + default: + break + } + } + .store(in: &cancellables) + + // Act: send create mutation + try await sendCreateRequest(withId: id, content: originalContent) + await fulfillment(of: [createReceived], timeout: 10) + // Assert + let createSyncData = await getMutationSync(forPostWithId: id) + XCTAssertNotNil(createSyncData) + let createdPost = createSyncData?.model.instance as? Post + XCTAssertNotNil(createdPost) + XCTAssertEqual(createdPost?.content, originalContent) + XCTAssertEqual(createSyncData?.syncMetadata.version, 1) + XCTAssertEqual(createSyncData?.syncMetadata.deleted, false) + + // Act: send update mutation + try await sendUpdateRequest(forId: id, content: updatedContent, version: 1) + await fulfillment(of: [updateReceived], timeout: 10) + // Assert + let updateSyncData = await getMutationSync(forPostWithId: id) + XCTAssertNotNil(updateSyncData) + let updatedPost = updateSyncData?.model.instance as? Post + XCTAssertNotNil(updatedPost) + XCTAssertEqual(updatedPost?.content, updatedContent) + XCTAssertEqual(updateSyncData?.syncMetadata.version, 2) + XCTAssertEqual(updateSyncData?.syncMetadata.deleted, false) + + // Act: send delete mutation + try await sendDeleteRequest(forId: id, version: 2) + await fulfillment(of: [deleteReceived], timeout: 10) + // Assert + let deleteSyncData = await getMutationSync(forPostWithId: id) + XCTAssertNil(deleteSyncData) + } // MARK: - Utilities diff --git a/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginV2Tests/TestSupport/SyncEngineIntegrationV2TestBase.swift b/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginV2Tests/TestSupport/SyncEngineIntegrationV2TestBase.swift index 7ff6649ed0..bf06fd7a3b 100644 --- a/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginV2Tests/TestSupport/SyncEngineIntegrationV2TestBase.swift +++ b/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginV2Tests/TestSupport/SyncEngineIntegrationV2TestBase.swift @@ -44,7 +44,7 @@ class SyncEngineIntegrationV2TestBase: DataStoreTestBase { // swiftlint:enable force_try // swiftlint:enable force_cast - func setUp(withModels models: AmplifyModelRegistration, logLevel: LogLevel = .error) async { + func setUp(withModels models: AmplifyModelRegistration, syncExpressions: [DataStoreSyncExpression] = [], logLevel: LogLevel = .error) async { Amplify.Logging.logLevel = logLevel @@ -55,10 +55,17 @@ class SyncEngineIntegrationV2TestBase: DataStoreTestBase { )) #if os(watchOS) try Amplify.add(plugin: AWSDataStorePlugin(modelRegistration: models, - configuration: .custom(syncMaxRecords: 100, disableSubscriptions: { false }))) + configuration: .custom( + syncMaxRecords: 100, + syncExpressions: syncExpressions, + disableSubscriptions: { false } + ))) #else try Amplify.add(plugin: AWSDataStorePlugin(modelRegistration: models, - configuration: .custom(syncMaxRecords: 100))) + configuration: .custom( + syncMaxRecords: 100, + syncExpressions: syncExpressions + ))) #endif } catch { XCTFail(String(describing: error)) diff --git a/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginV2Tests/TransformerV2/DataStoreSyncExpressionsTests.swift b/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginV2Tests/TransformerV2/DataStoreSyncExpressionsTests.swift new file mode 100644 index 0000000000..e3834477d7 --- /dev/null +++ b/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/AWSDataStorePluginV2Tests/TransformerV2/DataStoreSyncExpressionsTests.swift @@ -0,0 +1,174 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +@testable import Amplify +@testable import AWSDataStorePlugin +#if !os(watchOS) +@testable import DataStoreHostApp +#endif + +class DataStoreSyncExpressionsTests: SyncEngineIntegrationV2TestBase { + + struct TestModelRegistration: AmplifyModelRegistration { + func registerModels(registry: ModelRegistry.Type) { + registry.register(modelType: Team1V2.self) + registry.register(modelType: Project1V2.self) + } + + let version: String = "1" + } + + /// Given: DataStore configured with syncExpressions + /// When: Adding models - two matching and one not matching syncExpressions + /// Then: Receive create mutation only for matching models (filtered out on server side) + func testSyncModelWithSyncExpressions() async throws { + let ModelType = Team1V2.self + + let incorrectName = "other_name" + let incorrectModel1Id = UUID().uuidString + let incorrectModel1 = ModelType.init(id: incorrectModel1Id, name: incorrectName) + + let correctName1 = "correct_name_1" + let correctModel1Id = UUID().uuidString + let correctModel1 = ModelType.init(id: correctModel1Id, name: correctName1) + + let correctName2 = "correct_name_2" + let correctModel2Id = UUID().uuidString + let correctModel2 = ModelType.init(id: correctModel2Id, name: correctName2) + + let onCreateCorrectModel1 = expectation(description: "Received onCreate for correctModel1") + let onCreateCorrectModel2 = expectation(description: "Received onCreate for correctModel2") + + await setUp( + withModels: TestModelRegistration(), + syncExpressions: [ + .syncExpression(ModelType.schema) { + QueryPredicateGroup(type: .or, predicates: [ + ModelType.keys.name.eq(correctName1), + ModelType.keys.name.eq(correctName2) + ]) + } + ]) + + try await startAmplifyAndWaitForSync() + + let subscription = Amplify.Publisher.create(Amplify.DataStore.observe(ModelType.self)).sink { completion in + switch completion { + case .finished: break + case .failure(let error): XCTFail("\(error)") + } + + } receiveValue: { mutation in + guard mutation.mutationType == "create" else { return } + + do { + let createdModelId = try mutation.decodeModel().identifier + + if createdModelId == correctModel1Id { + onCreateCorrectModel1.fulfill() + } + + if createdModelId == correctModel2Id { + onCreateCorrectModel2.fulfill() + } + + if createdModelId == incorrectModel1Id { + XCTFail("We should not receive this mutation as it should have been filtered out on the server side") + } + + } catch { + XCTFail(error.localizedDescription) + } + } + + _ = try await Amplify.API.mutate(request: .createMutation(of: incorrectModel1)).get() + _ = try await Amplify.API.mutate(request: .createMutation(of: correctModel1)).get() + _ = try await Amplify.API.mutate(request: .createMutation(of: correctModel2)).get() + + await fulfillment(of: [onCreateCorrectModel1, onCreateCorrectModel2], timeout: TestCommonConstants.networkTimeout) + + subscription.cancel() + } + + /// Given: DataStore configured with syncExpressions which causes error "Filters combination exceed maximum limit 10 for subscription." when connecting to sync subscriptions + /// When: Adding models - two matching and one not matching syncExpressions + /// Then: Receive create mutation only for matching models (filtered out locally) + func testSyncModelWithWithTooManyFiltersCombination_FallbackToNoFilterSubscriptions() async throws { + let ModelType = Team1V2.self + + let incorrectName = "other_name" + let incorrectModel1Id = UUID().uuidString + let incorrectModel1 = ModelType.init(id: incorrectModel1Id, name: incorrectName) + + let correctName1 = "correct_name_1" + let correctModel1Id = UUID().uuidString + let correctModel1 = ModelType.init(id: correctModel1Id, name: correctName1) + + let correctName2 = "correct_name_2" + let correctModel2Id = UUID().uuidString + let correctModel2 = ModelType.init(id: correctModel2Id, name: correctName2) + + let onCreateCorrectModel1 = expectation(description: "Received onCreate for correctModel1") + let onCreateCorrectModel2 = expectation(description: "Received onCreate for correctModel2") + + await setUp( + withModels: TestModelRegistration(), + syncExpressions: [ + .syncExpression(ModelType.schema) { + QueryPredicateGroup(type: .or, predicates: + + (0...20).map { ModelType.keys.name.eq("\($0)") } + + + + + [ + ModelType.keys.name.eq(correctName1), + ModelType.keys.name.eq(correctName2) + ] + ) + } + ]) + + try await startAmplifyAndWaitForSync() + + let subscription = Amplify.Publisher.create(Amplify.DataStore.observe(ModelType.self)).sink { completion in + switch completion { + case .finished: break + case .failure(let error): XCTFail("\(error)") + } + + } receiveValue: { mutation in + do { + let createdModelId = try mutation.decodeModel().identifier + + if createdModelId == correctModel1Id { + onCreateCorrectModel1.fulfill() + } + + if createdModelId == correctModel2Id { + onCreateCorrectModel2.fulfill() + } + + if createdModelId == incorrectModel1Id { + XCTFail("We should not receive this mutation as it should have been filtered out locally") + } + + } catch { + XCTFail(error.localizedDescription) + } + } + + _ = try await Amplify.API.mutate(request: .createMutation(of: incorrectModel1)).get() + _ = try await Amplify.API.mutate(request: .createMutation(of: correctModel1)).get() + _ = try await Amplify.API.mutate(request: .createMutation(of: correctModel2)).get() + + await fulfillment(of: [onCreateCorrectModel1, onCreateCorrectModel2], timeout: TestCommonConstants.networkTimeout) + + subscription.cancel() + } +} diff --git a/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/DataStoreHostApp.xcodeproj/project.pbxproj b/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/DataStoreHostApp.xcodeproj/project.pbxproj index 54af42878f..30e9dee375 100644 --- a/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/DataStoreHostApp.xcodeproj/project.pbxproj +++ b/AmplifyPlugins/DataStore/Tests/DataStoreHostApp/DataStoreHostApp.xcodeproj/project.pbxproj @@ -1425,6 +1425,7 @@ 97CABB4B28C921A30041E213 /* AWSDataStoreMultiAuthCombinationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21BBFADF289BFE4E00B32A39 /* AWSDataStoreMultiAuthCombinationTests.swift */; }; 97CABB4C28C921A30041E213 /* AWSDataStoreMultiAuthThreeRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21BBFADB289BFE4E00B32A39 /* AWSDataStoreMultiAuthThreeRulesTests.swift */; }; 97CABB4D28C921A30041E213 /* AWSDataStoreMultiAuthTwoRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21BBFADD289BFE4E00B32A39 /* AWSDataStoreMultiAuthTwoRulesTests.swift */; }; + D89B12772BC6F8C500348051 /* DataStoreSyncExpressionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D89B12762BC6F8C500348051 /* DataStoreSyncExpressionsTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -2263,6 +2264,7 @@ 97914C2529563539002000EA /* DataStoreStressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStoreStressTests.swift; sourceTree = ""; }; 97914D0E29563544002000EA /* DatastoreStressTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DatastoreStressTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 97914D1129563C3D002000EA /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + D89B12762BC6F8C500348051 /* DataStoreSyncExpressionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStoreSyncExpressionsTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2906,6 +2908,7 @@ 21BBFA0A289BFE3400B32A39 /* DataStoreModelWithDefaultValueTests.swift */, 21BBF9FF289BFE3400B32A39 /* DataStoreModelWithSecondaryIndexTests.swift */, 21BBF9FD289BFE3400B32A39 /* DataStoreSchemaDriftTests.swift */, + D89B12762BC6F8C500348051 /* DataStoreSyncExpressionsTests.swift */, 21BBFA07289BFE3400B32A39 /* README.md */, 97731F0428B68DAC006F180B /* schema.graphql */, ); @@ -5542,6 +5545,7 @@ 97731E8428B3BD39006F180B /* CustomerMultipleSecondaryIndexV2+Schema.swift in Sources */, 97731EB928B3BD39006F180B /* Comment3aV2+Schema.swift in Sources */, 97731EB728B3BD39006F180B /* Comment3aV2.swift in Sources */, + D89B12772BC6F8C500348051 /* DataStoreSyncExpressionsTests.swift in Sources */, 97731EC728B3BD39006F180B /* Team2V2.swift in Sources */, 97731D5728B3BD25006F180B /* DataStoreTestBase.swift in Sources */, 97731ECB28B3BD39006F180B /* Post6V2.swift in Sources */,