From 5ca564476a4a40b3071291e0754c736a96e5b73b Mon Sep 17 00:00:00 2001 From: Ivan Smolin Date: Wed, 24 May 2023 10:26:34 +0300 Subject: [PATCH] feat: `SingleValueStorage` implementations + `AppInstallLifetimeSingleValueStorage` for automatically removing keychain items on app reinstall. `DefaultRecoverableJsonNetworkService` supports iOS 12. --- CHANGELOG.md | 5 + Package.swift | 2 +- .../DefaultEncryptedTokenKeyStorage.swift | 11 +- .../DefaultEncryptedTokenStorage.swift | 11 +- .../SingleValueAuthKeychainStorage.swift | 35 +++--- TIAuth/TIAuth.podspec | 2 +- .../Sources/BaseCancellable.swift | 4 + .../Sources/AppReinstallChecker.swift | 4 +- .../BaseSingleValueDefaultsStorage.swift | 38 ++++++ .../Sources/BaseSingleValueStorage.swift | 68 +++++++++++ .../Sources/SingleValueStorage.swift | 1 + .../Sources/StringValueDefaultsStorage.swift | 40 +++++++ ...AppInstallLifetimeSingleValueStorage.swift | 82 +++++++++++++ .../BaseSingleValueKeychainStorage.swift | 50 ++++++++ .../StringValueKeychainStorage.swift | 53 +++++++++ ...DefaultRecoverableJsonNetworkService.swift | 109 +++++++++++++----- .../Sources/ApiInteractor/ApiInteractor.swift | 25 ++-- ...tEndpointSecurityRequestPreprocessor.swift | 89 ++++++++------ .../Protocols/AlertPresentationContext.swift | 1 + 19 files changed, 526 insertions(+), 104 deletions(-) create mode 100644 TIFoundationUtils/DataStorage/Sources/BaseSingleValueDefaultsStorage.swift create mode 100644 TIFoundationUtils/DataStorage/Sources/BaseSingleValueStorage.swift create mode 100644 TIFoundationUtils/DataStorage/Sources/StringValueDefaultsStorage.swift create mode 100644 TIKeychainUtils/Sources/AppInstallKeychainSingleValueStorage/AppInstallLifetimeSingleValueStorage.swift create mode 100644 TIKeychainUtils/Sources/SingleValueKeychainStorage/BaseSingleValueKeychainStorage.swift create mode 100644 TIKeychainUtils/Sources/SingleValueKeychainStorage/StringValueKeychainStorage.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ce7864ab..0960439f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +### 1.45.0 + +- **Added**: `SingleValueStorage` implementations + `AppInstallLifetimeSingleValueStorage` for automatically removing keychain items on app reinstall. +- **Update**: `DefaultRecoverableJsonNetworkService` supports iOS 12. + ### 1.44.0 - **Added**: HTTP status codes to `EndpointErrorResult.apiError` responses diff --git a/Package.swift b/Package.swift index 663448ce..afac3e9f 100644 --- a/Package.swift +++ b/Package.swift @@ -91,7 +91,7 @@ let package = Package( .target(name: "OTPSwiftView", dependencies: ["TIUIElements"], path: "OTPSwiftView/Sources"), .target(name: "TITransitions", path: "TITransitions/Sources"), .target(name: "TIPagination", dependencies: ["Cursors", "TISwiftUtils"], path: "TIPagination/Sources"), - .target(name: "TIAuth", dependencies: ["TIFoundationUtils", "TIUIKitCore", "KeychainAccess"], path: "TIAuth/Sources"), + .target(name: "TIAuth", dependencies: ["TIFoundationUtils", "TIUIKitCore", "TIKeychainUtils"], path: "TIAuth/Sources"), .target(name: "TIEcommerce", dependencies: ["TIFoundationUtils", "TISwiftUtils", "TINetworking", "TIUIKitCore", "TIUIElements"], path: "TIEcommerce/Sources"), .target(name: "TITextProcessing", dependencies: [.product(name: "Antlr4", package: "antlr4")], diff --git a/TIAuth/Sources/TokenStorage/DefaultEncryptedTokenKeyStorage.swift b/TIAuth/Sources/TokenStorage/DefaultEncryptedTokenKeyStorage.swift index 70fd0800..58a3d4dc 100644 --- a/TIAuth/Sources/TokenStorage/DefaultEncryptedTokenKeyStorage.swift +++ b/TIAuth/Sources/TokenStorage/DefaultEncryptedTokenKeyStorage.swift @@ -22,12 +22,13 @@ import KeychainAccess import Foundation +import TIFoundationUtils import LocalAuthentication open class DefaultEncryptedTokenKeyStorage: SingleValueAuthKeychainStorage { open class Defaults: SingleValueAuthKeychainStorage.Defaults { - public static var encryptedTokenKeyStorageKey: String { - keychainServiceIdentifier + ".encryptedTokenKey" + public static var encryptedTokenKeyStorageKey: StorageKey { + .init(rawValue: keychainServiceIdentifier + ".encryptedTokenKey") } public static var reusableLAContext: LAContext { @@ -40,11 +41,11 @@ open class DefaultEncryptedTokenKeyStorage: SingleValueAuthKeychainStorage public init(keychain: Keychain = Keychain(service: Defaults.keychainServiceIdentifier), localAuthContext: LAContext = Defaults.reusableLAContext, settingsStorage: AuthSettingsStorage = DefaultAuthSettingsStorage(), - encryptedTokenKeyStorageKey: String = Defaults.encryptedTokenKeyStorageKey) { + encryptedTokenKeyStorageKey: StorageKey = Defaults.encryptedTokenKeyStorageKey) { let getValueClosure: GetValueClosure = { keychain, storageKey in do { - guard let value = try keychain.getData(storageKey) else { + guard let value = try keychain.getData(storageKey.rawValue) else { return .failure(.valueNotFound) } @@ -56,7 +57,7 @@ open class DefaultEncryptedTokenKeyStorage: SingleValueAuthKeychainStorage let setValueClosure: SetValueClosure = { keychain, value, storageKey in do { - return .success(try keychain.set(value, key: storageKey)) + return .success(try keychain.set(value, key: storageKey.rawValue)) } catch { return .failure(.unableToWriteData(underlyingError: error)) } diff --git a/TIAuth/Sources/TokenStorage/DefaultEncryptedTokenStorage.swift b/TIAuth/Sources/TokenStorage/DefaultEncryptedTokenStorage.swift index fc928fc3..ab8ebbab 100644 --- a/TIAuth/Sources/TokenStorage/DefaultEncryptedTokenStorage.swift +++ b/TIAuth/Sources/TokenStorage/DefaultEncryptedTokenStorage.swift @@ -21,22 +21,23 @@ // import Foundation +import TIFoundationUtils import KeychainAccess open class DefaultEncryptedTokenStorage: SingleValueAuthKeychainStorage { open class Defaults: SingleValueAuthKeychainStorage.Defaults { - public static var encryptedTokenStorageKey: String { - keychainServiceIdentifier + ".encryptedToken" + public static var encryptedTokenStorageKey: StorageKey { + .init(rawValue: keychainServiceIdentifier + ".encryptedToken") } } public init(keychain: Keychain = Keychain(service: Defaults.keychainServiceIdentifier), settingsStorage: AuthSettingsStorage = DefaultAuthSettingsStorage(), - encryptedTokenStorageKey: String = Defaults.encryptedTokenStorageKey) { + encryptedTokenStorageKey: StorageKey = Defaults.encryptedTokenStorageKey) { let getValueClosure: GetValueClosure = { keychain, storageKey in do { - guard let value = try keychain.getData(storageKey) else { + guard let value = try keychain.getData(storageKey.rawValue) else { return .failure(.valueNotFound) } @@ -52,7 +53,7 @@ open class DefaultEncryptedTokenStorage: SingleValueAuthKeychainStorage: SingleValueStorage { +open class SingleValueAuthKeychainStorage: BaseSingleValueKeychainStorage { open class Defaults { public static var keychainServiceIdentifier: String { Bundle.main.bundleIdentifier ?? "ru.touchin.TIAuth" } } - public typealias GetValueClosure = (Keychain, String) -> Result - public typealias SetValueClosure = (Keychain, ValueType, String) -> Result - - public let keychain: Keychain public let settingsStorage: AuthSettingsStorage - public let storageKey: String - public let getValueClosure: GetValueClosure - public let setValueClosure: SetValueClosure public init(keychain: Keychain = Keychain(service: Defaults.keychainServiceIdentifier), settingsStorage: AuthSettingsStorage = DefaultAuthSettingsStorage(), - storageKey: String, + storageKey: StorageKey, getValueClosure: @escaping GetValueClosure, setValueClosure: @escaping SetValueClosure) { - self.keychain = keychain self.settingsStorage = settingsStorage - self.storageKey = storageKey - self.getValueClosure = getValueClosure - self.setValueClosure = setValueClosure + + super.init(keychain: keychain, + storageKey: storageKey, + getValueClosure: getValueClosure, + setValueClosure: setValueClosure) } // MARK: - SingleValueStorage - open func hasStoredValue() -> Bool { - !settingsStorage.shouldResetStoredAuthData && ((try? keychain.contains(storageKey)) ?? false) + open override func hasStoredValue() -> Bool { + !settingsStorage.shouldResetStoredAuthData && super.hasStoredValue() } - open func store(value: ValueType) -> Result { - return setValueClosure(keychain, value, storageKey) - } - - open func getValue() -> Result { + open override func getValue() -> Result { guard !settingsStorage.shouldResetStoredAuthData else { let result: Result do { - try keychain.remove(storageKey) + try storage.remove(storageKey.rawValue) settingsStorage.shouldResetStoredAuthData = false result = .failure(.valueNotFound) @@ -79,6 +70,6 @@ open class SingleValueAuthKeychainStorage: SingleValueStorage { return result } - return getValueClosure(keychain, storageKey) + return super.getValue() } } diff --git a/TIAuth/TIAuth.podspec b/TIAuth/TIAuth.podspec index 051f92ec..65da2869 100644 --- a/TIAuth/TIAuth.podspec +++ b/TIAuth/TIAuth.podspec @@ -13,6 +13,6 @@ Pod::Spec.new do |s| s.source_files = s.name + '/Sources/**/*' s.dependency 'TIFoundationUtils', s.version.to_s + s.dependency 'TIKeychainUtils', s.version.to_s s.dependency 'TIUIKitCore', s.version.to_s - s.dependency 'KeychainAccess', "~> 4.2" end diff --git a/TIFoundationUtils/Cancellables/Sources/BaseCancellable.swift b/TIFoundationUtils/Cancellables/Sources/BaseCancellable.swift index 850fa217..69f9a365 100644 --- a/TIFoundationUtils/Cancellables/Sources/BaseCancellable.swift +++ b/TIFoundationUtils/Cancellables/Sources/BaseCancellable.swift @@ -25,6 +25,10 @@ open class BaseCancellable: Cancellable { public init() {} + deinit { + cancel() + } + open func cancel() { isCancelled = true } diff --git a/TIFoundationUtils/DataStorage/Sources/AppReinstallChecker.swift b/TIFoundationUtils/DataStorage/Sources/AppReinstallChecker.swift index bb2dcee7..8fc4c023 100644 --- a/TIFoundationUtils/DataStorage/Sources/AppReinstallChecker.swift +++ b/TIFoundationUtils/DataStorage/Sources/AppReinstallChecker.swift @@ -40,9 +40,9 @@ open class AppReinstallChecker { } public init(defaultsStorage: UserDefaults = .standard, - storageKey: String) { + storageKey: StorageKey) { self.defaultsStorage = defaultsStorage - self.storageKey = storageKey + self.storageKey = storageKey.rawValue } } diff --git a/TIFoundationUtils/DataStorage/Sources/BaseSingleValueDefaultsStorage.swift b/TIFoundationUtils/DataStorage/Sources/BaseSingleValueDefaultsStorage.swift new file mode 100644 index 00000000..b1b14a95 --- /dev/null +++ b/TIFoundationUtils/DataStorage/Sources/BaseSingleValueDefaultsStorage.swift @@ -0,0 +1,38 @@ +// +// Copyright (c) 2023 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation + +open class BaseSingleValueDefaultsStorage: BaseSingleValueStorage { + public init(defaults: UserDefaults, + storageKey: StorageKey, + getValueClosure: @escaping GetValueClosure, + setValueClosure: @escaping SetValueClosure) { + + super.init(storage: defaults, + storageKey: storageKey, + hasValueClosure: { .success($0.object(forKey: $1.rawValue) != nil) }, + deleteValueClosure: { .success($0.removeObject(forKey: $1.rawValue)) }, + getValueClosure: getValueClosure, + setValueClosure: setValueClosure) + } +} diff --git a/TIFoundationUtils/DataStorage/Sources/BaseSingleValueStorage.swift b/TIFoundationUtils/DataStorage/Sources/BaseSingleValueStorage.swift new file mode 100644 index 00000000..eed87d9c --- /dev/null +++ b/TIFoundationUtils/DataStorage/Sources/BaseSingleValueStorage.swift @@ -0,0 +1,68 @@ +// +// Copyright (c) 2023 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +open class BaseSingleValueStorage: SingleValueStorage { + public typealias HasValueClosure = (StorageType, StorageKey) -> Result + public typealias GetValueClosure = (StorageType, StorageKey) -> Result + public typealias SetValueClosure = (StorageType, ValueType, StorageKey) -> Result + public typealias DeleteValueClosure = (StorageType, StorageKey) -> Result + + public let storage: StorageType + public let storageKey: StorageKey + public let hasValueClosure: HasValueClosure + public let deleteValueClosure: DeleteValueClosure + public let getValueClosure: GetValueClosure + public let setValueClosure: SetValueClosure + + public init(storage: StorageType, + storageKey: StorageKey, + hasValueClosure: @escaping HasValueClosure, + deleteValueClosure: @escaping DeleteValueClosure, + getValueClosure: @escaping GetValueClosure, + setValueClosure: @escaping SetValueClosure) { + + self.storage = storage + self.storageKey = storageKey + self.hasValueClosure = hasValueClosure + self.deleteValueClosure = deleteValueClosure + self.getValueClosure = getValueClosure + self.setValueClosure = setValueClosure + } + + // MARK: - SingleValueStorage + + open func hasStoredValue() -> Bool { + (try? hasValueClosure(storage, storageKey).get()) ?? false + } + + open func store(value: ValueType) -> Result { + setValueClosure(storage, value, storageKey) + } + + open func getValue() -> Result { + getValueClosure(storage, storageKey) + } + + public func deleteValue() -> Result { + deleteValueClosure(storage, storageKey) + } +} diff --git a/TIFoundationUtils/DataStorage/Sources/SingleValueStorage.swift b/TIFoundationUtils/DataStorage/Sources/SingleValueStorage.swift index 4c832e6a..d1a440b8 100644 --- a/TIFoundationUtils/DataStorage/Sources/SingleValueStorage.swift +++ b/TIFoundationUtils/DataStorage/Sources/SingleValueStorage.swift @@ -27,4 +27,5 @@ public protocol SingleValueStorage { func hasStoredValue() -> Bool func store(value: ValueType) -> Result func getValue() -> Result + func deleteValue() -> Result } diff --git a/TIFoundationUtils/DataStorage/Sources/StringValueDefaultsStorage.swift b/TIFoundationUtils/DataStorage/Sources/StringValueDefaultsStorage.swift new file mode 100644 index 00000000..46e4ac45 --- /dev/null +++ b/TIFoundationUtils/DataStorage/Sources/StringValueDefaultsStorage.swift @@ -0,0 +1,40 @@ +// +// Copyright (c) 2023 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation + +public final class StringValueDefaultsStorage: BaseSingleValueDefaultsStorage { + public init(defaults: UserDefaults, storageKey: StorageKey) { + let getValueClosure: GetValueClosure = { defaults, storageKey in + guard let value = defaults.string(forKey: storageKey.rawValue) else { + return .failure(.valueNotFound) + } + + return .success(value) + } + + super.init(defaults: defaults, + storageKey: storageKey, + getValueClosure: getValueClosure, + setValueClosure: { .success($0.set($1, forKey: $2.rawValue)) }) + } +} diff --git a/TIKeychainUtils/Sources/AppInstallKeychainSingleValueStorage/AppInstallLifetimeSingleValueStorage.swift b/TIKeychainUtils/Sources/AppInstallKeychainSingleValueStorage/AppInstallLifetimeSingleValueStorage.swift new file mode 100644 index 00000000..781b4322 --- /dev/null +++ b/TIKeychainUtils/Sources/AppInstallKeychainSingleValueStorage/AppInstallLifetimeSingleValueStorage.swift @@ -0,0 +1,82 @@ +// +// Copyright (c) 2023 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import TIFoundationUtils + +open class AppInstallLifetimeSingleValueStorage: SingleValueStorage where Storage.ErrorType == StorageError { + public let appReinstallChecker: AppReinstallChecker + public let wrappedStorage: Storage + + public init(storage: Storage, + appReinstallChecker: AppReinstallChecker) { + + self.wrappedStorage = storage + self.appReinstallChecker = appReinstallChecker + } + + // MARK: - SingleValueStorage + + open func hasStoredValue() -> Bool { + if appReinstallChecker.isAppFirstRun { + return false + } + + return wrappedStorage.hasStoredValue() + } + + open func getValue() -> Result { + if appReinstallChecker.isAppFirstRun { + let result = wrappedStorage.deleteValue() + + if case .success = result { + appReinstallChecker.isAppFirstRun = false + } + + return result.flatMap { .failure(StorageError.valueNotFound) } + } else { + return wrappedStorage.getValue() + } + } + + open func store(value: Storage.ValueType) -> Result { + let result = wrappedStorage.store(value: value) + + if case .success = result { + appReinstallChecker.isAppFirstRun = false + } + + return result + } + + public func deleteValue() -> Result { + wrappedStorage.deleteValue() + } +} + +public extension SingleValueStorage { + func appInstallLifetimeStorage(reinstallChecker: AppReinstallChecker) -> AppInstallLifetimeSingleValueStorage + where Self.ErrorType == ErrorType { + + AppInstallLifetimeSingleValueStorage(storage: self, + appReinstallChecker: reinstallChecker) + } +} diff --git a/TIKeychainUtils/Sources/SingleValueKeychainStorage/BaseSingleValueKeychainStorage.swift b/TIKeychainUtils/Sources/SingleValueKeychainStorage/BaseSingleValueKeychainStorage.swift new file mode 100644 index 00000000..f9159ae7 --- /dev/null +++ b/TIKeychainUtils/Sources/SingleValueKeychainStorage/BaseSingleValueKeychainStorage.swift @@ -0,0 +1,50 @@ +// +// Copyright (c) 2023 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import TIFoundationUtils +import KeychainAccess + +open class BaseSingleValueKeychainStorage: BaseSingleValueStorage { + public init(keychain: Keychain, + storageKey: StorageKey, + getValueClosure: @escaping GetValueClosure, + setValueClosure: @escaping SetValueClosure) { + + let hasValueClosure: HasValueClosure = { keychain, storageKey in + Result { try keychain.contains(storageKey.rawValue) } + .mapError { StorageError.unableToExtractData(underlyingError: $0) } + } + + + let deleteValueClosure: DeleteValueClosure = { keychain, storageKey in + Result { try keychain.remove(storageKey.rawValue) } + .mapError { StorageError.unableToWriteData(underlyingError: $0) } + } + + super.init(storage: keychain, + storageKey: storageKey, + hasValueClosure: hasValueClosure, + deleteValueClosure: deleteValueClosure, + getValueClosure: getValueClosure, + setValueClosure: setValueClosure) + } +} diff --git a/TIKeychainUtils/Sources/SingleValueKeychainStorage/StringValueKeychainStorage.swift b/TIKeychainUtils/Sources/SingleValueKeychainStorage/StringValueKeychainStorage.swift new file mode 100644 index 00000000..7814f1be --- /dev/null +++ b/TIKeychainUtils/Sources/SingleValueKeychainStorage/StringValueKeychainStorage.swift @@ -0,0 +1,53 @@ +// +// Copyright (c) 2023 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import KeychainAccess +import TIFoundationUtils + +public final class StringValueKeychainStorage: BaseSingleValueKeychainStorage { + public init(keychain: Keychain, storageKey: StorageKey) { + let getValueClosure: BaseSingleValueKeychainStorage.GetValueClosure = { keychain, storageKey in + do { + guard let value = try keychain.get(storageKey.rawValue) else { + return .failure(.valueNotFound) + } + + return .success(value) + } catch { + return .failure(.unableToExtractData(underlyingError: error)) + } + } + + let setValueClosure: BaseSingleValueKeychainStorage.SetValueClosure = { keychain, value, storageKey in + do { + return .success(try keychain.set(value, key: storageKey.rawValue)) + } catch { + return .failure(.unableToWriteData(underlyingError: error)) + } + } + + super.init(keychain: keychain, + storageKey: storageKey, + getValueClosure: getValueClosure, + setValueClosure: setValueClosure) + } +} diff --git a/TIMoyaNetworking/Sources/RecoverableNetworkService/DefaultRecoverableJsonNetworkService.swift b/TIMoyaNetworking/Sources/RecoverableNetworkService/DefaultRecoverableJsonNetworkService.swift index eaaa8988..150fcb4e 100644 --- a/TIMoyaNetworking/Sources/RecoverableNetworkService/DefaultRecoverableJsonNetworkService.swift +++ b/TIMoyaNetworking/Sources/RecoverableNetworkService/DefaultRecoverableJsonNetworkService.swift @@ -20,55 +20,110 @@ // THE SOFTWARE. // -import Moya import TINetworking import TISwiftUtils +import TIFoundationUtils -@available(iOS 13.0.0, *) open class DefaultRecoverableJsonNetworkService: DefaultJsonNetworkService { - public typealias EndpointResponse = EndpointRecoverableRequestResult - public typealias ErrorType = EndpointErrorResult + public typealias EndpointResponse = EndpointRecoverableRequestResult + public typealias ErrorType = EndpointErrorResult public typealias RequestRetrier = AnyEndpointRequestRetrier public private(set) var defaultRequestRetriers: [RequestRetrier] = [] + open func process(recoverableRequest: EndpointRequest, + prependRequestRetriers: [RequestRetrier] = [], + appendRequestRetriers: [RequestRetrier] = [], + completion: @escaping ParameterClosure>) -> Cancellable + { + + process(recoverableRequest: recoverableRequest, + errorHandlers: prependRequestRetriers + defaultRequestRetriers + appendRequestRetriers, + completion: completion) + } + + @available(iOS 13.0.0, *) open func process(recoverableRequest: EndpointRequest, prependRequestRetriers: [RequestRetrier] = [], appendRequestRetriers: [RequestRetrier] = []) async -> EndpointResponse { - await process(recoverableRequest: recoverableRequest, - errorHandlers: prependRequestRetriers + defaultRequestRetriers + appendRequestRetriers) + await withTaskCancellableClosure { + process(recoverableRequest: recoverableRequest, + prependRequestRetriers: prependRequestRetriers, + appendRequestRetriers: appendRequestRetriers, + completion: $0) + } } open func process(recoverableRequest: EndpointRequest, - errorHandlers: [RequestRetrier]) async -> EndpointResponse { + errorHandlers: [RequestRetrier], + completion: @escaping ParameterClosure>) -> Cancellable { - let result: RequestResult = await process(request: recoverableRequest) - - if case let .failure(errorResponse) = result { - var failures = [errorResponse] - - for handler in errorHandlers { - let handlerResult = await handler.validateAndRepair(errorResults: failures) - - switch handlerResult { - case let .success(retryResult): - switch retryResult { - case .retry, .retryWithDelay: - return await process(recoverableRequest: recoverableRequest, errorHandlers: errorHandlers) - case .doNotRetry, .doNotRetryWithError: - break - } - case let .failure(error): - failures.append(error) + Cancellables.scoped { cancellableBag in + process(request: recoverableRequest) { (result: RequestResult) in + if case let .failure(errorResponse) = result { + Self.validateAndRepair(recoverableRequest: recoverableRequest, + errors: [errorResponse], + retriers: errorHandlers, + cancellableBag: cancellableBag, + completion: completion) + } else { + completion(result.mapError { .init(failures: [$0]) }) } } + } + } - return .failure(.init(failures: failures)) + @available(iOS 13.0.0, *) + open func process(recoverableRequest: EndpointRequest, + errorHandlers: [RequestRetrier]) async -> EndpointResponse { + + await withTaskCancellableClosure { + process(recoverableRequest: recoverableRequest, + errorHandlers: errorHandlers, + completion: $0) + } + } + + private static func validateAndRepair(recoverableRequest: EndpointRequest, + errors: [ErrorType], + retriers: R, + cancellableBag: BaseCancellableBag, + completion: @escaping ParameterClosure>) + where R.Element == RequestRetrier { + + guard let retrier = retriers.first, !cancellableBag.isCancelled else { + completion(.failure(.init(failures: errors))) + return } - return result.mapError { .init(failures: [$0]) } + retrier.validateAndRepair(errorResults: errors) { handlerResult in + switch handlerResult { + case let .success(retryResult): + switch retryResult { + case .retry, .retryWithDelay: + validateAndRepair(recoverableRequest: recoverableRequest, + errors: errors, + retriers: retriers, + cancellableBag: cancellableBag, + completion: completion) + case .doNotRetry, .doNotRetryWithError: + validateAndRepair(recoverableRequest: recoverableRequest, + errors: errors, + retriers: retriers.dropFirst(), + cancellableBag: cancellableBag, + completion: completion) + } + case let .failure(error): + validateAndRepair(recoverableRequest: recoverableRequest, + errors: errors + [error], + retriers: retriers.dropFirst(), + cancellableBag: cancellableBag, + completion: completion) + } + } + .add(to: cancellableBag) } public func register(defaultRequestRetrier: RequestRetrier) diff --git a/TINetworking/Sources/ApiInteractor/ApiInteractor.swift b/TINetworking/Sources/ApiInteractor/ApiInteractor.swift index 93a5e148..6afad2af 100644 --- a/TINetworking/Sources/ApiInteractor/ApiInteractor.swift +++ b/TINetworking/Sources/ApiInteractor/ApiInteractor.swift @@ -36,13 +36,24 @@ public protocol ApiInteractor { completion: @escaping ParameterClosure) -> Cancellable } +public extension ApiInteractor { + func process(request: EndpointRequest, + completion: @escaping ParameterClosure>) -> Cancellable { + + process(request: request, + mapSuccess: Result.success, + mapFailure: { .failure(.apiError($0.apiError, $0.statusCode)) }, + mapNetworkError: { .failure(.networkError($0)) }, + completion: completion) + } +} + @available(iOS 13.0.0, *) public extension ApiInteractor { func process(request: EndpointRequest) async -> RequestResult { - await process(request: request, - mapSuccess: Result.success, - mapFailure: { .failure(.apiError($0.apiError, $0.statusCode)) }, - mapNetworkError: { .failure(.networkError($0)) }) + await withTaskCancellableClosure { + process(request: request, completion: $0) + } } func process(request: EndpointRequest, @@ -50,13 +61,11 @@ public extension ApiInteractor { mapFailure: @escaping Closure, R>, mapNetworkError: @escaping Closure) async -> R { - await withTaskCancellableClosure { completion in + await withTaskCancellableClosure { process(request: request, mapSuccess: mapSuccess, mapFailure: mapFailure, - mapNetworkError: mapNetworkError) { - completion($0) - } + mapNetworkError: mapNetworkError, completion: $0) } } } diff --git a/TINetworking/Sources/RequestPreprocessors/DefaultEndpointSecurityRequestPreprocessor.swift b/TINetworking/Sources/RequestPreprocessors/DefaultEndpointSecurityRequestPreprocessor.swift index c0bf2676..7e81d427 100644 --- a/TINetworking/Sources/RequestPreprocessors/DefaultEndpointSecurityRequestPreprocessor.swift +++ b/TINetworking/Sources/RequestPreprocessors/DefaultEndpointSecurityRequestPreprocessor.swift @@ -25,17 +25,31 @@ import TIFoundationUtils open class DefaultSecuritySchemePreprocessor: SecuritySchemePreprocessor { struct ValueNotProvidedError: Error {} + public typealias ResultProvider = (@escaping (Result) -> Void) -> Cancellable public typealias ValueProvider = (@escaping (String?) -> Void) -> Cancellable - private let valueProvider: ValueProvider + private let resultProvider: ResultProvider - public init(valueProvider: @escaping ValueProvider) { - self.valueProvider = valueProvider + public init(resultProvider: @escaping ResultProvider) { + self.resultProvider = resultProvider } - public init(staticValue: String?) { - self.valueProvider = { + public init(valueProvider: @escaping ValueProvider) { + self.resultProvider = { completion in + valueProvider { + guard let value = $0 else { + completion(.failure(ValueNotProvidedError())) + return + } + completion(.success(value)) + } + } + } + + public convenience init(staticValue: String?) { + self.init { $0(staticValue) + return Cancellables.nonCancellable() } } @@ -45,44 +59,53 @@ open class DefaultSecuritySchemePreprocessor: SecuritySchemePreprocessor { public func preprocess(request: EndpointRequest, using security: SecurityScheme, completion: @escaping (Result, Error>) -> Void) -> Cancellable { + resultProvider { + switch $0 { + case let .success(value): + completion(.success(Self.modify(request: request, + using: security, + securityValue: value))) + + case let .failure(error): + completion(.failure(error)) + } + } + } + + private static func modify(request: EndpointRequest, + using security: SecurityScheme, + securityValue: String) -> EndpointRequest { var modifiedRequest = request - return valueProvider { - guard let value = $0 else { - completion(.failure(ValueNotProvidedError())) - return - } + switch security { + case let .http(authenticationScheme): + let headerValue = "\(authenticationScheme.rawValue) \(securityValue)" + var headerParameters = modifiedRequest.headerParameters ?? [:] + headerParameters.updateValue(.init(value: headerValue), + forKey: "Authorization") - switch security { - case let .http(authenticationScheme): - let headerValue = "\(authenticationScheme.rawValue) \(value)" + modifiedRequest.headerParameters = headerParameters + + case let .apiKey(parameterLocation, parameterName): + switch parameterLocation { + case .header: var headerParameters = modifiedRequest.headerParameters ?? [:] - headerParameters.updateValue(.init(value: headerValue), - forKey: "Authorization") + headerParameters.updateValue(.init(value: securityValue), + forKey: parameterName) modifiedRequest.headerParameters = headerParameters - case let .apiKey(parameterLocation, parameterName): - switch parameterLocation { - case .header: - var headerParameters = modifiedRequest.headerParameters ?? [:] - headerParameters.updateValue(.init(value: value), - forKey: parameterName) + case .query: + modifiedRequest.queryParameters.updateValue(.init(value: securityValue), + forKey: parameterName) - modifiedRequest.headerParameters = headerParameters - - case .query: - modifiedRequest.queryParameters.updateValue(.init(value: value), - forKey: parameterName) - - case .cookie: - modifiedRequest.cookieParameters.updateValue(.init(value: value), - forKey: parameterName) - } + case .cookie: + modifiedRequest.cookieParameters.updateValue(.init(value: securityValue), + forKey: parameterName) } - - completion(.success(modifiedRequest)) } + + return modifiedRequest } } diff --git a/TIUIKitCore/Sources/Alerts/Protocols/AlertPresentationContext.swift b/TIUIKitCore/Sources/Alerts/Protocols/AlertPresentationContext.swift index 377a4086..2406f6c8 100644 --- a/TIUIKitCore/Sources/Alerts/Protocols/AlertPresentationContext.swift +++ b/TIUIKitCore/Sources/Alerts/Protocols/AlertPresentationContext.swift @@ -24,6 +24,7 @@ import TISwiftUtils import UIKit /// A context from where the alert can be presented. +@MainActor public protocol AlertPresentationContext { func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: VoidClosure?) }