From 8eead4aaf13d790a0a2aedc466a9200465374868 Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Tue, 7 May 2024 10:43:59 -0500 Subject: [PATCH 1/5] chore: updated swift deps --- .../StorageEngine+SyncRequirement.swift | 2 +- .../Storage/StorageEngine.swift | 1 + .../InitialSync/InitialSyncOperation.swift | 72 ++-- .../InitialSync/InitialSyncOrchestrator.swift | 6 +- .../OutgoingMutationQueue+Action.swift | 2 +- .../OutgoingMutationQueue+State.swift | 2 +- .../OutgoingMutationQueue.swift | 12 +- ...ocessMutationErrorFromCloudOperation.swift | 62 +-- .../SyncMutationToCloudOperation.swift | 87 ++--- .../Sync/RemoteSyncEngine+Action.swift | 4 +- .../Sync/RemoteSyncEngine+State.swift | 4 +- .../Sync/RemoteSyncEngine.swift | 8 +- .../Sync/RemoteSyncEngineBehavior.swift | 2 +- .../AWSIncomingEventReconciliationQueue.swift | 4 +- ...WSIncomingSubscriptionEventPublisher.swift | 2 +- ...omingAsyncSubscriptionEventPublisher.swift | 181 +++++---- .../AWSModelReconciliationQueue.swift | 4 +- .../APICategoryGraphQLBehaviorExtended.swift | 41 -- .../Auth/AWSAuthModeStrategy.swift | 14 + .../Auth/AWSAuthorizationType.swift | 3 +- .../GraphQLRequest/GraphQLRequest+Model.swift | 135 +++++-- .../APICategory+CategoryConfigurable.swift | 6 + .../Operation/NondeterminsticOperation.swift | 99 +++++ .../Operation/RetryableGraphQLOperation.swift | 259 ++++++------- .../API/Request/GraphQLOperationRequest.swift | 5 + .../API/Request/GraphQLRequest.swift | 8 + ...alyticsCategory+CategoryConfigurable.swift | 6 + .../AuthCategory+CategoryConfigurable.swift | 7 + .../DataStoreCategory+Configurable.swift | 4 + .../GeoCategory+CategoryConfigurable.swift | 6 + .../HubCategory+CategoryConfigurable.swift | 15 + ...LoggingCategory+CategoryConfigurable.swift | 26 ++ ...cationsCategory+CategoryConfigurable.swift | 7 + ...ictionsCategory+CategoryConfigurable.swift | 7 + ...StorageCategory+CategoryConfigurable.swift | 6 + .../Request/StorageDownloadDataRequest.swift | 28 +- .../Request/StorageDownloadFileRequest.swift | 27 ++ .../Request/StorageGetURLRequest.swift | 49 ++- .../Request/StorageListRequest.swift | 16 + .../Request/StorageRemoveRequest.swift | 16 + .../Request/StorageUploadDataRequest.swift | 33 +- .../Request/StorageUploadFileRequest.swift | 33 +- .../Storage/Result/StorageListResult.swift | 35 +- .../Storage/StorageAccessLevel.swift | 1 + .../StorageCategory+ClientBehavior.swift | 59 +++ .../Storage/StorageCategoryBehavior.swift | 147 ++++++- .../Categories/Storage/StoragePath.swift | 45 +++ .../Configuration/AmplifyConfiguration.swift | 4 +- .../Configuration/AmplifyOutputsData.swift | 363 ++++++++++++++++++ .../Configuration/ConfigurationError.swift | 8 + .../Internal/Amplify+Resolve.swift | 1 - .../AmplifyConfigurationInitialization.swift | 70 ++++ .../Internal/Category+Configuration.swift | 1 - .../Internal/CategoryConfigurable.swift | 6 +- .../AmplifyAsyncThrowingSequence.swift | 2 + .../AmplifyTask+OperationTaskAdapters.swift | 4 +- .../Core/Support/AmplifyTaskExecution.swift | 71 ++++ .../Amplify/Core/Support/TaskQueue.swift | 51 ++- 58 files changed, 1685 insertions(+), 494 deletions(-) delete mode 100644 packages/amplify_datastore/ios/internal/AWSPluginsCore/API/APICategoryGraphQLBehaviorExtended.swift create mode 100644 packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/NondeterminsticOperation.swift create mode 100644 packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StoragePath.swift create mode 100644 packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/AmplifyOutputsData.swift create mode 100644 packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTaskExecution.swift diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngine+SyncRequirement.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngine+SyncRequirement.swift index 0b99894ea6..b6a8aac20c 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngine+SyncRequirement.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngine+SyncRequirement.swift @@ -25,7 +25,7 @@ extension StorageEngine { )) } - guard let apiGraphQL = api as? APICategoryGraphQLBehaviorExtended else { + guard let apiGraphQL = api as? APICategoryGraphQLBehavior else { log.info("Unable to find GraphQL API plugin for syncEngine. syncEngine will not be started") return .failure(.configuration( "Unable to find suitable GraphQL API plugin for syncEngine. syncEngine will not be started", diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngine.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngine.swift index 078bf60624..53c213e4fb 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngine.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Storage/StorageEngine.swift @@ -206,6 +206,7 @@ final class StorageEngine: StorageEngineBehavior { "Cannot apply a condition on model which does not exist.", "Save the model instance without a condition first.") completion(.failure(causedBy: dataStoreError)) + return } do { diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperation.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperation.swift index e01f235b88..0365b91862 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperation.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOperation.swift @@ -13,7 +13,7 @@ import Foundation final class InitialSyncOperation: AsynchronousOperation { typealias SyncQueryResult = PaginatedList - private weak var api: APICategoryGraphQLBehaviorExtended? + private weak var api: APICategoryGraphQLBehavior? private weak var reconciliationQueue: IncomingEventReconciliationQueue? private weak var storageAdapter: StorageEngineAdapter? private let dataStoreConfiguration: DataStoreConfiguration @@ -22,6 +22,7 @@ final class InitialSyncOperation: AsynchronousOperation { private let modelSchema: ModelSchema private var recordsReceived: UInt + private var queryTask: Task? private var syncMaxRecords: UInt { return dataStoreConfiguration.syncMaxRecords @@ -61,7 +62,7 @@ final class InitialSyncOperation: AsynchronousOperation { } init(modelSchema: ModelSchema, - api: APICategoryGraphQLBehaviorExtended?, + api: APICategoryGraphQLBehavior?, reconciliationQueue: IncomingEventReconciliationQueue?, storageAdapter: StorageEngineAdapter?, dataStoreConfiguration: DataStoreConfiguration, @@ -86,7 +87,7 @@ final class InitialSyncOperation: AsynchronousOperation { log.info("Beginning sync for \(modelSchema.name)") let lastSyncMetadata = getLastSyncMetadata() let lastSyncTime = getLastSyncTime(lastSyncMetadata) - Task { + self.queryTask = Task { await query(lastSyncTime: lastSyncTime) } } @@ -168,42 +169,44 @@ final class InitialSyncOperation: AsynchronousOperation { } let minSyncPageSize = Int(min(syncMaxRecords - recordsReceived, syncPageSize)) let limit = minSyncPageSize < 0 ? Int(syncPageSize) : minSyncPageSize - let completionListener: GraphQLOperation.ResultListener = { result in - switch result { - case .failure(let apiError): - if self.isAuthSignedOutError(apiError: apiError) { - self.log.error("Sync for \(self.modelSchema.name) failed due to signed out error \(apiError.errorDescription)") + let authTypes = await authModeStrategy.authTypesFor(schema: modelSchema, operation: .read) + .publisher() + .map { Optional.some($0) } // map to optional to have nil as element + .replaceEmpty(with: nil) // use a nil element to trigger default auth if no auth provided + .map { authType in { [weak self] in + guard let self, let api = self.api else { + throw APIError.operationError("Operation cancelled", "") } - // TODO: Retry query on error - let error = DataStoreError.api(apiError) - self.dataStoreConfiguration.errorHandler(error) - self.finish(result: .failure(error)) - case .success(let graphQLResult): - self.handleQueryResults(lastSyncTime: lastSyncTime, graphQLResult: graphQLResult) + return try await api.query(request: GraphQLRequest.syncQuery( + modelSchema: self.modelSchema, + where: self.syncPredicate, + limit: limit, + nextToken: nextToken, + lastSync: lastSyncTime, + authType: authType + )) + }} + .eraseToAnyPublisher() + + switch await RetryableGraphQLOperation(requestStream: authTypes).run() { + case .success(let graphQLResult): + await handleQueryResults(lastSyncTime: lastSyncTime, graphQLResult: graphQLResult) + case .failure(let apiError): + if self.isAuthSignedOutError(apiError: apiError) { + self.log.error("Sync for \(self.modelSchema.name) failed due to signed out error \(apiError.errorDescription)") } + self.dataStoreConfiguration.errorHandler(DataStoreError.api(apiError)) + self.finish(result: .failure(.api(apiError))) } - - var authTypes = await authModeStrategy.authTypesFor(schema: modelSchema, operation: .read) - - RetryableGraphQLOperation(requestFactory: { - GraphQLRequest.syncQuery(modelSchema: self.modelSchema, - where: self.syncPredicate, - limit: limit, - nextToken: nextToken, - lastSync: lastSyncTime, - authType: authTypes.next()) - }, - maxRetries: authTypes.count, - resultListener: completionListener) { nextRequest, wrappedCompletionListener in - api.query(request: nextRequest, listener: wrappedCompletionListener) - }.main() } /// Disposes of the query results: Stops if error, reconciles results if success, and kick off a new query if there /// is a next token - private func handleQueryResults(lastSyncTime: Int64?, - graphQLResult: Result>) { + private func handleQueryResults( + lastSyncTime: Int64?, + graphQLResult: Result> + ) async { guard !isCancelled else { finish(result: .successfulVoid) return @@ -238,9 +241,7 @@ final class InitialSyncOperation: AsynchronousOperation { } if let nextToken = syncQueryResult.nextToken, recordsReceived < syncMaxRecords { - Task { - await self.query(lastSyncTime: lastSyncTime, nextToken: nextToken) - } + await self.query(lastSyncTime: lastSyncTime, nextToken: nextToken) } else { updateModelSyncMetadata(lastSyncTime: syncQueryResult.startedAt) } @@ -292,6 +293,9 @@ final class InitialSyncOperation: AsynchronousOperation { super.finish() } + override func cancel() { + self.queryTask?.cancel() + } } extension InitialSyncOperation: DefaultLogger { diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOrchestrator.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOrchestrator.swift index dbfe953ab1..806b19a240 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOrchestrator.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/InitialSync/InitialSyncOrchestrator.swift @@ -19,7 +19,7 @@ protocol InitialSyncOrchestrator { typealias InitialSyncOrchestratorFactory = (DataStoreConfiguration, AuthModeStrategy, - APICategoryGraphQLBehaviorExtended?, + APICategoryGraphQLBehavior?, IncomingEventReconciliationQueue?, StorageEngineAdapter?) -> InitialSyncOrchestrator @@ -30,7 +30,7 @@ final class AWSInitialSyncOrchestrator: InitialSyncOrchestrator { private var initialSyncOperationSinks: [String: AnyCancellable] private let dataStoreConfiguration: DataStoreConfiguration - private weak var api: APICategoryGraphQLBehaviorExtended? + private weak var api: APICategoryGraphQLBehavior? private weak var reconciliationQueue: IncomingEventReconciliationQueue? private weak var storageAdapter: StorageEngineAdapter? private let authModeStrategy: AuthModeStrategy @@ -52,7 +52,7 @@ final class AWSInitialSyncOrchestrator: InitialSyncOrchestrator { init(dataStoreConfiguration: DataStoreConfiguration, authModeStrategy: AuthModeStrategy, - api: APICategoryGraphQLBehaviorExtended?, + api: APICategoryGraphQLBehavior?, reconciliationQueue: IncomingEventReconciliationQueue?, storageAdapter: StorageEngineAdapter?) { self.initialSyncOperationSinks = [:] diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+Action.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+Action.swift index a9c8309ad6..f042cfab00 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+Action.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+Action.swift @@ -15,7 +15,7 @@ extension OutgoingMutationQueue { enum Action { // Startup/config actions case initialized - case receivedStart(APICategoryGraphQLBehaviorExtended, MutationEventPublisher, IncomingEventReconciliationQueue?) + case receivedStart(APICategoryGraphQLBehavior, MutationEventPublisher, IncomingEventReconciliationQueue?) case receivedSubscription // Event loop diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+State.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+State.swift index f7c59eb8ea..b8839a4b3e 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+State.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue+State.swift @@ -16,7 +16,7 @@ extension OutgoingMutationQueue { // Startup/config states case notInitialized case stopped - case starting(APICategoryGraphQLBehaviorExtended, MutationEventPublisher, IncomingEventReconciliationQueue?) + case starting(APICategoryGraphQLBehavior, MutationEventPublisher, IncomingEventReconciliationQueue?) // Event loop case requestingEvent diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift index 26cde77852..98ce9a3ce2 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift @@ -13,7 +13,7 @@ import AWSPluginsCore /// Submits outgoing mutation events to the provisioned API protocol OutgoingMutationQueueBehavior: AnyObject { func stopSyncingToCloud(_ completion: @escaping BasicClosure) - func startSyncingToCloud(api: APICategoryGraphQLBehaviorExtended, + func startSyncingToCloud(api: APICategoryGraphQLBehavior, mutationEventPublisher: MutationEventPublisher, reconciliationQueue: IncomingEventReconciliationQueue?) var publisher: AnyPublisher { get } @@ -32,7 +32,7 @@ final class OutgoingMutationQueue: OutgoingMutationQueueBehavior { target: DispatchQueue.global() ) - private weak var api: APICategoryGraphQLBehaviorExtended? + private weak var api: APICategoryGraphQLBehavior? private weak var reconciliationQueue: IncomingEventReconciliationQueue? private var subscription: Subscription? @@ -84,7 +84,7 @@ final class OutgoingMutationQueue: OutgoingMutationQueueBehavior { // MARK: - Public API - func startSyncingToCloud(api: APICategoryGraphQLBehaviorExtended, + func startSyncingToCloud(api: APICategoryGraphQLBehavior, mutationEventPublisher: MutationEventPublisher, reconciliationQueue: IncomingEventReconciliationQueue?) { log.verbose(#function) @@ -130,7 +130,7 @@ final class OutgoingMutationQueue: OutgoingMutationQueueBehavior { /// Responder method for `starting`. Starts the operation queue and subscribes to /// the publisher. After subscribing to the publisher, return actions: /// - receivedSubscription - private func doStart(api: APICategoryGraphQLBehaviorExtended, + private func doStart(api: APICategoryGraphQLBehavior, mutationEventPublisher: MutationEventPublisher, reconciliationQueue: IncomingEventReconciliationQueue?) { log.verbose(#function) @@ -222,7 +222,7 @@ final class OutgoingMutationQueue: OutgoingMutationQueueBehavior { private func processSyncMutationToCloudResult(_ result: GraphQLOperation>.OperationResult, mutationEvent: MutationEvent, - api: APICategoryGraphQLBehaviorExtended) { + api: APICategoryGraphQLBehavior) { if case let .success(graphQLResponse) = result { if case let .success(graphQLResult) = graphQLResponse { processSuccessEvent(mutationEvent, @@ -271,7 +271,7 @@ final class OutgoingMutationQueue: OutgoingMutationQueueBehavior { } private func processMutationErrorFromCloud(mutationEvent: MutationEvent, - api: APICategoryGraphQLBehaviorExtended, + api: APICategoryGraphQLBehavior, apiError: APIError?, graphQLResponseError: GraphQLResponseError>?) { if let apiError = apiError, apiError.isOperationCancelledError { diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/ProcessMutationErrorFromCloudOperation.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/ProcessMutationErrorFromCloudOperation.swift index bbc4ec0895..c334745fb7 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/ProcessMutationErrorFromCloudOperation.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/ProcessMutationErrorFromCloudOperation.swift @@ -27,12 +27,12 @@ class ProcessMutationErrorFromCloudOperation: AsynchronousOperation { private let apiError: APIError? private let completion: (Result) -> Void private var mutationOperation: AtomicValue>?> - private weak var api: APICategoryGraphQLBehaviorExtended? + private weak var api: APICategoryGraphQLBehavior? private weak var reconciliationQueue: IncomingEventReconciliationQueue? init(dataStoreConfiguration: DataStoreConfiguration, mutationEvent: MutationEvent, - api: APICategoryGraphQLBehaviorExtended, + api: APICategoryGraphQLBehavior, storageAdapter: StorageEngineAdapter, graphQLResponseError: GraphQLResponseError>? = nil, apiError: APIError? = nil, @@ -296,44 +296,44 @@ class ProcessMutationErrorFromCloudOperation: AsynchronousOperation { } log.verbose("\(#function) sending mutation with data: \(apiRequest)") - let graphQLOperation = api.mutate(request: apiRequest) { [weak self] result in - guard let self = self, !self.isCancelled else { - return - } + Task { [weak self] in + do { + let result = try await api.mutate(request: apiRequest) + guard let self = self, !self.isCancelled else { + self?.finish(result: .failure(APIError.operationError("Mutation operation cancelled", ""))) + return + } - self.log.verbose("sendMutationToCloud received asyncEvent: \(result)") - self.validate(cloudResult: result, request: apiRequest) + self.log.verbose("sendMutationToCloud received asyncEvent: \(result)") + self.validate(cloudResult: result, request: apiRequest) + } catch { + self?.finish(result: .failure(APIError.operationError("Failed to do mutation", "", error))) + } } - mutationOperation.set(graphQLOperation) } - private func validate(cloudResult: MutationSyncCloudResult, request: MutationSyncAPIRequest) { + private func validate(cloudResult: GraphQLResponse, request: MutationSyncAPIRequest) { guard !isCancelled else { return } - if case .failure(let error) = cloudResult { - dataStoreConfiguration.errorHandler(error) - } - - if case let .success(graphQLResponse) = cloudResult { - if case .failure(let error) = graphQLResponse { - dataStoreConfiguration.errorHandler(error) - } else if case let .success(graphQLResult) = graphQLResponse { - guard let reconciliationQueue = reconciliationQueue else { - let dataStoreError = DataStoreError.configuration( - "reconciliationQueue is unexpectedly nil", - """ - The reference to reconciliationQueue has been released while an ongoing mutation was being processed. - \(AmplifyErrorMessages.reportBugToAWS()) - """ - ) - finish(result: .failure(dataStoreError)) - return - } - - reconciliationQueue.offer([graphQLResult], modelName: mutationEvent.modelName) + switch cloudResult { + case .success(let mutationSyncResult): + guard let reconciliationQueue = reconciliationQueue else { + let dataStoreError = DataStoreError.configuration( + "reconciliationQueue is unexpectedly nil", + """ + The reference to reconciliationQueue has been released while an ongoing mutation was being processed. + \(AmplifyErrorMessages.reportBugToAWS()) + """ + ) + finish(result: .failure(dataStoreError)) + return } + + reconciliationQueue.offer([mutationSyncResult], modelName: mutationEvent.modelName) + case .failure(let graphQLResponseError): + dataStoreConfiguration.errorHandler(graphQLResponseError) } finish(result: .success(nil)) diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift index 3732bafb4a..4321411644 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift @@ -17,23 +17,21 @@ class SyncMutationToCloudOperation: AsynchronousOperation { typealias MutationSyncCloudResult = GraphQLOperation>.OperationResult - private weak var api: APICategoryGraphQLBehaviorExtended? + private weak var api: APICategoryGraphQLBehavior? private let mutationEvent: MutationEvent private let getLatestSyncMetadata: () -> MutationSyncMetadata? private let completion: GraphQLOperation>.ResultListener private let requestRetryablePolicy: RequestRetryablePolicy - private let lock: NSRecursiveLock - private var networkReachabilityPublisher: AnyPublisher? - private var mutationOperation: GraphQLOperation>? + private var mutationOperation: Task? private var mutationRetryNotifier: MutationRetryNotifier? private var currentAttemptNumber: Int private var authTypesIterator: AWSAuthorizationTypeIterator? init(mutationEvent: MutationEvent, getLatestSyncMetadata: @escaping () -> MutationSyncMetadata?, - api: APICategoryGraphQLBehaviorExtended, + api: APICategoryGraphQLBehavior, authModeStrategy: AuthModeStrategy, networkReachabilityPublisher: AnyPublisher? = nil, currentAttemptNumber: Int = 1, @@ -46,7 +44,6 @@ class SyncMutationToCloudOperation: AsynchronousOperation { self.completion = completion self.currentAttemptNumber = currentAttemptNumber self.requestRetryablePolicy = requestRetryablePolicy ?? RequestRetryablePolicy() - self.lock = NSRecursiveLock() if let modelSchema = ModelRegistry.modelSchema(from: mutationEvent.modelName), let mutationType = GraphQLMutationType(rawValue: mutationEvent.mutationType) { @@ -66,17 +63,14 @@ class SyncMutationToCloudOperation: AsynchronousOperation { override func cancel() { log.verbose(#function) - lock.execute { - mutationOperation?.cancel() - mutationRetryNotifier?.cancel() - mutationRetryNotifier = nil - } + mutationOperation?.cancel() + mutationRetryNotifier?.cancel() + mutationRetryNotifier = nil let apiError = APIError(error: OperationCancelledError()) finish(result: .failure(apiError)) } - /// Does not require a locking context. Member access is read-only. private func sendMutationToCloud(withAuthType authType: AWSAuthorizationType? = nil) { guard !isCancelled else { return @@ -209,41 +203,50 @@ class SyncMutationToCloudOperation: AsynchronousOperation { return } log.verbose("\(#function) sending mutation with sync data: \(apiRequest)") - lock.execute { - mutationOperation = api.mutate(request: apiRequest) { [weak self] result in - self?.respond(toCloudResult: result, withAPIRequest: apiRequest) + + mutationOperation = Task { [weak self] in + let result: GraphQLResponse> + do { + result = try await api.mutate(request: apiRequest) + } catch { + result = .failure(.unknown("Failed to send sync mutation request", "", error)) } + + self?.respond( + toCloudResult: result, + withAPIRequest: apiRequest + ) } + } - /// Initiates a locking context private func respond( - toCloudResult result: GraphQLOperation>.OperationResult, + toCloudResult result: GraphQLResponse>, withAPIRequest apiRequest: GraphQLRequest> ) { - lock.execute { - guard !self.isCancelled else { - Amplify.log.debug("SyncMutationToCloudOperation cancelled, aborting") - return - } - - log.verbose("GraphQL mutation operation received result: \(result)") - validate(cloudResult: result, request: apiRequest) + guard !self.isCancelled else { + Amplify.log.debug("SyncMutationToCloudOperation cancelled, aborting") + return } + + log.verbose("GraphQL mutation operation received result: \(result)") + validate(cloudResult: result, request: apiRequest) } - /// - Warning: Must be invoked from a locking context - private func validate(cloudResult: MutationSyncCloudResult, - request: GraphQLRequest>) { - guard !isCancelled else { + private func validate( + cloudResult: GraphQLResponse>, + request: GraphQLRequest> + ) { + guard !isCancelled, let mutationOperation, !mutationOperation.isCancelled else { return } - if case .failure(let error) = cloudResult { - let advice = getRetryAdviceIfRetryable(error: error) + if case .failure(let error) = cloudResult, + let apiError = error.underlyingError as? APIError { + let advice = getRetryAdviceIfRetryable(error: apiError) guard advice.shouldRetry else { - finish(result: .failure(error)) + finish(result: .failure(apiError)) return } @@ -257,10 +260,9 @@ class SyncMutationToCloudOperation: AsynchronousOperation { return } - finish(result: cloudResult) + finish(result: .success(cloudResult)) } - /// - Warning: Must be invoked from a locking context private func resolveReachabilityPublisher(request: GraphQLRequest>) { if networkReachabilityPublisher == nil { if let reachability = api as? APICategoryReachabilityBehavior { @@ -277,7 +279,6 @@ class SyncMutationToCloudOperation: AsynchronousOperation { } } - /// - Warning: Must be invoked from a locking context func getRetryAdviceIfRetryable(error: APIError) -> RequestRetryAdvice { var advice = RequestRetryAdvice(shouldRetry: false, retryInterval: DispatchTimeInterval.never) @@ -319,13 +320,11 @@ class SyncMutationToCloudOperation: AsynchronousOperation { return advice } - /// - Warning: Must be invoked from a locking context private func shouldRetryWithDifferentAuthType() -> RequestRetryAdvice { let shouldRetry = authTypesIterator?.hasNext == true return RequestRetryAdvice(shouldRetry: shouldRetry, retryInterval: .milliseconds(0)) } - /// - Warning: Must be invoked from a locking context private func scheduleRetry(advice: RequestRetryAdvice, withAuthType authType: AWSAuthorizationType? = nil) { log.verbose("\(#function) scheduling retry for mutation \(advice)") @@ -338,23 +337,19 @@ class SyncMutationToCloudOperation: AsynchronousOperation { currentAttemptNumber += 1 } - /// Initiates a locking context + private func respondToMutationNotifierTriggered(withAuthType authType: AWSAuthorizationType?) { log.verbose("\(#function) mutationRetryNotifier triggered") - lock.execute { - sendMutationToCloud(withAuthType: authType) - mutationRetryNotifier = nil - } + sendMutationToCloud(withAuthType: authType) + mutationRetryNotifier = nil } /// Cleans up operation resources, finalizes AsynchronousOperation states, and invokes `completion` with `result` /// - Parameter result: The MutationSyncCloudResult to pass to `completion` private func finish(result: MutationSyncCloudResult) { log.verbose(#function) - lock.execute { - mutationOperation?.removeResultListener() - mutationOperation = nil - } + mutationOperation?.cancel() + mutationOperation = nil DispatchQueue.global().async { self.completion(result) diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+Action.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+Action.swift index 7421637a54..7ace6cd086 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+Action.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+Action.swift @@ -18,10 +18,10 @@ extension RemoteSyncEngine { case pausedSubscriptions case pausedMutationQueue(StorageEngineAdapter) - case clearedStateOutgoingMutations(APICategoryGraphQLBehaviorExtended, StorageEngineAdapter) + case clearedStateOutgoingMutations(APICategoryGraphQLBehavior, StorageEngineAdapter) case initializedSubscriptions case performedInitialSync - case activatedCloudSubscriptions(APICategoryGraphQLBehaviorExtended, MutationEventPublisher, IncomingEventReconciliationQueue?) + case activatedCloudSubscriptions(APICategoryGraphQLBehavior, MutationEventPublisher, IncomingEventReconciliationQueue?) case activatedMutationQueue case notifiedSyncStarted case cleanedUp(AmplifyError) diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+State.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+State.swift index d55c3fe5c3..a1ecebfbbb 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+State.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine+State.swift @@ -18,10 +18,10 @@ extension RemoteSyncEngine { case pausingSubscriptions case pausingMutationQueue case clearingStateOutgoingMutations(StorageEngineAdapter) - case initializingSubscriptions(APICategoryGraphQLBehaviorExtended, StorageEngineAdapter) + case initializingSubscriptions(APICategoryGraphQLBehavior, StorageEngineAdapter) case performingInitialSync case activatingCloudSubscriptions - case activatingMutationQueue(APICategoryGraphQLBehaviorExtended, MutationEventPublisher, IncomingEventReconciliationQueue?) + case activatingMutationQueue(APICategoryGraphQLBehavior, MutationEventPublisher, IncomingEventReconciliationQueue?) case notifyingSyncStarted case syncEngineActive diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine.swift index 26bb453571..fd30c9ecae 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngine.swift @@ -21,7 +21,7 @@ class RemoteSyncEngine: RemoteSyncEngineBehavior { private var authModeStrategy: AuthModeStrategy // Assigned at `start` - weak var api: APICategoryGraphQLBehaviorExtended? + weak var api: APICategoryGraphQLBehavior? weak var auth: AuthCategoryBehavior? // Assigned and released inside `performInitialQueries`, but we maintain a reference so we can `reset` @@ -197,7 +197,7 @@ class RemoteSyncEngine: RemoteSyncEngineBehavior { } // swiftlint:enable cyclomatic_complexity - func start(api: APICategoryGraphQLBehaviorExtended, auth: AuthCategoryBehavior?) { + func start(api: APICategoryGraphQLBehavior, auth: AuthCategoryBehavior?) { guard storageAdapter != nil else { log.error(error: DataStoreError.nilStorageAdapter()) remoteSyncTopicPublisher.send(completion: .failure(DataStoreError.nilStorageAdapter())) @@ -280,7 +280,7 @@ class RemoteSyncEngine: RemoteSyncEngineBehavior { } } - private func initializeSubscriptions(api: APICategoryGraphQLBehaviorExtended, + private func initializeSubscriptions(api: APICategoryGraphQLBehavior, storageAdapter: StorageEngineAdapter) async { log.debug("[InitializeSubscription] \(#function)") let syncableModelSchemas = ModelRegistry.modelSchemas.filter { $0.isSyncable } @@ -363,7 +363,7 @@ class RemoteSyncEngine: RemoteSyncEngineBehavior { reconciliationQueue.start() } - private func startMutationQueue(api: APICategoryGraphQLBehaviorExtended, + private func startMutationQueue(api: APICategoryGraphQLBehavior, mutationEventPublisher: MutationEventPublisher, reconciliationQueue: IncomingEventReconciliationQueue?) { log.debug(#function) diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngineBehavior.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngineBehavior.swift index ee5710ff21..765d72473f 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngineBehavior.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/RemoteSyncEngineBehavior.swift @@ -41,7 +41,7 @@ protocol RemoteSyncEngineBehavior: AnyObject { /// the updates in the Datastore /// 1. Mutation processor drains messages off the queue in serial and sends to the service, invoking /// any local callbacks on error if necessary - func start(api: APICategoryGraphQLBehaviorExtended, auth: AuthCategoryBehavior?) + func start(api: APICategoryGraphQLBehavior, auth: AuthCategoryBehavior?) func stop(completion: @escaping DataStoreCallback) diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/AWSIncomingEventReconciliationQueue.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/AWSIncomingEventReconciliationQueue.swift index 7705120b4b..4a6d765aca 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/AWSIncomingEventReconciliationQueue.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/AWSIncomingEventReconciliationQueue.swift @@ -15,7 +15,7 @@ typealias DisableSubscriptions = () -> Bool // Used for testing: typealias IncomingEventReconciliationQueueFactory = ([ModelSchema], - APICategoryGraphQLBehaviorExtended, + APICategoryGraphQLBehavior, StorageEngineAdapter, [DataStoreSyncExpression], AuthCategoryBehavior?, @@ -46,7 +46,7 @@ final class AWSIncomingEventReconciliationQueue: IncomingEventReconciliationQueu private let modelSchemasCount: Int init(modelSchemas: [ModelSchema], - api: APICategoryGraphQLBehaviorExtended, + api: APICategoryGraphQLBehavior, storageAdapter: StorageEngineAdapter, syncExpressions: [DataStoreSyncExpression], auth: AuthCategoryBehavior? = nil, diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/AWSIncomingSubscriptionEventPublisher.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/AWSIncomingSubscriptionEventPublisher.swift index 32365419fe..76c4b7dc9a 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/AWSIncomingSubscriptionEventPublisher.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/AWSIncomingSubscriptionEventPublisher.swift @@ -23,7 +23,7 @@ final class AWSIncomingSubscriptionEventPublisher: IncomingSubscriptionEventPubl } init(modelSchema: ModelSchema, - api: APICategoryGraphQLBehaviorExtended, + api: APICategoryGraphQLBehavior, modelPredicate: QueryPredicate?, auth: AuthCategoryBehavior?, authModeStrategy: AuthModeStrategy) async { diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventPublisher.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventPublisher.swift index d5dae69b37..a342d12c00 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventPublisher.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/IncomingAsyncSubscriptionEventPublisher.swift @@ -6,7 +6,7 @@ // import Amplify -import AWSPluginsCore +@_spi(WebSocket) import AWSPluginsCore import Combine import Foundation @@ -39,7 +39,8 @@ final class IncomingAsyncSubscriptionEventPublisher: AmplifyCancellable { return onCreateConnected && onUpdateConnected && onDeleteConnected } - private let incomingSubscriptionEvents: PassthroughSubject + private let incomingSubscriptionEvents = PassthroughSubject() + private var cancelables = Set() private let awsAuthService: AWSAuthServiceBehavior private let consistencyQueue: DispatchQueue @@ -47,7 +48,7 @@ final class IncomingAsyncSubscriptionEventPublisher: AmplifyCancellable { private let modelName: ModelName init(modelSchema: ModelSchema, - api: APICategoryGraphQLBehaviorExtended, + api: APICategoryGraphQLBehavior, modelPredicate: QueryPredicate?, auth: AuthCategoryBehavior?, authModeStrategy: AuthModeStrategy, @@ -67,72 +68,81 @@ final class IncomingAsyncSubscriptionEventPublisher: AmplifyCancellable { connectionStatusQueue.maxConcurrentOperationCount = 1 connectionStatusQueue.isSuspended = false - let incomingSubscriptionEvents = PassthroughSubject() - self.incomingSubscriptionEvents = incomingSubscriptionEvents self.awsAuthService = awsAuthService ?? AWSAuthService() // onCreate operation - let onCreateValueListener = onCreateValueListenerHandler(event:) - let onCreateAuthTypeProvider = await authModeStrategy.authTypesFor(schema: modelSchema, - operations: [.create, .read]) - self.onCreateValueListener = onCreateValueListener - self.onCreateOperation = RetryableGraphQLSubscriptionOperation( - requestFactory: IncomingAsyncSubscriptionEventPublisher.apiRequestFactoryFor( - for: modelSchema, - subscriptionType: .onCreate, - api: api, - auth: auth, - awsAuthService: self.awsAuthService, - authTypeProvider: onCreateAuthTypeProvider), - maxRetries: onCreateAuthTypeProvider.count, - resultListener: genericCompletionListenerHandler) { nextRequest, wrappedCompletion in - api.subscribe(request: nextRequest, - valueListener: onCreateValueListener, - completionListener: wrappedCompletion) - } - onCreateOperation?.main() + self.onCreateValueListener = onCreateValueListenerHandler(event:) + self.onCreateOperation = await retryableOperation( + subscriptionType: .create, + modelSchema: modelSchema, + authModeStrategy: authModeStrategy, + auth: auth, + api: api + ) + onCreateOperation?.subscribe() + .sink(receiveCompletion: genericCompletionListenerHandler(result:), receiveValue: onCreateValueListener!) + .store(in: &cancelables) // onUpdate operation - let onUpdateValueListener = onUpdateValueListenerHandler(event:) - let onUpdateAuthTypeProvider = await authModeStrategy.authTypesFor(schema: modelSchema, - operations: [.update, .read]) - self.onUpdateValueListener = onUpdateValueListener - self.onUpdateOperation = RetryableGraphQLSubscriptionOperation( - requestFactory: IncomingAsyncSubscriptionEventPublisher.apiRequestFactoryFor( - for: modelSchema, - subscriptionType: .onUpdate, - api: api, - auth: auth, - awsAuthService: self.awsAuthService, - authTypeProvider: onUpdateAuthTypeProvider), - maxRetries: onUpdateAuthTypeProvider.count, - resultListener: genericCompletionListenerHandler) { nextRequest, wrappedCompletion in - api.subscribe(request: nextRequest, - valueListener: onUpdateValueListener, - completionListener: wrappedCompletion) - } - onUpdateOperation?.main() + self.onUpdateValueListener = onUpdateValueListenerHandler(event:) + self.onUpdateOperation = await retryableOperation( + subscriptionType: .update, + modelSchema: modelSchema, + authModeStrategy: authModeStrategy, + auth: auth, + api: api + ) + onUpdateOperation?.subscribe() + .sink(receiveCompletion: genericCompletionListenerHandler(result:), receiveValue: onUpdateValueListener!) + .store(in: &cancelables) // onDelete operation - let onDeleteValueListener = onDeleteValueListenerHandler(event:) - let onDeleteAuthTypeProvider = await authModeStrategy.authTypesFor(schema: modelSchema, - operations: [.delete, .read]) - self.onDeleteValueListener = onDeleteValueListener - self.onDeleteOperation = RetryableGraphQLSubscriptionOperation( - requestFactory: IncomingAsyncSubscriptionEventPublisher.apiRequestFactoryFor( - for: modelSchema, - subscriptionType: .onDelete, - api: api, - auth: auth, - awsAuthService: self.awsAuthService, - authTypeProvider: onDeleteAuthTypeProvider), - maxRetries: onUpdateAuthTypeProvider.count, - resultListener: genericCompletionListenerHandler) { nextRequest, wrappedCompletion in - api.subscribe(request: nextRequest, - valueListener: onDeleteValueListener, - completionListener: wrappedCompletion) - } - onDeleteOperation?.main() + self.onDeleteValueListener = onDeleteValueListenerHandler(event:) + self.onDeleteOperation = await retryableOperation( + subscriptionType: .delete, + modelSchema: modelSchema, + authModeStrategy: authModeStrategy, + auth: auth, + api: api + ) + onDeleteOperation?.subscribe() + .sink(receiveCompletion: genericCompletionListenerHandler(result:), receiveValue: onDeleteValueListener!) + .store(in: &cancelables) + } + + + func retryableOperation( + subscriptionType: IncomingAsyncSubscriptionType, + modelSchema: ModelSchema, + authModeStrategy: AuthModeStrategy, + auth: AuthCategoryBehavior?, + api: APICategoryGraphQLBehavior + ) async -> RetryableGraphQLSubscriptionOperation { + let authTypeProvider = await authModeStrategy.authTypesFor( + schema: modelSchema, + operations: subscriptionType.operations + ) + + return RetryableGraphQLSubscriptionOperation( + requestStream: authTypeProvider.publisher() + .map { Optional.some($0) } // map to optional to have nil as element + .replaceEmpty(with: nil) // use a nil element to trigger default auth if no auth provided + .map { authType in { [weak self] in + guard let self else { + throw APIError.operationError("GraphQL subscription cancelled", "") + } + + return api.subscribe(request: await IncomingAsyncSubscriptionEventPublisher.makeAPIRequest( + for: modelSchema, + subscriptionType: subscriptionType.subscriptionType, + api: api, + auth: auth, + authType: authType, + awsAuthService: self.awsAuthService + )) + }} + .eraseToAnyPublisher() + ) } func onCreateValueListenerHandler(event: Event) { @@ -183,9 +193,9 @@ final class IncomingAsyncSubscriptionEventPublisher: AmplifyCancellable { } } - func genericCompletionListenerHandler(result: Result) { + func genericCompletionListenerHandler(result: Subscribers.Completion) { switch result { - case .success: + case .finished: send(completion: .finished) case .failure(let apiError): log.verbose("[InitializeSubscription.1] API.subscribe failed for `\(modelName)` error: \(apiError.errorDescription)") @@ -196,7 +206,7 @@ final class IncomingAsyncSubscriptionEventPublisher: AmplifyCancellable { static func makeAPIRequest(for modelSchema: ModelSchema, subscriptionType: GraphQLSubscriptionType, - api: APICategoryGraphQLBehaviorExtended, + api: APICategoryGraphQLBehavior, auth: AuthCategoryBehavior?, authType: AWSAuthorizationType?, awsAuthService: AWSAuthServiceBehavior) async -> GraphQLRequest { @@ -226,7 +236,7 @@ final class IncomingAsyncSubscriptionEventPublisher: AmplifyCancellable { return request } - static func hasOIDCAuthProviderAvailable(api: APICategoryGraphQLBehaviorExtended) -> AmplifyOIDCAuthProvider? { + static func hasOIDCAuthProviderAvailable(api: APICategoryGraphQLBehavior) -> AmplifyOIDCAuthProvider? { if let apiPlugin = api as? APICategoryAuthProviderFactoryBehavior, let oidcAuthProvider = apiPlugin.apiAuthProviderFactory().oidcAuthProvider() { return oidcAuthProvider @@ -254,7 +264,7 @@ final class IncomingAsyncSubscriptionEventPublisher: AmplifyCancellable { func cancel() { consistencyQueue.sync { - genericCompletionListenerHandler(result: .successfulVoid) + genericCompletionListenerHandler(result: .finished) onCreateOperation?.cancel() onCreateOperation = nil @@ -287,30 +297,33 @@ final class IncomingAsyncSubscriptionEventPublisher: AmplifyCancellable { onDeleteOperation = nil onDeleteValueListener?(.connection(.disconnected)) - genericCompletionListenerHandler(result: .successfulVoid) + genericCompletionListenerHandler(result: .finished) } } } -// MARK: - IncomingAsyncSubscriptionEventPublisher + API request factory -extension IncomingAsyncSubscriptionEventPublisher { - static func apiRequestFactoryFor(for modelSchema: ModelSchema, - subscriptionType: GraphQLSubscriptionType, - api: APICategoryGraphQLBehaviorExtended, - auth: AuthCategoryBehavior?, - awsAuthService: AWSAuthServiceBehavior, - authTypeProvider: AWSAuthorizationTypeIterator) -> RetryableGraphQLOperation.RequestFactory { - var authTypes = authTypeProvider - return { - return await IncomingAsyncSubscriptionEventPublisher.makeAPIRequest(for: modelSchema, - subscriptionType: subscriptionType, - api: api, - auth: auth, - authType: authTypes.next(), - awsAuthService: awsAuthService) +enum IncomingAsyncSubscriptionType { + case create + case delete + case update + + var operations: [ModelOperation] { + switch self { + case .create: return [.create, .read] + case .delete: return [.delete, .read] + case .update: return [.update, .read] } } + + var subscriptionType: GraphQLSubscriptionType { + switch self { + case .create: return .onCreate + case .delete: return .onDelete + case .update: return .onUpdate + } + } + } extension IncomingAsyncSubscriptionEventPublisher: DefaultLogger { diff --git a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/AWSModelReconciliationQueue.swift b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/AWSModelReconciliationQueue.swift index 7eacedb029..03074d82e3 100644 --- a/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/AWSModelReconciliationQueue.swift +++ b/packages/amplify_datastore/ios/internal/AWSDataStorePlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/AWSModelReconciliationQueue.swift @@ -14,7 +14,7 @@ import Foundation typealias ModelReconciliationQueueFactory = ( ModelSchema, StorageEngineAdapter, - APICategoryGraphQLBehaviorExtended, + APICategoryGraphQLBehavior, ReconcileAndSaveOperationQueue, QueryPredicate?, AuthCategoryBehavior?, @@ -78,7 +78,7 @@ final class AWSModelReconciliationQueue: ModelReconciliationQueue { init(modelSchema: ModelSchema, storageAdapter: StorageEngineAdapter?, - api: APICategoryGraphQLBehaviorExtended, + api: APICategoryGraphQLBehavior, reconcileAndSaveQueue: ReconcileAndSaveOperationQueue, modelPredicate: QueryPredicate?, auth: AuthCategoryBehavior?, diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/API/APICategoryGraphQLBehaviorExtended.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/API/APICategoryGraphQLBehaviorExtended.swift deleted file mode 100644 index 91d56f9763..0000000000 --- a/packages/amplify_datastore/ios/internal/AWSPluginsCore/API/APICategoryGraphQLBehaviorExtended.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// Copyright Amazon.com Inc. or its affiliates. -// All Rights Reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// - -import Foundation -import Amplify - -/// Extending the existing `APICategoryGraphQLBehavior` to include callback based APIs. -/// -/// This exists to allow DataStore to continue to use the `APICategoryGraphQLCallbackBehavior` APIs without exposing -/// them publicly from Amplify in `APICategoryGraphQLBehavior`. Eventually, the goal is for DataStore to use the -/// Async APIs, at which point, this protocol can be completely removed. Introducing this protocol allows Amplify to -/// to fully deprecate the callback based APIs, while allowing DataStore a gradual migration path forward in moving -/// away from APIPlugin's callback APIs to the Async APIs. -/// See https://github.com/aws-amplify/amplify-ios/issues/2252 for more details -/// -/// - Warning: Although this has `public` access, it is intended for internal use and should not be used directly -/// by host applications. The behavior of this may change without warning. -public protocol APICategoryGraphQLBehaviorExtended: - APICategoryGraphQLCallbackBehavior, APICategoryGraphQLBehavior, AnyObject { } - -/// Listener callback based APIs -/// -/// - Warning: Although this has `public` access, it is intended for internal use and should not be used directly -/// by host applications. The behavior of this may change without warning. -public protocol APICategoryGraphQLCallbackBehavior { - @discardableResult - func query(request: GraphQLRequest, - listener: GraphQLOperation.ResultListener?) -> GraphQLOperation - @discardableResult - func mutate(request: GraphQLRequest, - listener: GraphQLOperation.ResultListener?) -> GraphQLOperation - - func subscribe(request: GraphQLRequest, - valueListener: GraphQLSubscriptionOperation.InProcessListener?, - completionListener: GraphQLSubscriptionOperation.ResultListener?) - -> GraphQLSubscriptionOperation -} diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthModeStrategy.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthModeStrategy.swift index 020dc68e60..91d38c2855 100644 --- a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthModeStrategy.swift +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthModeStrategy.swift @@ -6,6 +6,7 @@ // import Foundation +import Combine import Amplify /// Represents different auth strategies supported by a client @@ -95,6 +96,19 @@ public struct AWSAuthorizationTypeIterator: AuthorizationTypeIterator { } } +extension AuthorizationTypeIterator { + public func publisher() -> AnyPublisher { + var it = self + return Deferred { + var authTypes = [AuthorizationType]() + while let authType = it.next() { + authTypes.append(authType) + } + return Publishers.MergeMany(authTypes.map { Just($0) }) + }.eraseToAnyPublisher() + } +} + // MARK: - AWSDefaultAuthModeStrategy /// AWS default auth mode strategy. diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthorizationType.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthorizationType.swift index 0530183e3c..465cc35337 100644 --- a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthorizationType.swift +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Auth/AWSAuthorizationType.swift @@ -6,6 +6,7 @@ // import Foundation +import Amplify // swiftlint:disable line_length @@ -13,7 +14,7 @@ import Foundation /// GraphQL backend, or an Amazon API Gateway endpoint. /// /// - SeeAlso: [https://docs.aws.amazon.com/appsync/latest/devguide/security.html](AppSync Security) -public enum AWSAuthorizationType: String { +public enum AWSAuthorizationType: String, AuthorizationMode { /// For public APIs case none = "NONE" diff --git a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+Model.swift b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+Model.swift index 7229c4ea1b..7338fab830 100644 --- a/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+Model.swift +++ b/packages/amplify_datastore/ios/internal/AWSPluginsCore/Model/GraphQLRequest/GraphQLRequest+Model.swift @@ -35,7 +35,8 @@ protocol ModelGraphQLRequestFactory { static func list(_ modelType: M.Type, where predicate: QueryPredicate?, includes: IncludedAssociations, - limit: Int?) -> GraphQLRequest> + limit: Int?, + authMode: AWSAuthorizationType?) -> GraphQLRequest> /// Creates a `GraphQLRequest` that represents a query that expects a single value as a result. /// The request will be created with the correct correct document based on the `ModelSchema` and @@ -50,16 +51,19 @@ protocol ModelGraphQLRequestFactory { /// - seealso: `GraphQLQuery`, `GraphQLQueryType.get` static func get(_ modelType: M.Type, byId id: String, - includes: IncludedAssociations) -> GraphQLRequest + includes: IncludedAssociations, + authMode: AWSAuthorizationType?) -> GraphQLRequest static func get(_ modelType: M.Type, byIdentifier id: String, - includes: IncludedAssociations) -> GraphQLRequest + includes: IncludedAssociations, + authMode: AWSAuthorizationType?) -> GraphQLRequest where M: ModelIdentifiable, M.IdentifierFormat == ModelIdentifierFormat.Default static func get(_ modelType: M.Type, byIdentifier id: ModelIdentifier, - includes: IncludedAssociations) -> GraphQLRequest + includes: IncludedAssociations, + authMode: AWSAuthorizationType?) -> GraphQLRequest where M: ModelIdentifiable // MARK: Mutation @@ -76,7 +80,8 @@ protocol ModelGraphQLRequestFactory { modelSchema: ModelSchema, where predicate: QueryPredicate?, includes: IncludedAssociations, - type: GraphQLMutationType) -> GraphQLRequest + type: GraphQLMutationType, + authMode: AWSAuthorizationType?) -> GraphQLRequest /// Creates a `GraphQLRequest` that represents a create mutation /// for a given `model` instance. @@ -85,7 +90,9 @@ protocol ModelGraphQLRequestFactory { /// - model: the model instance populated with values /// - Returns: a valid `GraphQLRequest` instance /// - seealso: `GraphQLRequest.mutation(of:where:type:)` - static func create(_ model: M, includes: IncludedAssociations) -> GraphQLRequest + static func create(_ model: M, + includes: IncludedAssociations, + authMode: AWSAuthorizationType?) -> GraphQLRequest /// Creates a `GraphQLRequest` that represents an update mutation /// for a given `model` instance. @@ -97,7 +104,8 @@ protocol ModelGraphQLRequestFactory { /// - seealso: `GraphQLRequest.mutation(of:where:type:)` static func update(_ model: M, where predicate: QueryPredicate?, - includes: IncludedAssociations) -> GraphQLRequest + includes: IncludedAssociations, + authMode: AWSAuthorizationType?) -> GraphQLRequest /// Creates a `GraphQLRequest` that represents a delete mutation /// for a given `model` instance. @@ -109,7 +117,8 @@ protocol ModelGraphQLRequestFactory { /// - seealso: `GraphQLRequest.mutation(of:where:type:)` static func delete(_ model: M, where predicate: QueryPredicate?, - includes: IncludedAssociations) -> GraphQLRequest + includes: IncludedAssociations, + authMode: AWSAuthorizationType?) -> GraphQLRequest // MARK: Subscription @@ -125,7 +134,8 @@ protocol ModelGraphQLRequestFactory { /// - seealso: `GraphQLSubscription`, `GraphQLSubscriptionType` static func subscription(of: M.Type, type: GraphQLSubscriptionType, - includes: IncludedAssociations) -> GraphQLRequest + includes: IncludedAssociations, + authMode: AWSAuthorizationType?) -> GraphQLRequest } // MARK: - Extension @@ -141,52 +151,97 @@ extension GraphQLRequest: ModelGraphQLRequestFactory { return modelType.schema } - public static func create(_ model: M, includes: IncludedAssociations = { _ in [] }) -> GraphQLRequest { - return create(model, modelSchema: modelSchema(for: model), includes: includes) + public static func create( + _ model: M, + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + return create( + model, + modelSchema: modelSchema(for: model), + includes: includes, + authMode: authMode) } public static func update(_ model: M, where predicate: QueryPredicate? = nil, - includes: IncludedAssociations = { _ in [] }) -> GraphQLRequest { - return update(model, modelSchema: modelSchema(for: model), where: predicate, includes: includes) + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + return update( + model, + modelSchema: modelSchema(for: model), + where: predicate, + includes: includes, + authMode: authMode) } public static func delete(_ model: M, where predicate: QueryPredicate? = nil, - includes: IncludedAssociations = { _ in [] }) -> GraphQLRequest { - return delete(model, modelSchema: modelSchema(for: model), where: predicate, includes: includes) + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + return delete( + model, + modelSchema: modelSchema(for: model), + where: predicate, + includes: includes, + authMode: authMode) } - public static func create(_ model: M, modelSchema: ModelSchema, includes: IncludedAssociations = { _ in [] }) -> GraphQLRequest { - return mutation(of: model, modelSchema: modelSchema, includes: includes, type: .create) + public static func create(_ model: M, + modelSchema: ModelSchema, + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + return mutation(of: model, + modelSchema: modelSchema, + includes: includes, + type: .create, + authMode: authMode) } public static func update(_ model: M, modelSchema: ModelSchema, where predicate: QueryPredicate? = nil, - includes: IncludedAssociations = { _ in [] }) -> GraphQLRequest { - return mutation(of: model, modelSchema: modelSchema, where: predicate, includes: includes, type: .update) + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + return mutation(of: model, + modelSchema: modelSchema, + where: predicate, + includes: includes, + type: .update, + authMode: authMode) } public static func delete(_ model: M, modelSchema: ModelSchema, where predicate: QueryPredicate? = nil, - includes: IncludedAssociations = { _ in [] }) -> GraphQLRequest { - return mutation(of: model, modelSchema: modelSchema, where: predicate, includes: includes, type: .delete) + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + return mutation(of: model, + modelSchema: modelSchema, + where: predicate, + includes: includes, + type: .delete, + authMode: authMode) } public static func mutation(of model: M, where predicate: QueryPredicate? = nil, includes: IncludedAssociations = { _ in [] }, - type: GraphQLMutationType) -> GraphQLRequest { - mutation(of: model, modelSchema: model.schema, where: predicate, includes: includes, type: type) + type: GraphQLMutationType, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { + mutation(of: model, + modelSchema: model.schema, + where: predicate, + includes: includes, + type: type, + authMode: authMode) } public static func mutation(of model: M, modelSchema: ModelSchema, where predicate: QueryPredicate? = nil, includes: IncludedAssociations = { _ in [] }, - type: GraphQLMutationType) -> GraphQLRequest { + type: GraphQLMutationType, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelSchema, operationType: .mutation) documentBuilder.add(decorator: DirectiveNameDecorator(type: type)) @@ -216,12 +271,14 @@ extension GraphQLRequest: ModelGraphQLRequestFactory { return GraphQLRequest(document: document.stringValue, variables: document.variables, responseType: M.self, - decodePath: document.name) + decodePath: document.name, + authMode: authMode) } public static func get(_ modelType: M.Type, byId id: String, - includes: IncludedAssociations = { _ in [] }) -> GraphQLRequest { + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelType.schema, operationType: .query) documentBuilder.add(decorator: DirectiveNameDecorator(type: .get)) @@ -237,19 +294,22 @@ extension GraphQLRequest: ModelGraphQLRequestFactory { return GraphQLRequest(document: document.stringValue, variables: document.variables, responseType: M?.self, - decodePath: document.name) + decodePath: document.name, + authMode: authMode) } public static func get(_ modelType: M.Type, byIdentifier id: String, - includes: IncludedAssociations = { _ in [] }) -> GraphQLRequest + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest where M: ModelIdentifiable, M.IdentifierFormat == ModelIdentifierFormat.Default { - return .get(modelType, byId: id, includes: includes) + return .get(modelType, byId: id, includes: includes, authMode: authMode) } public static func get(_ modelType: M.Type, byIdentifier id: ModelIdentifier, - includes: IncludedAssociations = { _ in [] }) -> GraphQLRequest + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest where M: ModelIdentifiable { var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelType.schema, operationType: .query) @@ -265,13 +325,15 @@ extension GraphQLRequest: ModelGraphQLRequestFactory { return GraphQLRequest(document: document.stringValue, variables: document.variables, responseType: M?.self, - decodePath: document.name) + decodePath: document.name, + authMode: authMode) } public static func list(_ modelType: M.Type, where predicate: QueryPredicate? = nil, includes: IncludedAssociations = { _ in [] }, - limit: Int? = nil) -> GraphQLRequest> { + limit: Int? = nil, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest> { let primaryKeysOnly = (M.rootPath != nil) ? true : false var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelType.schema, operationType: .query) @@ -292,12 +354,14 @@ extension GraphQLRequest: ModelGraphQLRequestFactory { return GraphQLRequest>(document: document.stringValue, variables: document.variables, responseType: List.self, - decodePath: document.name) + decodePath: document.name, + authMode: authMode) } public static func subscription(of modelType: M.Type, type: GraphQLSubscriptionType, - includes: IncludedAssociations = { _ in [] }) -> GraphQLRequest { + includes: IncludedAssociations = { _ in [] }, + authMode: AWSAuthorizationType? = nil) -> GraphQLRequest { var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: modelType.schema, operationType: .subscription) documentBuilder.add(decorator: DirectiveNameDecorator(type: type)) @@ -312,6 +376,7 @@ extension GraphQLRequest: ModelGraphQLRequestFactory { return GraphQLRequest(document: document.stringValue, variables: document.variables, responseType: modelType, - decodePath: document.name) + decodePath: document.name, + authMode: authMode) } } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Internal/APICategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Internal/APICategory+CategoryConfigurable.swift index 817b40d326..26e8f1a9e5 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Internal/APICategory+CategoryConfigurable.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Internal/APICategory+CategoryConfigurable.swift @@ -25,4 +25,10 @@ extension APICategory: CategoryConfigurable { try configure(using: categoryConfiguration(from: amplifyConfiguration)) } + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + isConfigured = true + } } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/NondeterminsticOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/NondeterminsticOperation.swift new file mode 100644 index 0000000000..7c930b1924 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/NondeterminsticOperation.swift @@ -0,0 +1,99 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import Combine +/** + A non-deterministic operation offers multiple paths to accomplish its task. + It attempts the next path if all preceding paths have failed with an error that allows for continuation. + */ +enum NondeterminsticOperationError: Error { + case totalFailure + case cancelled +} + +final class NondeterminsticOperation { + /// operation that to be eval + typealias Operation = () async throws -> T + typealias OnError = (Error) -> Bool + + private let operations: AsyncStream + private var shouldTryNextOnError: OnError = { _ in true } + private var cancellables = Set() + private var task: Task? + + deinit { + cancel() + } + + init(operations: AsyncStream, shouldTryNextOnError: OnError? = nil) { + self.operations = operations + if let shouldTryNextOnError { + self.shouldTryNextOnError = shouldTryNextOnError + } + } + + convenience init( + operationStream: AnyPublisher, + shouldTryNextOnError: OnError? = nil + ) { + var cancellables = Set() + self.init( + operations: AsyncStream { continuation in + operationStream.sink { _ in + continuation.finish() + } receiveValue: { operation in + continuation.yield(operation) + }.store(in: &cancellables) + }, + shouldTryNextOnError: shouldTryNextOnError + ) + self.cancellables = cancellables + } + + /// Synchronous version of executing the operations + func execute() -> Future { + Future { [weak self] promise in + self?.task = Task { [weak self] in + do { + if let self { + promise(.success(try await self.run())) + } else { + promise(.failure(NondeterminsticOperationError.cancelled)) + } + } catch { + promise(.failure(error)) + } + } + } + } + + /// Asynchronous version of executing the operations + func run() async throws -> T { + for await operation in operations { + if Task.isCancelled { + throw NondeterminsticOperationError.cancelled + } + do { + return try await operation() + } catch { + if shouldTryNextOnError(error) { + continue + } else { + throw error + } + } + } + throw NondeterminsticOperationError.totalFailure + } + + /// Cancel the operation + func cancel() { + task?.cancel() + cancellables = Set() + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/RetryableGraphQLOperation.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/RetryableGraphQLOperation.swift index ed2a6e2753..f40229d9f9 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/RetryableGraphQLOperation.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Operation/RetryableGraphQLOperation.swift @@ -6,183 +6,150 @@ // import Foundation +import Combine -/// Convenience protocol to handle any kind of GraphQLOperation -public protocol AnyGraphQLOperation { - associatedtype Success - associatedtype Failure: Error - typealias ResultListener = (Result) -> Void -} - -/// Abastraction for a retryable GraphQLOperation. -public protocol RetryableGraphQLOperationBehavior: Operation, DefaultLogger { - associatedtype Payload: Decodable - - /// GraphQLOperation concrete type - associatedtype OperationType: AnyGraphQLOperation - - typealias RequestFactory = () async -> GraphQLRequest - typealias OperationFactory = (GraphQLRequest, @escaping OperationResultListener) -> OperationType - typealias OperationResultListener = OperationType.ResultListener - - /// Operation unique identifier - var id: UUID { get } - - /// Number of attempts (min 1) - var attempts: Int { get set } - - /// Underlying GraphQL operation instantiated by `operationFactory` - var underlyingOperation: AtomicValue { get set } - /// Maximum number of allowed retries - var maxRetries: Int { get } - - /// GraphQLRequest factory, invoked to create a new operation - var requestFactory: RequestFactory { get } - - /// GraphQL operation factory, invoked with a newly created GraphQL request - /// and a wrapped result listener. - var operationFactory: OperationFactory { get } +// MARK: - RetryableGraphQLOperation +public final class RetryableGraphQLOperation { + public typealias Payload = Payload - var resultListener: OperationResultListener { get } + private let nondeterminsticOperation: NondeterminsticOperation.Success> - init(requestFactory: @escaping RequestFactory, - maxRetries: Int, - resultListener: @escaping OperationResultListener, - _ operationFactory: @escaping OperationFactory) + public init( + requestStream: AnyPublisher<() async throws -> GraphQLTask.Success, Never> + ) { + self.nondeterminsticOperation = NondeterminsticOperation( + operationStream: requestStream, + shouldTryNextOnError: Self.onError(_:) + ) + } - func start(request: GraphQLRequest) + deinit { + cancel() + } - func shouldRetry(error: APIError?) -> Bool -} + static func onError(_ error: Error) -> Bool { + guard let error = error as? APIError, + let authError = error.underlyingError as? AuthError + else { + return false + } -extension RetryableGraphQLOperationBehavior { - public static var log: Logger { - Amplify.Logging.logger(forCategory: CategoryType.api.displayName, forNamespace: String(describing: self)) - } - public var log: Logger { - Self.log + switch authError { + case .notAuthorized: return true + default: return false + } } -} -// MARK: RetryableGraphQLOperationBehavior + default implementation -extension RetryableGraphQLOperationBehavior { - public func start(request: GraphQLRequest) { - attempts += 1 - log.debug("[\(id)] - Try [\(attempts)/\(maxRetries)]") - let wrappedResultListener: OperationResultListener = { result in - if case let .failure(error) = result, self.shouldRetry(error: error as? APIError) { - self.log.debug("\(error)") - Task { - self.start(request: await self.requestFactory()) - } - return - } - - if case let .failure(error) = result { - self.log.debug("\(error)") - self.log.debug("[\(self.id)] - Failed") + public func execute( + _ operationType: GraphQLOperationType + ) -> AnyPublisher.Success, APIError> { + nondeterminsticOperation.execute().mapError { + if let apiError = $0 as? APIError { + return apiError + } else { + return APIError.operationError("Failed to execute GraphQL operation", "", $0) } + }.eraseToAnyPublisher() + } - if case .success = result { - self.log.debug("[Operation \(self.id)] - Success") + public func run() async -> Result.Success, APIError> { + do { + let result = try await nondeterminsticOperation.run() + return .success(result) + } catch { + if let apiError = error as? APIError { + return .failure(apiError) + } else { + return .failure(.operationError("Failed to execute GraphQL operation", "", error)) } - self.resultListener(result) } - underlyingOperation.set(operationFactory(request, wrappedResultListener)) } + + public func cancel() { + nondeterminsticOperation.cancel() + } + } -// MARK: - RetryableGraphQLOperation -public final class RetryableGraphQLOperation: Operation, RetryableGraphQLOperationBehavior { +public final class RetryableGraphQLSubscriptionOperation { + public typealias Payload = Payload - public typealias OperationType = GraphQLOperation - - public var id: UUID - public var maxRetries: Int - public var attempts: Int = 0 - public var requestFactory: RequestFactory - public var underlyingOperation: AtomicValue?> = AtomicValue(initialValue: nil) - public var resultListener: OperationResultListener - public var operationFactory: OperationFactory - - public init(requestFactory: @escaping RequestFactory, - maxRetries: Int, - resultListener: @escaping OperationResultListener, - _ operationFactory: @escaping OperationFactory) { - self.id = UUID() - self.maxRetries = max(1, maxRetries) - self.requestFactory = requestFactory - self.operationFactory = operationFactory - self.resultListener = resultListener + public typealias SubscriptionEvents = GraphQLSubscriptionEvent + private var task: Task? + private let nondeterminsticOperation: NondeterminsticOperation> + + public init( + requestStream: AnyPublisher<() async throws -> AmplifyAsyncThrowingSequence, Never> + ) { + self.nondeterminsticOperation = NondeterminsticOperation(operationStream: requestStream) } - public override func main() { - Task { - start(request: await requestFactory()) - } + deinit { + cancel() } - override public func cancel() { - self.underlyingOperation.get()?.cancel() + public func subscribe() -> AnyPublisher { + let subject = PassthroughSubject() + self.task = Task { await self.trySubscribe(subject) } + return subject.eraseToAnyPublisher() } - public func shouldRetry(error: APIError?) -> Bool { - guard case let .operationError(_, _, underlyingError) = error, - let authError = underlyingError as? AuthError else { - return false - } + private func trySubscribe(_ subject: PassthroughSubject) async { + var apiError: APIError? + do { + try Task.checkCancellation() + let sequence = try await self.nondeterminsticOperation.run() + defer { sequence.cancel() } + for try await event in sequence { + try Task.checkCancellation() + subject.send(event) + } + } catch is CancellationError { + subject.send(completion: .finished) + } catch { + if let error = error as? APIError { + apiError = error + } + Self.log.debug("Failed with subscription request: \(error)") + } - switch authError { - case .signedOut, .notAuthorized: - return attempts < maxRetries - default: - return false + if apiError != nil { + subject.send(completion: .failure(apiError!)) + } else { + subject.send(completion: .finished) } } -} - -// MARK: - RetryableGraphQLSubscriptionOperation -public final class RetryableGraphQLSubscriptionOperation: Operation, - RetryableGraphQLOperationBehavior { - public typealias OperationType = GraphQLSubscriptionOperation - public typealias Payload = Payload - - public var id: UUID - public var maxRetries: Int - public var attempts: Int = 0 - public var underlyingOperation: AtomicValue?> = AtomicValue(initialValue: nil) - public var requestFactory: RequestFactory - public var resultListener: OperationResultListener - public var operationFactory: OperationFactory - - public init(requestFactory: @escaping RequestFactory, - maxRetries: Int, - resultListener: @escaping OperationResultListener, - _ operationFactory: @escaping OperationFactory) { - self.id = UUID() - self.maxRetries = max(1, maxRetries) - self.requestFactory = requestFactory - self.operationFactory = operationFactory - self.resultListener = resultListener + public func cancel() { + self.task?.cancel() + self.nondeterminsticOperation.cancel() } - public override func main() { - Task { - start(request: await requestFactory()) +} + +extension AsyncSequence { + fileprivate var asyncStream: AsyncStream { + AsyncStream { continuation in + Task { + var it = self.makeAsyncIterator() + do { + while let ele = try await it.next() { + continuation.yield(ele) + } + continuation.finish() + } catch { + continuation.finish() + } + } } } +} - public override func cancel() { - self.underlyingOperation.get()?.cancel() +extension RetryableGraphQLSubscriptionOperation { + public static var log: Logger { + Amplify.Logging.logger(forCategory: CategoryType.api.displayName, forNamespace: String(describing: self)) } - - public func shouldRetry(error: APIError?) -> Bool { - return attempts < maxRetries + public var log: Logger { + Self.log } - } - -// MARK: GraphQLOperation - GraphQLSubscriptionOperation + AnyGraphQLOperation -extension GraphQLOperation: AnyGraphQLOperation {} -extension GraphQLSubscriptionOperation: AnyGraphQLOperation {} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLOperationRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLOperationRequest.swift index 99115342bb..2f5ebf1ed2 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLOperationRequest.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLOperationRequest.swift @@ -25,6 +25,9 @@ public struct GraphQLOperationRequest: AmplifyOperationRequest { /// The path to traverse before decoding to `responseType`. public let decodePath: String? + /// The authorization mode + public let authMode: AuthorizationMode? + /// Options to adjust the behavior of this request, including plugin-options public let options: Options @@ -35,6 +38,7 @@ public struct GraphQLOperationRequest: AmplifyOperationRequest { variables: [String: Any]? = nil, responseType: R.Type, decodePath: String? = nil, + authMode: AuthorizationMode? = nil, options: Options) { self.apiName = apiName self.operationType = operationType @@ -42,6 +46,7 @@ public struct GraphQLOperationRequest: AmplifyOperationRequest { self.variables = variables self.responseType = responseType self.decodePath = decodePath + self.authMode = authMode self.options = options } } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLRequest.swift index 5c566d2fca..ba0086de66 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLRequest.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/API/Request/GraphQLRequest.swift @@ -5,6 +5,9 @@ // SPDX-License-Identifier: Apache-2.0 // +/// Empty protocol for plugins to define specific `AuthorizationMode` types for the request. +public protocol AuthorizationMode { } + /// GraphQL Request public struct GraphQLRequest { @@ -21,6 +24,9 @@ public struct GraphQLRequest { /// Type to decode the graphql response data object to public let responseType: R.Type + /// The authorization mode + public let authMode: AuthorizationMode? + /// The path to decode to the graphQL response data to `responseType`. Delimited by `.` The decode path /// "listTodos.items" will traverse to the object at `listTodos`, and decode the object at `items` to `responseType` /// The data at that decode path is a list of Todo objects so `responseType` should be `[Todo].self` @@ -34,11 +40,13 @@ public struct GraphQLRequest { variables: [String: Any]? = nil, responseType: R.Type, decodePath: String? = nil, + authMode: AuthorizationMode? = nil, options: GraphQLRequest.Options? = nil) { self.apiName = apiName self.document = document self.variables = variables self.responseType = responseType + self.authMode = authMode self.decodePath = decodePath self.options = options } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Internal/AnalyticsCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Internal/AnalyticsCategory+CategoryConfigurable.swift index e284dcb487..3978b10615 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Internal/AnalyticsCategory+CategoryConfigurable.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Analytics/Internal/AnalyticsCategory+CategoryConfigurable.swift @@ -25,4 +25,10 @@ extension AnalyticsCategory: CategoryConfigurable { try configure(using: categoryConfiguration(from: amplifyConfiguration)) } + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + isConfigured = true + } } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Internal/AuthCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Internal/AuthCategory+CategoryConfigurable.swift index 44fade465c..aff88dc2b1 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Internal/AuthCategory+CategoryConfigurable.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Auth/Internal/AuthCategory+CategoryConfigurable.swift @@ -26,4 +26,11 @@ extension AuthCategory: CategoryConfigurable { func configure(using amplifyConfiguration: AmplifyConfiguration) throws { try configure(using: categoryConfiguration(from: amplifyConfiguration)) } + + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + isConfigured = true + } } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Internal/DataStoreCategory+Configurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Internal/DataStoreCategory+Configurable.swift index dd3ac68569..a6241d4980 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Internal/DataStoreCategory+Configurable.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/DataStore/Internal/DataStoreCategory+Configurable.swift @@ -15,6 +15,10 @@ extension DataStoreCategory: CategoryConfigurable { } } + func configure(using amplifyConfiguration: AmplifyOutputsData) throws { + try configureFirstWithEmptyConfiguration() + } + func configure(using configuration: CategoryConfiguration?) throws { guard !isConfigured else { let error = ConfigurationError.amplifyAlreadyConfigured( diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Internal/GeoCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Internal/GeoCategory+CategoryConfigurable.swift index 06b15ae809..ce233726dd 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Internal/GeoCategory+CategoryConfigurable.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Geo/Internal/GeoCategory+CategoryConfigurable.swift @@ -25,4 +25,10 @@ extension GeoCategory: CategoryConfigurable { try configure(using: categoryConfiguration(from: amplifyConfiguration)) } + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + isConfigured = true + } } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/Internal/HubCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/Internal/HubCategory+CategoryConfigurable.swift index 7adfbcb77f..878abf30de 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/Internal/HubCategory+CategoryConfigurable.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Hub/Internal/HubCategory+CategoryConfigurable.swift @@ -27,4 +27,19 @@ extension HubCategory: CategoryConfigurable { try configure(using: categoryConfiguration(from: amplifyConfiguration)) } + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + guard configurationState.get() != .configured else { + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(categoryType.displayName) has already been configured.", + "Remove the duplicate call to `Amplify.configure()`" + ) + throw error + } + + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + configurationState.set(.configured) + } + } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/Internal/LoggingCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/Internal/LoggingCategory+CategoryConfigurable.swift index 4c691fd65c..67a9c69899 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/Internal/LoggingCategory+CategoryConfigurable.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Logging/Internal/LoggingCategory+CategoryConfigurable.swift @@ -39,4 +39,30 @@ extension LoggingCategory: CategoryConfigurable { try configure(using: categoryConfiguration(from: amplifyConfiguration)) } + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + let plugin: LoggingCategoryPlugin + switch configurationState { + case .default: + // Default plugin is already assigned, and no configuration is applicable, exit early + configurationState = .configured + return + case .pendingConfiguration(let pendingPlugin): + plugin = pendingPlugin + case .configured: + let error = ConfigurationError.amplifyAlreadyConfigured( + "\(categoryType.displayName) has already been configured.", + "Remove the duplicate call to `Amplify.configure()`" + ) + throw error + } + + try plugin.configure(using: amplifyOutputs) + self.plugins[plugin.key] = plugin + + if plugin.key != AWSUnifiedLoggingPlugin.key, let consolePlugin = try? self.getPlugin(for: AWSUnifiedLoggingPlugin.key) { + try consolePlugin.configure(using: amplifyOutputs) + } + + configurationState = .configured + } } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Internal/PushNotificationsCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Internal/PushNotificationsCategory+CategoryConfigurable.swift index 6986d75558..59d80d72aa 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Internal/PushNotificationsCategory+CategoryConfigurable.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Notifications/PushNotifications/Internal/PushNotificationsCategory+CategoryConfigurable.swift @@ -23,4 +23,11 @@ extension PushNotificationsCategory: CategoryConfigurable { func configure(using amplifyConfiguration: AmplifyConfiguration) throws { try configure(using: categoryConfiguration(from: amplifyConfiguration)) } + + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + isConfigured = true + } } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Internal/PredictionsCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Internal/PredictionsCategory+CategoryConfigurable.swift index 2e29ec5146..1551e9aa79 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Internal/PredictionsCategory+CategoryConfigurable.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Predictions/Internal/PredictionsCategory+CategoryConfigurable.swift @@ -25,4 +25,11 @@ extension PredictionsCategory: CategoryConfigurable { try configure(using: categoryConfiguration(from: amplifyConfiguration)) } + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + isConfigured = true + } + } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Internal/StorageCategory+CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Internal/StorageCategory+CategoryConfigurable.swift index 20eaafd4ad..caf4552793 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Internal/StorageCategory+CategoryConfigurable.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Internal/StorageCategory+CategoryConfigurable.swift @@ -25,4 +25,10 @@ extension StorageCategory: CategoryConfigurable { try configure(using: categoryConfiguration(from: amplifyConfiguration)) } + func configure(using amplifyOutputs: AmplifyOutputsData) throws { + for plugin in Array(plugins.values) { + try plugin.configure(using: amplifyOutputs) + } + isConfigured = true + } } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageDownloadDataRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageDownloadDataRequest.swift index f4f7ee0b83..1a8ed260b9 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageDownloadDataRequest.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageDownloadDataRequest.swift @@ -13,9 +13,15 @@ import Foundation /// - Tag: StorageDownloadDataRequest public struct StorageDownloadDataRequest: AmplifyOperationRequest { + /// The path for the object in storage + /// + /// - Tag: StorageDownloadFileRequest.path + public let path: (any StoragePath)? + /// The unique identifier for the object in storage /// /// - Tag: StorageDownloadDataRequest.key + @available(*, deprecated, message: "Use `path` instead of `key`") public let key: String /// Options to adjust the behavior of this request, including plugin-options @@ -23,10 +29,19 @@ public struct StorageDownloadDataRequest: AmplifyOperationRequest { /// - Tag: StorageDownloadDataRequest.options public let options: Options - /// - Tag: StorageDownloadDataRequest.key + /// - Tag: StorageDownloadDataRequest.init + @available(*, deprecated, message: "Use init(path:local:options)") public init(key: String, options: Options) { self.key = key self.options = options + self.path = nil + } + + /// - Tag: StorageDownloadDataRequest.init + public init(path: any StoragePath, options: Options) { + self.key = "" + self.options = options + self.path = path } } @@ -40,11 +55,13 @@ public extension StorageDownloadDataRequest { /// Access level of the storage system. Defaults to `public` /// /// - Tag: StorageDownloadDataRequestOptions.accessLevel + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") public let accessLevel: StorageAccessLevel /// Target user to apply the action on. /// /// - Tag: StorageDownloadDataRequestOptions.targetIdentityId + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") public let targetIdentityId: String? /// Extra plugin specific options, only used in special circumstances when the existing options do not provide @@ -73,6 +90,7 @@ public extension StorageDownloadDataRequest { /// /// - Tag: StorageDownloadDataRequestOptions.init + @available(*, deprecated, message: "Use init(pluginOptions)") public init(accessLevel: StorageAccessLevel = .guest, targetIdentityId: String? = nil, pluginOptions: Any? = nil) { @@ -80,5 +98,13 @@ public extension StorageDownloadDataRequest { self.targetIdentityId = targetIdentityId self.pluginOptions = pluginOptions } + + /// + /// - Tag: StorageDownloadDataRequestOptions.init + public init(pluginOptions: Any? = nil) { + self.accessLevel = .guest + self.targetIdentityId = nil + self.pluginOptions = pluginOptions + } } } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageDownloadFileRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageDownloadFileRequest.swift index 78d7be6ffd..7ec34c222f 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageDownloadFileRequest.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageDownloadFileRequest.swift @@ -13,9 +13,15 @@ import Foundation /// - Tag: StorageDownloadFileRequest public struct StorageDownloadFileRequest: AmplifyOperationRequest { + /// The path for the object in storage + /// + /// - Tag: StorageDownloadFileRequest.path + public let path: (any StoragePath)? + /// The unique identifier for the object in storage /// /// - Tag: StorageDownloadFileRequest.key + @available(*, deprecated, message: "Use `path` instead of `key`") public let key: String /// The local file to download the object to @@ -29,10 +35,20 @@ public struct StorageDownloadFileRequest: AmplifyOperationRequest { public let options: Options /// - Tag: StorageDownloadFileRequest.init + @available(*, deprecated, message: "Use init(path:local:options)") public init(key: String, local: URL, options: Options) { self.key = key self.local = local self.options = options + self.path = nil + } + + /// - Tag: StorageDownloadFileRequest.init + public init(path: any StoragePath, local: URL, options: Options) { + self.key = "" + self.local = local + self.options = options + self.path = path } } @@ -46,11 +62,13 @@ public extension StorageDownloadFileRequest { /// Access level of the storage system. Defaults to `public` /// /// - Tag: StorageDownloadFileRequestOptions.accessLevel + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") public let accessLevel: StorageAccessLevel /// Target user to apply the action on. /// /// - Tag: StorageDownloadFileRequestOptions.targetIdentityId + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") public let targetIdentityId: String? /// Extra plugin specific options, only used in special circumstances when the existing options do not provide @@ -61,6 +79,7 @@ public extension StorageDownloadFileRequest { public let pluginOptions: Any? /// - Tag: StorageDownloadFileRequestOptions.init + @available(*, deprecated, message: "Use init(pluginOptions)") public init(accessLevel: StorageAccessLevel = .guest, targetIdentityId: String? = nil, pluginOptions: Any? = nil) { @@ -68,5 +87,13 @@ public extension StorageDownloadFileRequest { self.targetIdentityId = targetIdentityId self.pluginOptions = pluginOptions } + + /// - Tag: StorageDownloadFileRequestOptions.init + @available(*, deprecated, message: "Use init(pluginOptions)") + public init(pluginOptions: Any? = nil) { + self.accessLevel = .guest + self.targetIdentityId = nil + self.pluginOptions = pluginOptions + } } } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageGetURLRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageGetURLRequest.swift index 25c4563098..e8ffd22c00 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageGetURLRequest.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageGetURLRequest.swift @@ -14,18 +14,33 @@ public struct StorageGetURLRequest: AmplifyOperationRequest { /// The unique identifier for the object in storage /// - /// - Tag: StorageListRequest.key + /// - Tag: StorageGetURLRequest.key + @available(*, deprecated, message: "Use `path` in Storage API instead of `key`") public let key: String - /// Options to adjust the behavior of this request, including plugin-options + /// The unique path for the object in storage + /// + /// - Tag: StorageGetURLRequest.path + public let path: (any StoragePath)? + + /// Options to adjust the behaviour of this request, including plugin-options /// - /// - Tag: StorageListRequest.options + /// - Tag: StorageGetURLRequest.options public let options: Options - /// - Tag: StorageListRequest.init + /// - Tag: StorageGetURLRequest.init + @available(*, deprecated, message: "Use init(path:options)") public init(key: String, options: Options) { self.key = key self.options = options + self.path = nil + } + + /// - Tag: StorageGetURLRequest.init + public init(path: any StoragePath, options: Options) { + self.key = "" + self.options = options + self.path = path } } @@ -33,27 +48,29 @@ public extension StorageGetURLRequest { /// Options to adjust the behavior of this request, including plugin-options /// - /// - Tag: StorageListRequestOptions + /// - Tag: StorageGetURLRequest.Options struct Options { /// The default amount of time before the URL expires is 18000 seconds, or 5 hours. /// - /// - Tag: StorageListRequestOptions.defaultExpireInSeconds + /// - Tag: StorageGetURLRequest.Options.defaultExpireInSeconds public static let defaultExpireInSeconds = 18_000 /// Access level of the storage system. Defaults to `public` /// - /// - Tag: StorageListRequestOptions.accessLevel + /// - Tag: StorageGetURLRequest.Options.accessLevel + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") public let accessLevel: StorageAccessLevel /// Target user to apply the action on. /// - /// - Tag: StorageListRequestOptions.targetIdentityId + /// - Tag: StorageGetURLRequest.Options.targetIdentityId + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") public let targetIdentityId: String? /// Number of seconds before the URL expires. Defaults to /// [defaultExpireInSeconds](x-source-tag://StorageListRequestOptions.defaultExpireInSeconds) /// - /// - Tag: StorageListRequestOptions.expires + /// - Tag: StorageGetURLRequest.Options.expires public let expires: Int /// Extra plugin specific options, only used in special circumstances when the existing options do @@ -62,10 +79,11 @@ public extension StorageGetURLRequest { /// [AWSStorageGetURLOptions](x-source-tag://AWSStorageGetURLOptions) for /// expected key/values. /// - /// - Tag: StorageListRequestOptions.pluginOptions + /// - Tag: StorageGetURLRequest.Options.pluginOptions public let pluginOptions: Any? - /// - Tag: StorageListRequestOptions.init + /// - Tag: StorageGetURLRequest.Options.init + @available(*, deprecated, message: "Use init(expires:pluginOptions)") public init(accessLevel: StorageAccessLevel = .guest, targetIdentityId: String? = nil, expires: Int = Options.defaultExpireInSeconds, @@ -75,5 +93,14 @@ public extension StorageGetURLRequest { self.expires = expires self.pluginOptions = pluginOptions } + + /// - Tag: StorageGetURLRequest.Options.init + public init(expires: Int = Options.defaultExpireInSeconds, + pluginOptions: Any? = nil) { + self.expires = expires + self.pluginOptions = pluginOptions + self.accessLevel = .guest + self.targetIdentityId = nil + } } } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageListRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageListRequest.swift index d82d4f1718..6cc7dbb496 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageListRequest.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageListRequest.swift @@ -15,9 +15,22 @@ public struct StorageListRequest: AmplifyOperationRequest { /// - Tag: StorageListRequest public let options: Options + /// The unique path for the object in storage + /// + /// - Tag: StorageListRequest.path + public let path: (any StoragePath)? + /// - Tag: StorageListRequest.init + @available(*, deprecated, message: "Use init(path:options)") public init(options: Options) { self.options = options + self.path = nil + } + + /// - Tag: StorageListRequest.init + public init(path: any StoragePath, options: Options) { + self.options = options + self.path = path } } @@ -32,16 +45,19 @@ public extension StorageListRequest { /// Access level of the storage system. Defaults to `public` /// /// - Tag: StorageListRequestOptions.accessLevel + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") public let accessLevel: StorageAccessLevel /// Target user to apply the action on /// /// - Tag: StorageListRequestOptions.targetIdentityId + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") public let targetIdentityId: String? /// Path to the keys /// /// - Tag: StorageListRequestOptions.path + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") public let path: String? /// Number between 1 and 1,000 that indicates the limit of how many entries to fetch when diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageRemoveRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageRemoveRequest.swift index 91492a3c70..31ae1de4f3 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageRemoveRequest.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageRemoveRequest.swift @@ -14,17 +14,32 @@ public struct StorageRemoveRequest: AmplifyOperationRequest { /// The unique identifier for the object in storage /// /// - Tag: StorageRemoveRequest.key + @available(*, deprecated, message: "Use `path` in Storage API instead of `key`") public let key: String + /// The unique path for the object in storage + /// + /// - Tag: StorageRemoveRequest.path + public let path: (any StoragePath)? + /// Options to adjust the behavior of this request, including plugin-options /// /// - Tag: StorageRemoveRequest.options public let options: Options /// - Tag: StorageRemoveRequest.init + @available(*, deprecated, message: "Use init(path:options)") public init(key: String, options: Options) { self.key = key self.options = options + self.path = nil + } + + /// - Tag: StorageRemoveRequest.init + public init(path: any StoragePath, options: Options) { + self.key = "" + self.options = options + self.path = path } } @@ -38,6 +53,7 @@ public extension StorageRemoveRequest { /// Access level of the storage system. Defaults to `public` /// /// - Tag: StorageRemoveRequestOptions.accessLevel + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") public let accessLevel: StorageAccessLevel /// Extra plugin specific options, only used in special circumstances when the existing options do not provide diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageUploadDataRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageUploadDataRequest.swift index 2f892b936d..572b160bf8 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageUploadDataRequest.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageUploadDataRequest.swift @@ -13,9 +13,15 @@ import Foundation /// - Tag: StorageUploadDataRequest public struct StorageUploadDataRequest: AmplifyOperationRequest { + /// The path for the object in storage + /// + /// - Tag: StorageDownloadFileRequest.path + public let path: (any StoragePath)? + /// The unique identifier for the object in storage /// /// - Tag: StorageUploadDataRequest.key + @available(*, deprecated, message: "Use `path` instead of `key`") public let key: String /// The data in memory to be uploaded @@ -29,10 +35,19 @@ public struct StorageUploadDataRequest: AmplifyOperationRequest { public let options: Options /// - Tag: StorageUploadDataRequest.init + @available(*, deprecated, message: "Use init(path:data:options)") public init(key: String, data: Data, options: Options) { self.key = key self.data = data self.options = options + self.path = nil + } + + public init(path: any StoragePath, data: Data, options: Options) { + self.key = "" + self.data = data + self.options = options + self.path = path } } @@ -46,11 +61,13 @@ public extension StorageUploadDataRequest { /// Access level of the storage system. Defaults to `public` /// /// - Tag: StorageUploadDataRequestOptions.accessLevel + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") public let accessLevel: StorageAccessLevel /// Target user to apply the action on. /// /// - Tag: StorageUploadDataRequestOptions.targetIdentityId + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") public let targetIdentityId: String? /// Metadata for the object to store @@ -71,16 +88,30 @@ public extension StorageUploadDataRequest { public let pluginOptions: Any? /// - Tag: StorageUploadDataRequestOptions.init + @available(*, deprecated, message: "Use init(metadata:contentType:options)") public init(accessLevel: StorageAccessLevel = .guest, targetIdentityId: String? = nil, metadata: [String: String]? = nil, contentType: String? = nil, - pluginOptions: Any? = nil) { + pluginOptions: Any? = nil + ) { self.accessLevel = accessLevel self.targetIdentityId = targetIdentityId self.metadata = metadata self.contentType = contentType self.pluginOptions = pluginOptions } + + /// - Tag: StorageUploadDataRequestOptions.init + public init(metadata: [String: String]? = nil, + contentType: String? = nil, + pluginOptions: Any? = nil + ) { + self.accessLevel = .guest + self.targetIdentityId = nil + self.metadata = metadata + self.contentType = contentType + self.pluginOptions = pluginOptions + } } } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageUploadFileRequest.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageUploadFileRequest.swift index 9382776c5b..23c8d159f6 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageUploadFileRequest.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Operation/Request/StorageUploadFileRequest.swift @@ -13,8 +13,14 @@ import Foundation /// - Tag: StorageUploadFileRequest public struct StorageUploadFileRequest: AmplifyOperationRequest { + /// The path for the object in storage + /// + /// - Tag: StorageDownloadFileRequest.path + public let path: (any StoragePath)? + /// The unique identifier for the object in storage /// - Tag: StorageUploadFileRequest.key + @available(*, deprecated, message: "Use `path` instead of `key`") public let key: String /// The file to be uploaded @@ -26,10 +32,19 @@ public struct StorageUploadFileRequest: AmplifyOperationRequest { public let options: Options /// - Tag: StorageUploadFileRequest.init + @available(*, deprecated, message: "Use init(path:local:options)") public init(key: String, local: URL, options: Options) { self.key = key self.local = local self.options = options + self.path = nil + } + + public init(path: any StoragePath, local: URL, options: Options) { + self.key = "" + self.local = local + self.options = options + self.path = path } } @@ -43,11 +58,13 @@ public extension StorageUploadFileRequest { /// Access level of the storage system. Defaults to `public` /// /// - Tag: StorageUploadFileRequestOptions.accessLevel + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") public let accessLevel: StorageAccessLevel /// Target user to apply the action on. /// /// - Tag: StorageUploadFileRequestOptions.targetIdentityId + @available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") public let targetIdentityId: String? /// Metadata for the object to store @@ -68,16 +85,30 @@ public extension StorageUploadFileRequest { public let pluginOptions: Any? /// - Tag: StorageUploadFileRequestOptions.init + @available(*, deprecated, message: "Use init(metadata:contentType:pluginOptions)") public init(accessLevel: StorageAccessLevel = .guest, targetIdentityId: String? = nil, metadata: [String: String]? = nil, contentType: String? = nil, - pluginOptions: Any? = nil) { + pluginOptions: Any? = nil + ) { self.accessLevel = accessLevel self.targetIdentityId = targetIdentityId self.metadata = metadata self.contentType = contentType self.pluginOptions = pluginOptions } + + /// - Tag: StorageUploadFileRequestOptions.init + public init(metadata: [String: String]? = nil, + contentType: String? = nil, + pluginOptions: Any? = nil + ) { + self.accessLevel = .guest + self.targetIdentityId = nil + self.metadata = metadata + self.contentType = contentType + self.pluginOptions = pluginOptions + } } } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Result/StorageListResult.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Result/StorageListResult.swift index f01a517c44..057b9e177a 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Result/StorageListResult.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/Result/StorageListResult.swift @@ -42,9 +42,15 @@ extension StorageListResult { /// - Tag: StorageListResultItem public struct Item { + /// The path of the object in storage. + /// + /// - Tag: StorageListResultItem.path + public let path: String + /// The unique identifier of the object in storage. /// /// - Tag: StorageListResultItem.key + @available(*, deprecated, message: "Use `path` instead.") public let key: String /// Size in bytes of the object @@ -72,16 +78,35 @@ extension StorageListResult { /// [StorageCategoryBehavior.list](x-source-tag://StorageCategoryBehavior.list). /// /// - Tag: StorageListResultItem.init - public init(key: String, - size: Int? = nil, - eTag: String? = nil, - lastModified: Date? = nil, - pluginResults: Any? = nil) { + @available(*, deprecated, message: "Use init(path:size:lastModifiedDate:eTag:pluginResults)") + public init( + key: String, + size: Int? = nil, + eTag: String? = nil, + lastModified: Date? = nil, + pluginResults: Any? = nil + ) { self.key = key self.size = size self.eTag = eTag self.lastModified = lastModified self.pluginResults = pluginResults + self.path = "" + } + + public init( + path: String, + size: Int? = nil, + eTag: String? = nil, + lastModified: Date? = nil, + pluginResults: Any? = nil + ) { + self.path = path + self.key = path + self.size = size + self.eTag = eTag + self.lastModified = lastModified + self.pluginResults = pluginResults } } } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageAccessLevel.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageAccessLevel.swift index 8319818bc8..726effc9be 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageAccessLevel.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageAccessLevel.swift @@ -11,6 +11,7 @@ import Foundation /// See https://aws-amplify.github.io/docs/ios/storage#storage-access /// /// - Tag: StorageAccessLevel +@available(*, deprecated, message: "Use `path` in Storage API instead of `Options`") public enum StorageAccessLevel: String { /// Objects can be read or written by any user without authentication diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategory+ClientBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategory+ClientBehavior.swift index f36e1f8954..55b69bbe43 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategory+ClientBehavior.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategory+ClientBehavior.swift @@ -17,6 +17,14 @@ extension StorageCategory: StorageCategoryBehavior { try await plugin.getURL(key: key, options: options) } + @discardableResult + public func getURL( + path: any StoragePath, + options: StorageGetURLOperation.Request.Options? = nil + ) async throws -> URL { + try await plugin.getURL(path: path, options: options) + } + @discardableResult public func downloadData( key: String, @@ -25,6 +33,14 @@ extension StorageCategory: StorageCategoryBehavior { plugin.downloadData(key: key, options: options) } + @discardableResult + public func downloadData( + path: any StoragePath, + options: StorageDownloadDataOperation.Request.Options? = nil + ) -> StorageDownloadDataTask { + plugin.downloadData(path: path, options: options) + } + @discardableResult public func downloadFile( key: String, @@ -34,6 +50,15 @@ extension StorageCategory: StorageCategoryBehavior { plugin.downloadFile(key: key, local: local, options: options) } + @discardableResult + public func downloadFile( + path: any StoragePath, + local: URL, + options: StorageDownloadFileOperation.Request.Options? = nil + ) -> StorageDownloadFileTask { + plugin.downloadFile(path: path, local: local, options: options) + } + @discardableResult public func uploadData( key: String, @@ -43,6 +68,15 @@ extension StorageCategory: StorageCategoryBehavior { plugin.uploadData(key: key, data: data, options: options) } + @discardableResult + public func uploadData( + path: any StoragePath, + data: Data, + options: StorageUploadDataOperation.Request.Options? = nil + ) -> StorageUploadDataTask { + plugin.uploadData(path: path, data: data, options: options) + } + @discardableResult public func uploadFile( key: String, @@ -52,6 +86,15 @@ extension StorageCategory: StorageCategoryBehavior { plugin.uploadFile(key: key, local: local, options: options) } + @discardableResult + public func uploadFile( + path: any StoragePath, + local: URL, + options: StorageUploadFileOperation.Request.Options? = nil + ) -> StorageUploadFileTask { + plugin.uploadFile(path: path, local: local, options: options) + } + @discardableResult public func remove( key: String, @@ -60,6 +103,14 @@ extension StorageCategory: StorageCategoryBehavior { try await plugin.remove(key: key, options: options) } + @discardableResult + public func remove( + path: any StoragePath, + options: StorageRemoveRequest.Options? = nil + ) async throws -> String { + try await plugin.remove(path: path, options: options) + } + @discardableResult public func list( options: StorageListOperation.Request.Options? = nil @@ -67,6 +118,14 @@ extension StorageCategory: StorageCategoryBehavior { try await plugin.list(options: options) } + @discardableResult + public func list( + path: any StoragePath, + options: StorageListOperation.Request.Options? = nil + ) async throws -> StorageListResult { + try await plugin.list(path: path, options: options) + } + public func handleBackgroundEvents(identifier: String) async -> Bool { await plugin.handleBackgroundEvents(identifier: identifier) } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategoryBehavior.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategoryBehavior.swift index b09b450113..0b933d4dfc 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategoryBehavior.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StorageCategoryBehavior.swift @@ -22,9 +22,26 @@ public protocol StorageCategoryBehavior { /// - Returns: requested Get URL /// /// - Tag: StorageCategoryBehavior.getURL + @available(*, deprecated, message: "Use getURL(path:options:)") @discardableResult - func getURL(key: String, - options: StorageGetURLOperation.Request.Options?) async throws -> URL + func getURL( + key: String, + options: StorageGetURLOperation.Request.Options? + ) async throws -> URL + + /// Retrieve the remote URL for the object from storage. + /// + /// - Parameters: + /// - path: the path to the object in storage. + /// - options: Parameters to specific plugin behavior + /// - Returns: requested Get URL + /// + /// - Tag: StorageCategoryBehavior.getURL + @discardableResult + func getURL( + path: any StoragePath, + options: StorageGetURLOperation.Request.Options? + ) async throws -> URL /// Retrieve the object from storage into memory. /// @@ -34,10 +51,24 @@ public protocol StorageCategoryBehavior { /// - Returns: A task that provides progress updates and the key which was used to download /// /// - Tag: StorageCategoryBehavior.downloadData + @available(*, deprecated, message: "Use downloadData(path:options:)") @discardableResult func downloadData(key: String, options: StorageDownloadDataOperation.Request.Options?) -> StorageDownloadDataTask + /// Retrieve the object from storage into memory. + /// + /// - Parameters: + /// - path: The path for the object in storage + /// - options: Options to adjust the behavior of this request, including plugin-options + /// - Returns: A task that provides progress updates and the key which was used to download + /// + /// - Tag: StorageCategoryBehavior.downloadData + func downloadData( + path: any StoragePath, + options: StorageDownloadDataOperation.Request.Options? + ) -> StorageDownloadDataTask + /// Download to file the object from storage. /// /// - Parameters: @@ -47,10 +78,29 @@ public protocol StorageCategoryBehavior { /// - Returns: A task that provides progress updates and the key which was used to download /// /// - Tag: StorageCategoryBehavior.downloadFile + @available(*, deprecated, message: "Use downloadFile(path:options:)") @discardableResult - func downloadFile(key: String, - local: URL, - options: StorageDownloadFileOperation.Request.Options?) -> StorageDownloadFileTask + func downloadFile( + key: String, + local: URL, + options: StorageDownloadFileOperation.Request.Options? + ) -> StorageDownloadFileTask + + /// Download to file the object from storage. + /// + /// - Parameters: + /// - path: The path for the object in storage. + /// - local: The local file to download destination + /// - options: Parameters to specific plugin behavior + /// - Returns: A task that provides progress updates and the key which was used to download + /// + /// - Tag: StorageCategoryBehavior.downloadFile + @discardableResult + func downloadFile( + path: any StoragePath, + local: URL, + options: StorageDownloadFileOperation.Request.Options? + ) -> StorageDownloadFileTask /// Upload data to storage /// @@ -61,10 +111,29 @@ public protocol StorageCategoryBehavior { /// - Returns: A task that provides progress updates and the key which was used to upload /// /// - Tag: StorageCategoryBehavior.uploadData + @available(*, deprecated, message: "Use uploadData(path:options:)") @discardableResult - func uploadData(key: String, - data: Data, - options: StorageUploadDataOperation.Request.Options?) -> StorageUploadDataTask + func uploadData( + key: String, + data: Data, + options: StorageUploadDataOperation.Request.Options? + ) -> StorageUploadDataTask + + /// Upload data to storage + /// + /// - Parameters: + /// - path: The path of the object in storage. + /// - data: The data in memory to be uploaded + /// - options: Parameters to specific plugin behavior + /// - Returns: A task that provides progress updates and the key which was used to upload + /// + /// - Tag: StorageCategoryBehavior.uploadData + @discardableResult + func uploadData( + path: any StoragePath, + data: Data, + options: StorageUploadDataOperation.Request.Options? + ) -> StorageUploadDataTask /// Upload local file to storage /// @@ -75,10 +144,29 @@ public protocol StorageCategoryBehavior { /// - Returns: A task that provides progress updates and the key which was used to upload /// /// - Tag: StorageCategoryBehavior.uploadFile + @available(*, deprecated, message: "Use uploadFile(path:options:)") + @discardableResult + func uploadFile( + key: String, + local: URL, + options: StorageUploadFileOperation.Request.Options? + ) -> StorageUploadFileTask + + /// Upload local file to storage + /// + /// - Parameters: + /// - path: The path of the object in storage. + /// - local: The path to a local file. + /// - options: Parameters to specific plugin behavior + /// - Returns: A task that provides progress updates and the key which was used to upload + /// + /// - Tag: StorageCategoryBehavior.uploadFile @discardableResult - func uploadFile(key: String, - local: URL, - options: StorageUploadFileOperation.Request.Options?) -> StorageUploadFileTask + func uploadFile( + path: any StoragePath, + local: URL, + options: StorageUploadFileOperation.Request.Options? + ) -> StorageUploadFileTask /// Delete object from storage /// @@ -88,21 +176,52 @@ public protocol StorageCategoryBehavior { /// - Returns: An operation object that provides notifications and actions related to the execution of the work /// /// - Tag: StorageCategoryBehavior.remove + @available(*, deprecated, message: "Use remove(path:options:)") + @discardableResult + func remove( + key: String, + options: StorageRemoveOperation.Request.Options? + ) async throws -> String + + /// Delete object from storage + /// + /// - Parameters: + /// - path: The path of the object in storage. + /// - options: Parameters to specific plugin behavior + /// - Returns: An operation object that provides notifications and actions related to the execution of the work + /// + /// - Tag: StorageCategoryBehavior.remove @discardableResult - func remove(key: String, - options: StorageRemoveOperation.Request.Options?) async throws -> String + func remove( + path: any StoragePath, + options: StorageRemoveOperation.Request.Options? + ) async throws -> String /// List the object identifiers under the hierarchy specified by the path, relative to access level, from storage /// /// - Parameters: /// - options: Parameters to specific plugin behavior - /// - resultListener: Triggered when the list is complete /// - Returns: An operation object that provides notifications and actions related to the execution of the work /// /// - Tag: StorageCategoryBehavior.list + @available(*, deprecated, message: "Use list(path:options:)") @discardableResult func list(options: StorageListOperation.Request.Options?) async throws -> StorageListResult + /// List the object identifiers under the hierarchy specified by the path, relative to access level, from storage + /// + /// - Parameters: + /// - path: The path of the object in storage. + /// - options: Parameters to specific plugin behavior + /// - Returns: An operation object that provides notifications and actions related to the execution of the work + /// + /// - Tag: StorageCategoryBehavior.list + @discardableResult + func list( + path: any StoragePath, + options: StorageListOperation.Request.Options? + ) async throws -> StorageListResult + /// Handles background events which are related to URLSession /// - Parameter identifier: identifier /// - Returns: returns true if the identifier is handled by Amplify diff --git a/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StoragePath.swift b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StoragePath.swift new file mode 100644 index 0000000000..b3cf55867a --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Categories/Storage/StoragePath.swift @@ -0,0 +1,45 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public typealias IdentityIDPathResolver = (String) -> String + +/// Protocol that provides a closure to resolve the storage path. +/// +/// - Tag: StoragePath +public protocol StoragePath { + associatedtype Input + var resolve: (Input) -> String { get } +} + +public extension StoragePath where Self == StringStoragePath { + static func fromString(_ path: String) -> Self { + return StringStoragePath(resolve: { _ in return path }) + } +} + +public extension StoragePath where Self == IdentityIDStoragePath { + static func fromIdentityID(_ identityIdPathResolver: @escaping IdentityIDPathResolver) -> Self { + return IdentityIDStoragePath(resolve: identityIdPathResolver) + } +} + +/// Conforms to StoragePath protocol. Provides a storage path based on a string storage path. +/// +/// - Tag: StringStoragePath +public struct StringStoragePath: StoragePath { + public let resolve: (String) -> String +} + +/// Conforms to StoragePath protocol. +/// Provides a storage path constructed from an unique identity identifer. +/// +/// - Tag: IdentityStoragePath +public struct IdentityIDStoragePath: StoragePath { + public let resolve: IdentityIDPathResolver +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/AmplifyConfiguration.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/AmplifyConfiguration.swift index fb7f634348..2cb769f981 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/AmplifyConfiguration.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/AmplifyConfiguration.swift @@ -175,7 +175,7 @@ extension Amplify { /// Notifies all hub channels that Amplify is configured, in case any plugins need to be notified of the end of the /// configuration phase (e.g., to set up cross-channel dependencies) - private static func notifyAllHubChannels() { + static func notifyAllHubChannels() { let payload = HubPayload(eventName: HubPayload.EventName.Amplify.configured) for channel in HubChannel.amplifyChannels { Hub.plugins.values.forEach { $0.dispatch(to: channel, payload: payload) } @@ -210,7 +210,7 @@ extension Amplify { } //// Indicates is the runtime is for SwiftUI Previews - private static var isRunningForSwiftUIPreviews: Bool { + static var isRunningForSwiftUIPreviews: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != nil } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/AmplifyOutputsData.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/AmplifyOutputsData.swift new file mode 100644 index 0000000000..bdd7270017 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/AmplifyOutputsData.swift @@ -0,0 +1,363 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +// swiftlint:disable nesting +// `nesting` is disabled to best represent `AmplifyOutputsData` as close as possible +// to the JSON schema which is derived from. The JSON schema is hosted at +// https://github.com/aws-amplify/amplify-backend/blob/main/packages/client-config/src/client-config-schema/schema_v1.json + +/// Represents Amplify's Gen2 configuration for all categories intended to be used in an application. +/// +/// See: [Amplify.configure](x-source-tag://Amplify.configure) +/// +/// - Tag: AmplifyOutputs +/// +@_spi(InternalAmplifyConfiguration) +public struct AmplifyOutputsData: Codable { + public let version: String + public let analytics: Analytics? + public let auth: Auth? + public let data: DataCategory? + public let geo: Geo? + public let notifications: Notifications? + public let storage: Storage? + public let custom: CustomOutput? + + @_spi(InternalAmplifyConfiguration) + public struct Analytics: Codable { + public let amazonPinpoint: AmazonPinpoint? + + public struct AmazonPinpoint: Codable { + public let awsRegion: AWSRegion + public let appId: String + } + } + + @_spi(InternalAmplifyConfiguration) + public struct Auth: Codable { + public let awsRegion: AWSRegion + public let userPoolId: String + public let userPoolClientId: String + public let identityPoolId: String? + public let passwordPolicy: PasswordPolicy? + public let oauth: OAuth? + public let standardRequiredAttributes: [AmazonCognitoStandardAttributes]? + public let usernameAttributes: [UsernameAttributes]? + public let userVerificationTypes: [UserVerificationType]? + public let unauthenticatedIdentitiesEnabled: Bool? + public let mfaConfiguration: String? + public let mfaMethods: [String]? + + @_spi(InternalAmplifyConfiguration) + public struct PasswordPolicy: Codable { + public let minLength: UInt + public let requireNumbers: Bool + public let requireLowercase: Bool + public let requireUppercase: Bool + public let requireSymbols: Bool + } + + @_spi(InternalAmplifyConfiguration) + public struct OAuth: Codable { + public let identityProviders: [String] + public let domain: String + public let scopes: [String] + public let redirectSignInUri: [String] + public let redirectSignOutUri: [String] + public let responseType: String + } + + @_spi(InternalAmplifyConfiguration) + public enum UsernameAttributes: String, Codable { + case email = "email" + case phoneNumber = "phone_number" + } + + @_spi(InternalAmplifyConfiguration) + public enum UserVerificationType: String, Codable { + case email = "email" + case phoneNumber = "phone_number" + } + + init(awsRegion: AWSRegion, + userPoolId: String, + userPoolClientId: String, + identityPoolId: String? = nil, + passwordPolicy: PasswordPolicy? = nil, + oauth: OAuth? = nil, + standardRequiredAttributes: [AmazonCognitoStandardAttributes]? = nil, + usernameAttributes: [UsernameAttributes]? = nil, + userVerificationTypes: [UserVerificationType]? = nil, + unauthenticatedIdentitiesEnabled: Bool? = nil, + mfaConfiguration: String? = nil, + mfaMethods: [String]? = nil) { + self.awsRegion = awsRegion + self.userPoolId = userPoolId + self.userPoolClientId = userPoolClientId + self.identityPoolId = identityPoolId + self.passwordPolicy = passwordPolicy + self.oauth = oauth + self.standardRequiredAttributes = standardRequiredAttributes + self.usernameAttributes = usernameAttributes + self.userVerificationTypes = userVerificationTypes + self.unauthenticatedIdentitiesEnabled = unauthenticatedIdentitiesEnabled + self.mfaConfiguration = mfaConfiguration + self.mfaMethods = mfaMethods + } + + } + + @_spi(InternalAmplifyConfiguration) + public struct DataCategory: Codable { + public let awsRegion: AWSRegion + public let url: String + public let modelIntrospection: JSONValue? + public let apiKey: String? + public let defaultAuthorizationType: AWSAppSyncAuthorizationType + public let authorizationTypes: [AWSAppSyncAuthorizationType] + } + + @_spi(InternalAmplifyConfiguration) + public struct Geo: Codable { + public let awsRegion: AWSRegion + public let maps: Maps? + public let searchIndices: SearchIndices? + public let geofenceCollections: GeofenceCollections? + + @_spi(InternalAmplifyConfiguration) + public struct Maps: Codable { + public let items: [String: AmazonLocationServiceConfig] + public let `default`: String + + @_spi(InternalAmplifyConfiguration) + public struct AmazonLocationServiceConfig: Codable { + public let style: String + } + } + + @_spi(InternalAmplifyConfiguration) + public struct SearchIndices: Codable { + public let items: [String] + public let `default`: String + } + + @_spi(InternalAmplifyConfiguration) + public struct GeofenceCollections: Codable { + public let items: [String] + public let `default`: String + } + + // Internal init used for testing + init(awsRegion: AWSRegion, + maps: Maps? = nil, + searchIndices: SearchIndices? = nil, + geofenceCollections: GeofenceCollections? = nil) { + self.awsRegion = awsRegion + self.maps = maps + self.searchIndices = searchIndices + self.geofenceCollections = geofenceCollections + } + } + + @_spi(InternalAmplifyConfiguration) + public struct Notifications: Codable { + public let awsRegion: String + public let amazonPinpointAppId: String + public let channels: [AmazonPinpointChannelType] + } + + @_spi(InternalAmplifyConfiguration) + public struct Storage: Codable { + public let awsRegion: AWSRegion + public let bucketName: String + } + + @_spi(InternalAmplifyConfiguration) + public struct CustomOutput: Codable {} + + @_spi(InternalAmplifyConfiguration) + public typealias AWSRegion = String + + @_spi(InternalAmplifyConfiguration) + public enum AmazonCognitoStandardAttributes: String, Codable, CodingKeyRepresentable { + case address + case birthdate + case email + case familyName + case gender + case givenName + case locale + case middleName + case name + case nickname + case phoneNumber + case picture + case preferredUsername + case profile + case sub + case updatedAt + case website + case zoneinfo + } + + @_spi(InternalAmplifyConfiguration) + public enum AWSAppSyncAuthorizationType: String, Codable { + case amazonCognitoUserPools = "AMAZON_COGNITO_USER_POOLS" + case apiKey = "API_KEY" + case awsIAM = "AWS_IAM" + case awsLambda = "AWS_LAMBDA" + case openIDConnect = "OPENID_CONNECT" + } + + @_spi(InternalAmplifyConfiguration) + public enum AmazonPinpointChannelType: String, Codable { + case inAppMessaging = "IN_APP_MESSAGING" + case fcm = "FCM" + case apns = "APNS" + case email = "EMAIL" + case sms = "SMS" + } + + // Internal init used for testing + init(version: String = "", + analytics: Analytics? = nil, + auth: Auth? = nil, + data: DataCategory? = nil, + geo: Geo? = nil, + notifications: Notifications? = nil, + storage: Storage? = nil, + custom: CustomOutput? = nil) { + self.version = version + self.analytics = analytics + self.auth = auth + self.data = data + self.geo = geo + self.notifications = notifications + self.storage = storage + self.custom = custom + } +} +// swiftlint:enable nesting + +// MARK: - Configure + +/// Represents helper methods to configure with Amplify CLI Gen2 configuration. +public struct AmplifyOutputs { + + /// A closure that resolves the `AmplifyOutputsData` configuration + let resolveConfiguration: () throws -> AmplifyOutputsData + + /// Resolves configuration with `amplify_outputs.json` in the main bundle. + public static let amplifyOutputs: AmplifyOutputs = { + .init { + try AmplifyOutputsData(bundle: Bundle.main, resource: "amplify_outputs") + } + }() + + /// Resolves configuration with a data object, from the contents of an `amplify_outputs.json` file. + public static func data(_ data: Data) -> AmplifyOutputs { + .init { + try AmplifyOutputsData.decodeAmplifyOutputsData(from: data) + } + } + + /// Resolves configuration with the resource in the main bundle. + public static func resource(named resource: String) -> AmplifyOutputs { + .init { + try AmplifyOutputsData(bundle: Bundle.main, resource: resource) + } + } +} + +extension Amplify { + + /// API to configure with Amplify CLI Gen2's configuration. + /// + /// - Parameter with: `AmplifyOutputs` configuration resolver + public static func configure(with amplifyOutputs: AmplifyOutputs) throws { + do { + let resolvedConfiguration = try amplifyOutputs.resolveConfiguration() + try configure(resolvedConfiguration) + } catch { + log.info("Failed to find configuration.") + if isRunningForSwiftUIPreviews { + log.info("Running for SwiftUI previews with no configuration file present, skipping configuration.") + return + } else { + throw error + } + } + } + + /// Configures Amplify with the specified configuration. + /// + /// This method must be invoked after registering plugins, and before using any Amplify category. It must not be + /// invoked more than once. + /// + /// **Lifecycle** + /// + /// Internally, Amplify configures the Hub and Logging categories first, so they are available to plugins in the + /// remaining categories during the configuration phase. Plugins for the Hub and Logging categories must not + /// assume that any other categories are available. + /// + /// After Amplify has configured all of its categories, it will dispatch a `HubPayload.EventName.Amplify.configured` + /// event to each Amplify Hub channel. After this point, plugins may invoke calls on other Amplify categories. + /// + /// - Parameter configuration: The AmplifyOutputsData object + /// + /// - Tag: Amplify.configure + @_spi(InternalAmplifyConfiguration) + public static func configure(_ configuration: AmplifyOutputsData) throws { + // Always configure logging first since Auth dependings on logging + try configure(CategoryType.logging.category, using: configuration) + + // Always configure Hub and Auth next, so they are available to other categories. + // Auth is a special case for other plugins which depend on using Auth when being configured themselves. + let manuallyConfiguredCategories = [CategoryType.hub, .auth] + for categoryType in manuallyConfiguredCategories { + try configure(categoryType.category, using: configuration) + } + + // Looping through all categories to ensure we don't accidentally forget a category at some point in the future + let remainingCategories = CategoryType.allCases.filter { !manuallyConfiguredCategories.contains($0) } + for categoryType in remainingCategories { + switch categoryType { + case .analytics: + try configure(Analytics, using: configuration) + case .api: + try configure(API, using: configuration) + case .dataStore: + try configure(DataStore, using: configuration) + case .geo: + try configure(Geo, using: configuration) + case .predictions: + try configure(Predictions, using: configuration) + case .pushNotifications: + try configure(Notifications.Push, using: configuration) + case .storage: + try configure(Storage, using: configuration) + case .hub, .logging, .auth: + // Already configured + break + } + } + isConfigured = true + + notifyAllHubChannels() + } + + /// If `candidate` is `CategoryConfigurable`, then invokes `candidate.configure(using: configuration)`. + private static func configure(_ candidate: Category, using configuration: AmplifyOutputsData) throws { + guard let configurable = candidate as? CategoryConfigurable else { + return + } + + try configurable.configure(using: configuration) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/ConfigurationError.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/ConfigurationError.swift index 48c52986b8..36d7d7abab 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/ConfigurationError.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/ConfigurationError.swift @@ -21,6 +21,11 @@ public enum ConfigurationError { /// - Tag: ConfigurationError.invalidAmplifyConfigurationFile case invalidAmplifyConfigurationFile(ErrorDescription, RecoverySuggestion, Error? = nil) + /// The specified `amplify_outputs.json` file was not present or unreadable + /// + /// - Tag: ConfigurationError.invalidAmplifyOutputsFile + case invalidAmplifyOutputsFile(ErrorDescription, RecoverySuggestion, Error? = nil) + /// Unable to decode `amplifyconfiguration.json` into a valid AmplifyConfiguration object /// /// - Tag: ConfigurationError.unableToDecode @@ -38,6 +43,7 @@ extension ConfigurationError: AmplifyError { switch self { case .amplifyAlreadyConfigured(let description, _, _), .invalidAmplifyConfigurationFile(let description, _, _), + .invalidAmplifyOutputsFile(let description, _, _), .unableToDecode(let description, _, _), .unknown(let description, _, _): return description @@ -49,6 +55,7 @@ extension ConfigurationError: AmplifyError { switch self { case .amplifyAlreadyConfigured(_, let recoverySuggestion, _), .invalidAmplifyConfigurationFile(_, let recoverySuggestion, _), + .invalidAmplifyOutputsFile(_, let recoverySuggestion, _), .unableToDecode(_, let recoverySuggestion, _), .unknown(_, let recoverySuggestion, _): return recoverySuggestion @@ -60,6 +67,7 @@ extension ConfigurationError: AmplifyError { switch self { case .amplifyAlreadyConfigured(_, _, let underlyingError), .invalidAmplifyConfigurationFile(_, _, let underlyingError), + .invalidAmplifyOutputsFile(_, _, let underlyingError), .unableToDecode(_, _, let underlyingError), .unknown(_, _, let underlyingError): return underlyingError diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Amplify+Resolve.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Amplify+Resolve.swift index 27e9e7d3c0..43e2f7cc4b 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Amplify+Resolve.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Amplify+Resolve.swift @@ -16,5 +16,4 @@ extension Amplify { return try AmplifyConfiguration(bundle: Bundle.main) } - } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/AmplifyConfigurationInitialization.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/AmplifyConfigurationInitialization.swift index 7b1df887db..4c2b0ebcda 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/AmplifyConfigurationInitialization.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/AmplifyConfigurationInitialization.swift @@ -73,3 +73,73 @@ extension AmplifyConfiguration { } } + +extension AmplifyOutputsData { + init(bundle: Bundle, resource: String) throws { + guard let path = bundle.path(forResource: resource, ofType: "json") else { + throw ConfigurationError.invalidAmplifyOutputsFile( + """ + Could not load default `\(resource).json` file + """, + + """ + Expected to find the file, `\(resource).json` in the app bundle at `\(bundle.bundlePath)`, but + it was not present. Add `\(resource).json` to your app's "Copy Bundle Resources" build + phase and invoke `Amplify.configure(with: resource(named: "\(resource)")` with a configuration + object that you load. If your resource file is the default `amplify_outputs.json`, you can + invoke `Amplify.configure(with: .amplifyOutputs)` instead. + """ + ) + } + + let url = URL(fileURLWithPath: path) + + self = try AmplifyOutputsData.loadAmplifyOutputsData(from: url) + } + + static func loadAmplifyOutputsData(from url: URL) throws -> AmplifyOutputsData { + let fileData: Data + do { + fileData = try Data(contentsOf: url) + } catch { + throw ConfigurationError.invalidAmplifyOutputsFile( + """ + Could not extract UTF-8 data from `\(url.path)` + """, + + """ + Could not load data from the file at `\(url.path)`. Inspect the file to ensure it is present. + The system reported the following error: + \(error.localizedDescription) + """, + error + ) + } + + return try decodeAmplifyOutputsData(from: fileData) + } + + static func decodeAmplifyOutputsData(from data: Data) throws -> AmplifyOutputsData { + let jsonDecoder = JSONDecoder() + + do { + jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase + let configuration = try jsonDecoder.decode(AmplifyOutputsData.self, from: data) + return configuration + } catch { + throw ConfigurationError.unableToDecode( + """ + Could not decode `amplify_outputs.json`. + """, + + """ + `amplify_outputs.json` was found, but could not be converted to an object + using JSONDecoder. The system reported the following error: + \(error.localizedDescription) + """, + error + ) + } + } + +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Category+Configuration.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Category+Configuration.swift index f3ae70888f..ea8e81af0a 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Category+Configuration.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/Category+Configuration.swift @@ -37,5 +37,4 @@ extension CategoryTypeable { return amplifyConfiguration.auth } } - } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/CategoryConfigurable.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/CategoryConfigurable.swift index 8873a30a77..d92c18d93d 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/CategoryConfigurable.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Configuration/Internal/CategoryConfigurable.swift @@ -20,7 +20,11 @@ protocol CategoryConfigurable: AnyObject, CategoryTypeable { /// - Parameter amplifyConfiguration: The AmplifyConfiguration func configure(using amplifyConfiguration: AmplifyConfiguration) throws + /// Convenience method for configuring the category using the top-level AmplifyOutputsData + /// + /// - Parameter amplifyOutputs: The AmplifyOutputsData configuration + func configure(using amplifyOutputs: AmplifyOutputsData) throws + /// Clears the category configurations, and invokes `reset` on each added plugin func reset() async - } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyAsyncThrowingSequence.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyAsyncThrowingSequence.swift index 38772392da..6a4841f13b 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyAsyncThrowingSequence.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyAsyncThrowingSequence.swift @@ -6,6 +6,7 @@ // import Foundation +import Combine public typealias WeakAmplifyAsyncThrowingSequenceRef = WeakRef> @@ -49,4 +50,5 @@ public class AmplifyAsyncThrowingSequence: AsyncSequence, Can parent?.cancel() finish() } + } diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTask+OperationTaskAdapters.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTask+OperationTaskAdapters.swift index fb56c6df18..50e505bce9 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTask+OperationTaskAdapters.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTask+OperationTaskAdapters.swift @@ -20,7 +20,9 @@ public class AmplifyOperationTaskAdapter) { self.operation = operation self.childTask = ChildTask(parent: operation) - resultToken = operation.subscribe(resultListener: resultListener) + resultToken = operation.subscribe { [weak self] in + self?.resultListener($0) + } } deinit { diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTaskExecution.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTaskExecution.swift new file mode 100644 index 0000000000..ff73c60f26 --- /dev/null +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/AmplifyTaskExecution.swift @@ -0,0 +1,71 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +import Foundation + +/// Task that supports hub with execution of a single unit of work. . +/// +/// See Also: [AmplifyTask](x-source-tag://AmplifyTask) +/// +/// - Tag: AmplifyTaskExecution +public protocol AmplifyTaskExecution { + + associatedtype Success + associatedtype Request + associatedtype Failure: AmplifyError + + typealias AmplifyTaskExecutionResult = Result + + /// Blocks until the receiver has successfully collected a result or throws if an error was encountered. + /// + /// - Tag: AmplifyTaskExecution.value + var value: Success { get async throws } + + /// Hub event name for the task + /// + /// - Tag: AmplifyTaskExecution.eventName + var eventName: HubPayloadEventName { get } + + /// Category for which the Hub event would be dispatched for. + /// + /// - Tag: AmplifyTaskExecution.eventNameCategoryType + var eventNameCategoryType: CategoryType { get } + + /// Executes work represented by the receiver. + /// + /// - Tag: AmplifyTaskExecution.execute + func execute() async throws -> Success + + /// Dispatches a hub event. + /// + /// - Tag: AmplifyTaskExecution.dispatch + func dispatch(result: AmplifyTaskExecutionResult) + +} + +public extension AmplifyTaskExecution where Self: DefaultLogger { + var value: Success { + get async throws { + do { + log.info("Starting execution for \(eventName)") + let valueReturned = try await execute() + log.info("Successfully completed execution for \(eventName) with result:\n\(valueReturned)") + dispatch(result: .success(valueReturned)) + return valueReturned + } catch let error as Failure { + log.error("Failed execution for \(eventName) with error:\n\(error)") + dispatch(result: .failure(error)) + throw error + } + } + } + + func dispatch(result: AmplifyTaskExecutionResult) { + let channel = HubChannel(from: eventNameCategoryType) + let payload = HubPayload(eventName: eventName, context: nil, data: result) + Amplify.Hub.dispatch(to: channel, payload: payload) + } +} diff --git a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/TaskQueue.swift b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/TaskQueue.swift index f281c57e1c..a09bfcc4d8 100644 --- a/packages/amplify_datastore/ios/internal/Amplify/Core/Support/TaskQueue.swift +++ b/packages/amplify_datastore/ios/internal/Amplify/Core/Support/TaskQueue.swift @@ -8,10 +8,25 @@ import Foundation /// A helper for executing asynchronous work serially. -public actor TaskQueue { - private var previousTask: Task? +public class TaskQueue { + typealias Block = @Sendable () async -> Void + private var streamContinuation: AsyncStream.Continuation! - public init() {} + public init() { + let stream = AsyncStream.init { continuation in + streamContinuation = continuation + } + + Task { + for await block in stream { + _ = await block() + } + } + } + + deinit { + streamContinuation.finish() + } /// Serializes asynchronous requests made from an async context /// @@ -25,17 +40,31 @@ public actor TaskQueue { /// TaskQueue serializes this work so that `doAsync1` is performed before `doAsync2`, /// which is performed before `doAsync3`. public func sync(block: @Sendable @escaping () async throws -> Success) async throws -> Success { - let currentTask: Task = Task { [previousTask] in - _ = await previousTask?.result - return try await block() + try await withCheckedThrowingContinuation { continuation in + streamContinuation.yield { + do { + let value = try await block() + continuation.resume(returning: value) + } catch { + continuation.resume(throwing: error) + } + } } - previousTask = currentTask - return try await currentTask.value } - public nonisolated func async(block: @Sendable @escaping () async throws -> Success) rethrows { - Task { - try await sync(block: block) + public func async(block: @Sendable @escaping () async throws -> Success) { + streamContinuation.yield { + do { + _ = try await block() + } catch { + Self.log.warn("Failed to handle async task in TaskQueue<\(Success.self)> with error: \(error)") + } } } } + +extension TaskQueue { + public static var log: Logger { + Amplify.Logging.logger(forNamespace: String(describing: self)) + } +} From 83e1ce3ea19d79c828fed310e8c94f3f97c9879d Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Tue, 7 May 2024 12:45:12 -0500 Subject: [PATCH 2/5] chore(datastore): add subscribe() impl --- .../ios/Classes/FlutterApiPlugin.swift | 91 +++++++++- .../Classes/SwiftAmplifyDataStorePlugin.swift | 12 +- .../api/GraphQLRequest+Extension.swift | 26 +++ .../Classes/api/GraphQLResponse+Decode.swift | 168 ++++++++++++++++++ .../ios/Classes/api/Publisher+Extension.swift | 29 +++ .../Classes/types/hub/FlutterHubElement.swift | 6 +- .../lib/amplify_datastore.dart | 29 ++- .../lib/src/utils/native_api_helpers.dart | 66 +++++++ 8 files changed, 413 insertions(+), 14 deletions(-) create mode 100644 packages/amplify_datastore/ios/Classes/api/GraphQLRequest+Extension.swift create mode 100644 packages/amplify_datastore/ios/Classes/api/GraphQLResponse+Decode.swift create mode 100644 packages/amplify_datastore/ios/Classes/api/Publisher+Extension.swift create mode 100644 packages/amplify_datastore/lib/src/utils/native_api_helpers.dart diff --git a/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift b/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift index 3f80ff37a0..3655b0603a 100644 --- a/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift +++ b/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift @@ -9,8 +9,19 @@ import Combine public class FlutterApiPlugin: APICategoryPlugin { public var key: PluginKey = "awsAPIPlugin" - init() { - + private let apiAuthFactory: APIAuthProviderFactory + private let nativeApiPlugin: NativeApiPlugin + private let nativeSubscriptionEvents: PassthroughSubject + private var cancellables = Set() + + init( + apiAuthProviderFactory: APIAuthProviderFactory, + nativeApiPlugin: NativeApiPlugin, + subscriptionEventBus: PassthroughSubject + ) { + self.apiAuthFactory = apiAuthProviderFactory + self.nativeApiPlugin = nativeApiPlugin + self.nativeSubscriptionEvents = subscriptionEventBus } // TODO: Implment in Async Swift v2 @@ -23,15 +34,81 @@ public class FlutterApiPlugin: APICategoryPlugin preconditionFailure("method not supported") } - // TODO: Implment in Async Swift v2 - public func subscribe(request: GraphQLRequest) -> AmplifyAsyncThrowingSequence> where R : Decodable { - preconditionFailure("method not supported") + public func subscribe( + request: GraphQLRequest + ) -> AmplifyAsyncThrowingSequence> where R : Decodable { + var subscriptionId: String? = "" + + // TODO: write a e2e test to ensure we don't go over 100 AppSync connections + func unsubscribe(subscriptionId: String?){ + if let subscriptionId { + DispatchQueue.main.async { + self.nativeApiPlugin.unsubscribe(subscriptionId: subscriptionId) {} + } + } + } + + // TODO: shouldn't there be a timeout if there is no start_ack returned in a certain period of time + let (sequence, cancellable) = nativeSubscriptionEvents + .filter { $0.subscriptionId == subscriptionId } + .handleEvents(receiveCompletion: {_ in + unsubscribe(subscriptionId: subscriptionId) + }, receiveCancel: { + unsubscribe(subscriptionId: subscriptionId) + }) + .compactMap { [weak self] event -> GraphQLSubscriptionEvent? in + switch event.type { + case "connecting": + return .connection(.connecting) + case "start_ack": + return .connection(.connected) + case "complete": + return .connection(.disconnected) + case "data": + if let responseDecoded: GraphQLResponse = + try? self?.decodePayloadJson(request: request, payload: event.payloadJson) + { + return .data(responseDecoded) + } + return nil + case "error": + // TODO: (5d) error parsing + print("received error: \(String(describing: event.payloadJson))") + return nil + default: + print("ERROR unsupported subscription event type! \(String(describing: event.type))") + return nil + } + } + .toAmplifyAsyncThrowingSequence() + cancellables.insert(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 + subscriptionId = response.subscriptionId + } + } + return sequence + } + + private func decodePayloadJson( + request: GraphQLRequest, + payload: String? + ) throws -> GraphQLResponse { + guard let payload else { + throw DataStoreError.decodingError("Request payload could not be empty", "") + } + + return GraphQLResponse.fromAppSyncResponse( + string: payload, + decodePath: request.decodePath + ) } public func configure(using configuration: Any?) throws { } public func apiAuthProviderFactory() -> APIAuthProviderFactory { - preconditionFailure("method not supported") + return self.apiAuthFactory } public func add(interceptor: any URLRequestInterceptor, for apiName: String) throws { @@ -67,7 +144,7 @@ public class FlutterApiPlugin: APICategoryPlugin } public func reachabilityPublisher() throws -> AnyPublisher? { - preconditionFailure("method not supported") + return nil } diff --git a/packages/amplify_datastore/ios/Classes/SwiftAmplifyDataStorePlugin.swift b/packages/amplify_datastore/ios/Classes/SwiftAmplifyDataStorePlugin.swift index d75227237a..3ef0e01232 100644 --- a/packages/amplify_datastore/ios/Classes/SwiftAmplifyDataStorePlugin.swift +++ b/packages/amplify_datastore/ios/Classes/SwiftAmplifyDataStorePlugin.swift @@ -14,6 +14,7 @@ public class SwiftAmplifyDataStorePlugin: NSObject, FlutterPlugin, NativeAmplify private let customTypeSchemaRegistry: FlutterSchemaRegistry private let dataStoreObserveEventStreamHandler: DataStoreObserveEventStreamHandler? private let dataStoreHubEventStreamHandler: DataStoreHubEventStreamHandler? + private let nativeSubscriptionEventBus = PassthroughSubject() private var channel: FlutterMethodChannel? private var observeSubscription: AnyCancellable? private let nativeAuthPlugin: NativeAuthPlugin @@ -88,7 +89,14 @@ public class SwiftAmplifyDataStorePlugin: NSObject, FlutterPlugin, NativeAmplify AWSAuthorizationType(rawValue: $0) } try Amplify.add( - plugin: FlutterApiPlugin() + plugin: FlutterApiPlugin( + apiAuthProviderFactory: FlutterAuthProviders( + authProviders: authProviders, + nativeApiPlugin: nativeApiPlugin + ), + nativeApiPlugin: nativeApiPlugin, + subscriptionEventBus: nativeSubscriptionEventBus + ) ) return completion(.success(())) } catch let apiError as APIError { @@ -608,7 +616,7 @@ public class SwiftAmplifyDataStorePlugin: NSObject, FlutterPlugin, NativeAmplify } func sendSubscriptionEvent(event: NativeGraphQLSubscriptionResponse, completion: @escaping (Result) -> Void) { - fatalError("not implemented") + nativeSubscriptionEventBus.send(event) } private func checkArguments(args: Any) throws -> [String: Any] { diff --git a/packages/amplify_datastore/ios/Classes/api/GraphQLRequest+Extension.swift b/packages/amplify_datastore/ios/Classes/api/GraphQLRequest+Extension.swift new file mode 100644 index 0000000000..374da77e7d --- /dev/null +++ b/packages/amplify_datastore/ios/Classes/api/GraphQLRequest+Extension.swift @@ -0,0 +1,26 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import Foundation +import Amplify + +extension GraphQLRequest { + func toNativeGraphQLRequest() -> NativeGraphQLRequest { + let variablesJson = self.variables + .flatMap { try? JSONSerialization.data(withJSONObject: $0, options: []) } + .flatMap { String(data: $0, encoding: .utf8) } + + return NativeGraphQLRequest( + document: self.document, + apiName: self.apiName, + variablesJson: variablesJson ?? "{}", + responseType: String(describing: self.responseType), + decodePath: self.decodePath + ) + } +} diff --git a/packages/amplify_datastore/ios/Classes/api/GraphQLResponse+Decode.swift b/packages/amplify_datastore/ios/Classes/api/GraphQLResponse+Decode.swift new file mode 100644 index 0000000000..60f67e80e5 --- /dev/null +++ b/packages/amplify_datastore/ios/Classes/api/GraphQLResponse+Decode.swift @@ -0,0 +1,168 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import Foundation +import Amplify +import AWSPluginsCore + +extension GraphQLResponse { + static var jsonDecoder: JSONDecoder { + let decoder = JSONDecoder(dateDecodingStrategy: ModelDateFormatting.decodingStrategy) + return decoder + } + + static var jsonEncoder: JSONEncoder { + let encoder = JSONEncoder(dateEncodingStrategy: ModelDateFormatting.encodingStrategy) + return encoder + } + + public static func fromAppSyncResponse( + string: String, + decodePath: String?, + modelName: String? = nil + ) -> GraphQLResponse { + guard let data = string.data(using: .utf8) else { + return .failure(.transformationError( + string, + .operationError("Unable to deserialize json data", "Check the event structure.", nil) + )) + } + return fromAppSyncResponse(data: data, decodePath: decodePath, modelName: modelName) + } + + public static func fromAppSyncResponse( + data: Data, + decodePath: String?, + modelName: String? = nil + ) -> GraphQLResponse { + toJson(data: data) + .flatMap { fromAppSyncResponse(json: $0, decodePath: decodePath, modelName: modelName) } + .mapError { + if let response = String(data: data, encoding: .utf8) { + return .transformationError(response, $0) + } else { + return .transformationError("Response is not string encodable", $0) + } + } + } + + static func fromAppSyncResponse( + json: JSONValue, + decodePath: String?, + modelName: String? + ) -> Result { + if let decodePath { + if let payload = json.value(at: decodePath) { + return decodeDataPayload(payload, modelName: modelName) + } else { + return .failure(.operationError("Empty data on decode path \(decodePath)", "", nil)) + } + } else { + return decodeDataPayload(json, modelName: modelName) + } + } + + static func decodeDataPayload( + _ dataPayload: JSONValue, + modelName: String? + ) -> Result { + if R.Type.self == String.self { + return decodeDataPayloadToString(dataPayload).map { $0 as! R } + } + + let dataPayloadWithTypeName = modelName.flatMap { + dataPayload.asObject?.merging( + ["__typename": .string($0)] + ) { a, _ in a } + }.map { JSONValue.object($0) } ?? dataPayload + + if R.Type.self == AnyModel.self { + return decodeDataPayloadToAnyModel(dataPayloadWithTypeName).map { $0 as! R } + } + + return fromJson(dataPayloadWithTypeName) + .flatMap { data in + Result { try jsonDecoder.decode(R.self, from: data) } + .mapError { APIError.operationError("Could not decode json to type \(R.self)", "", $0)} + } + } + + static func decodeDataPayloadToAnyModel( + _ dataPaylod: JSONValue + ) -> Result { + guard let typeName = dataPaylod.__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: + \(dataPaylod) + """ + )) + } + + return decodeDataPayloadToString(dataPaylod).flatMap { underlyingModelString in + do { + return .success(.init(try ModelRegistry.decode( + modelName: typeName, + from: underlyingModelString, + jsonDecoder: jsonDecoder + ))) + } catch { + return .failure(.operationError( + "Could not decode to \(typeName) with \(underlyingModelString)", + "" + )) + } + } + } + + static func decodeDataPayloadToString( + _ dataPayload: JSONValue + ) -> Result { + do { + let data = try jsonEncoder.encode(dataPayload) + guard let string = String(data: data, encoding: .utf8) else { + return .failure( + .operationError("Could not get String from Data", "", nil) + ) + } + return .success(string) + } catch { + return .failure(.operationError( + "Could not get the String representation of the GraphQL response", + "" + )) + } + } + + static func toJson(data: Data) -> Result { + do { + return .success(try jsonDecoder.decode(JSONValue.self, from: data)) + } catch { + return .failure(.operationError( + "Could not decode to JSONValue from GraphQL Response", + "Service issue", + error + )) + } + } + + static func fromJson(_ json: JSONValue) -> Result { + do { + return .success(try jsonEncoder.encode(json)) + } catch { + return .failure(.operationError( + "Could not encode JSONValue to Data", + "", + error + )) + } + } + +} diff --git a/packages/amplify_datastore/ios/Classes/api/Publisher+Extension.swift b/packages/amplify_datastore/ios/Classes/api/Publisher+Extension.swift new file mode 100644 index 0000000000..8b1c408ab2 --- /dev/null +++ b/packages/amplify_datastore/ios/Classes/api/Publisher+Extension.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import Foundation +import Combine +import Amplify + +extension Publisher { + func toAmplifyAsyncThrowingSequence() -> (AmplifyAsyncThrowingSequence, AnyCancellable) { + let sequence = AmplifyAsyncThrowingSequence() + let cancellable = self.sink { completion in + switch completion { + case .finished: + sequence.finish() + case .failure(let error): + sequence.fail(error) + } + } receiveValue: { data in + sequence.send(data) + } + + return (sequence, cancellable) + } +} diff --git a/packages/amplify_datastore/ios/Classes/types/hub/FlutterHubElement.swift b/packages/amplify_datastore/ios/Classes/types/hub/FlutterHubElement.swift index 533e6b107e..ea61a0d47c 100644 --- a/packages/amplify_datastore/ios/Classes/types/hub/FlutterHubElement.swift +++ b/packages/amplify_datastore/ios/Classes/types/hub/FlutterHubElement.swift @@ -52,11 +52,11 @@ public struct FlutterHubElement { self.version = hubElement.version self.deleted = self.model["_deleted"] as? Bool ?? false if let value = self.model["_lastChangedAt"] as? Double { - self.lastChangedAt = Int(value) + self.lastChangedAt = Int64(value) } else if let value = self.model["_lastChangedAt"] as? String { - self.lastChangedAt = Int(value) + self.lastChangedAt = Int64(value) } else if let value = self.model["_lastChangedAt"] as? Int { - self.lastChangedAt = value + self.lastChangedAt = Int64(value) } } catch { throw FlutterDataStoreError.hubEventCast diff --git a/packages/amplify_datastore/lib/amplify_datastore.dart b/packages/amplify_datastore/lib/amplify_datastore.dart index c269b27b59..e3755f8524 100644 --- a/packages/amplify_datastore/lib/amplify_datastore.dart +++ b/packages/amplify_datastore/lib/amplify_datastore.dart @@ -9,6 +9,7 @@ import 'package:amplify_datastore/src/amplify_datastore_stream_controller.dart'; import 'package:amplify_datastore/src/datastore_plugin_options.dart'; import 'package:amplify_datastore/src/method_channel_datastore.dart'; import 'package:amplify_datastore/src/native_plugin.g.dart'; +import 'package:amplify_datastore/src/utils/native_api_helpers.dart'; import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; import 'package:meta/meta.dart'; @@ -296,6 +297,9 @@ class _NativeAmplifyApi final Map, APIAuthProvider> _authProviders; + final Map>> + _subscriptionsCache = {}; + @override String get runtimeTypeName => '_NativeAmplifyApi'; @@ -328,11 +332,32 @@ class _NativeAmplifyApi @override Future subscribe( NativeGraphQLRequest request) async { - throw UnimplementedError(); + final flutterRequest = NativeRequestToGraphQLRequest(request); + + final operation = Amplify.API.subscribe(flutterRequest, + onEstablished: () => sendNativeStartAckEvent(flutterRequest.id)); + + 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); + }, + onDone: () => sendNativeCompleteEvent(flutterRequest.id)); + + _subscriptionsCache[flutterRequest.id] = subscription; + + return getConnectingEvent(flutterRequest.id); } @override Future unsubscribe(String subscriptionId) async { - throw UnimplementedError(); + final subscription = _subscriptionsCache[subscriptionId]; + if (subscription != null) { + await subscription.cancel(); + _subscriptionsCache.remove(subscriptionId); + } } } diff --git a/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart b/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart new file mode 100644 index 0000000000..0d57cf021e --- /dev/null +++ b/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; + +import 'package:amplify_core/amplify_core.dart'; +import 'package:amplify_datastore/src/native_plugin.g.dart'; + +/// Convert a [NativeGraphQLResponse] to a [GraphQLResponse] +GraphQLRequest NativeRequestToGraphQLRequest( + NativeGraphQLRequest request) { + return GraphQLRequest( + document: request.document, + variables: jsonDecode(request.variablesJson ?? '{}'), + apiName: request.apiName, + ); +} + +/// Returns a connecting event [NativeGraphQLResponse] for the given [subscriptionId] +NativeGraphQLSubscriptionResponse getConnectingEvent(String subscriptionId) { + final event = NativeGraphQLSubscriptionResponse( + subscriptionId: subscriptionId, + type: 'connecting', + ); + return event; +} + +/// Send a subscription event to the platform side +void _sendSubscriptionEvent(NativeGraphQLSubscriptionResponse event) { + NativeApiBridge().sendSubscriptionEvent(event); +} + +/// Send a start_ack event for the given [subscriptionId] +void sendNativeStartAckEvent(String subscriptionId) { + final event = NativeGraphQLSubscriptionResponse( + subscriptionId: subscriptionId, + type: 'start_ack', + ); + _sendSubscriptionEvent(event); +} + +/// Send a data event for the given [subscriptionId] and [payloadJson] +void sendNativeDataEvent(String subscriptionId, String? payloadJson) { + final event = NativeGraphQLSubscriptionResponse( + subscriptionId: subscriptionId, + payloadJson: payloadJson, + type: 'data', + ); + _sendSubscriptionEvent(event); +} + +/// Send an error event for the given [subscriptionId] and [errorPayload] +void sendNativeErrorEvent(String subscriptionId, String errorPayload) { + final event = NativeGraphQLSubscriptionResponse( + subscriptionId: subscriptionId, + payloadJson: errorPayload, + type: 'error', + ); + _sendSubscriptionEvent(event); +} + +/// Send a complete event for the given [subscriptionId] +void sendNativeCompleteEvent(String subscriptionId) { + final event = NativeGraphQLSubscriptionResponse( + subscriptionId: subscriptionId, + type: 'complete', + ); + _sendSubscriptionEvent(event); +} From 2d57d842596d90bac909a3a6a51edff9bb34b55b Mon Sep 17 00:00:00 2001 From: Di Wu Date: Mon, 6 May 2024 16:46:28 -0700 Subject: [PATCH 3/5] port graphql error decoding logic --- .../ios/Classes/FlutterApiPlugin.swift | 25 +++- .../Classes/api/GraphQLResponse+Decode.swift | 116 +++++++++++++++--- 2 files changed, 118 insertions(+), 23 deletions(-) diff --git a/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift b/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift index 3655b0603a..c5efe084b0 100644 --- a/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift +++ b/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift @@ -66,15 +66,16 @@ public class FlutterApiPlugin: APICategoryPlugin return .connection(.disconnected) case "data": if let responseDecoded: GraphQLResponse = - try? self?.decodePayloadJson(request: request, payload: event.payloadJson) + try? self?.decodeGraphQLPayloadJson(request: request, payload: event.payloadJson) { return .data(responseDecoded) } return nil case "error": - // TODO: (5d) error parsing - print("received error: \(String(describing: event.payloadJson))") - return nil + if let payload = event.payloadJson { + return .data(.fromAppSyncSubscriptionErrorResponse(string: payload)) + } + return nil default: print("ERROR unsupported subscription event type! \(String(describing: event.type))") return nil @@ -91,7 +92,7 @@ public class FlutterApiPlugin: APICategoryPlugin return sequence } - private func decodePayloadJson( + private func decodeGraphQLPayloadJson( request: GraphQLRequest, payload: String? ) throws -> GraphQLResponse { @@ -104,6 +105,20 @@ public class FlutterApiPlugin: APICategoryPlugin decodePath: request.decodePath ) } + + private func decodeGraphQLSubscriptionPayloadJson( + request: GraphQLRequest, + payload: String? + ) throws -> GraphQLResponse { + guard let payload else { + throw DataStoreError.decodingError("Request payload could not be empty", "") + } + + return GraphQLResponse.fromAppSyncSubscriptionResponse( + string: payload, + decodePath: request.decodePath + ) + } public func configure(using configuration: Any?) throws { } diff --git a/packages/amplify_datastore/ios/Classes/api/GraphQLResponse+Decode.swift b/packages/amplify_datastore/ios/Classes/api/GraphQLResponse+Decode.swift index 60f67e80e5..272d20db73 100644 --- a/packages/amplify_datastore/ios/Classes/api/GraphQLResponse+Decode.swift +++ b/packages/amplify_datastore/ios/Classes/api/GraphQLResponse+Decode.swift @@ -29,44 +29,124 @@ extension GraphQLResponse { guard let data = string.data(using: .utf8) else { return .failure(.transformationError( string, - .operationError("Unable to deserialize json data", "Check the event structure.", nil) + .unknown("Unable to deserialize json data", "Check the event structure.") )) } - return fromAppSyncResponse(data: data, decodePath: decodePath, modelName: modelName) + + let result: Result, APIError> = toJson(data: data).flatMap { + fromAppSyncResponse(json: $0, decodePath: decodePath, modelName: modelName) + } + + switch result { + case .success(let response): return response + case .failure(let error): return .failure(.transformationError(string, error)) + } } - public static func fromAppSyncResponse( - data: Data, + public static func fromAppSyncSubscriptionResponse( + string: String, decodePath: String?, modelName: String? = nil ) -> GraphQLResponse { - toJson(data: data) - .flatMap { fromAppSyncResponse(json: $0, decodePath: decodePath, modelName: modelName) } - .mapError { - if let response = String(data: data, encoding: .utf8) { - return .transformationError(response, $0) + guard let data = string.data(using: .utf8) else { + return .failure(.transformationError( + string, + .unknown("Unable to deserialize json data", "Check the event structure.") + )) + } + + return toJson(data: data) + .flatMap { + if let decodePath, let data = $0.value(at: decodePath) { + return .success(data) + } else { + return .failure(APIError.unknown("Failed to get data from AppSync response", "")) + } + } + .flatMap { decodeDataPayload($0, modelName: modelName) } + .mapError { .transformationError(string, $0) } + } + + public static func fromAppSyncSubscriptionErrorResponse( + string: String + ) -> GraphQLResponse { + guard let data = string.data(using: .utf8) else { + return .failure(.transformationError( + string, + .unknown("Unable to deserialize json data", "Check the event structure.") + )) + } + + let result = toJson(data: data) + .flatMap { + let errors = $0.errors + if errors != nil { + return .success(errors?.asArray ?? [errors!]) } else { - return .transformationError("Response is not string encodable", $0) + return .failure(.unknown("Failed to get errors from AppSync response", "")) } } + .map { $0.compactMap(parseGraphQLError(error:)) } + + switch result { + case .success(let errors): return .failure(.error(errors)) + case .failure(let apiError): return .failure(.transformationError(string, apiError)) + } } +// MARK: - internal methods + // following logic in + // https://github.com/aws-amplify/amplify-swift/blob/main/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Internal/AWSAppSyncGraphQLResponse.swift#L18-L38 static func fromAppSyncResponse( json: JSONValue, decodePath: String?, modelName: String? - ) -> Result { - if let decodePath { - if let payload = json.value(at: decodePath) { - return decodeDataPayload(payload, modelName: modelName) - } else { - return .failure(.operationError("Empty data on decode path \(decodePath)", "", nil)) + ) -> Result, APIError> { + let data = decodePath != nil ? json.value(at: decodePath!) : json + let errors = json.errors?.asArray + switch (data, errors) { + case (.some(let data), .none): + return decodeDataPayload(data, modelName: modelName).map { .success($0) } + case (.none, .some(let errors)): + return .success(.failure(.error(errors.compactMap(parseGraphQLError(error:))))) + case (.some(let data), .some(let errors)): + return decodeDataPayload(data, modelName: modelName).map { + .failure(.partial($0, errors.compactMap(parseGraphQLError(error:)))) } - } else { - return decodeDataPayload(json, modelName: modelName) + case (.none, .none): + return .failure(.unknown( + "Failed to get data object or errors from GraphQL response", + "The AppSync service returned a malformed GraphQL response" + )) } } + // folowing logic in + // https://github.com/aws-amplify/amplify-swift/blob/main/AmplifyPlugins/API/Sources/AWSAPIPlugin/Support/Decode/GraphQLErrorDecoder.swift + static func parseGraphQLError( + error: JSONValue + ) -> GraphQLError? { + guard let errorObject = error.asObject else { + return nil + } + + let 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 } + } + + return (try? jsonEncoder.encode(error)) + .flatMap { try? jsonDecoder.decode(GraphQLError.self, from: $0) } + .map { + GraphQLError( + message: $0.message, + locations: $0.locations, + path: $0.path, + extensions: extensions.isEmpty ? nil : extensions + ) + } + } + static func decodeDataPayload( _ dataPayload: JSONValue, modelName: String? From df1e4097c729fc106609a597babe78fdd29c521c Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Thu, 9 May 2024 09:10:49 -0500 Subject: [PATCH 4/5] fix: moved subscription decoding off main thread --- packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift b/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift index c5efe084b0..5cf8c04753 100644 --- a/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift +++ b/packages/amplify_datastore/ios/Classes/FlutterApiPlugin.swift @@ -50,6 +50,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 + .receive(on: DispatchQueue.global()) .filter { $0.subscriptionId == subscriptionId } .handleEvents(receiveCompletion: {_ in unsubscribe(subscriptionId: subscriptionId) From 714ca171b728de873e0212f030e1c85409177d9d Mon Sep 17 00:00:00 2001 From: Elijah Quartey Date: Thu, 9 May 2024 15:32:25 -0500 Subject: [PATCH 5/5] fix: lowercase N --- .../amplify_datastore/lib/src/utils/native_api_helpers.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0d57cf021e..a36781deca 100644 --- a/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart +++ b/packages/amplify_datastore/lib/src/utils/native_api_helpers.dart @@ -4,7 +4,7 @@ import 'package:amplify_core/amplify_core.dart'; import 'package:amplify_datastore/src/native_plugin.g.dart'; /// Convert a [NativeGraphQLResponse] to a [GraphQLResponse] -GraphQLRequest NativeRequestToGraphQLRequest( +GraphQLRequest nativeRequestToGraphQLRequest( NativeGraphQLRequest request) { return GraphQLRequest( document: request.document,