diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SwiftyDropbox-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SwiftyDropbox-Package.xcscheme new file mode 100644 index 00000000..33a3a0bb --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/SwiftyDropbox-Package.xcscheme @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SwiftyDropbox.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SwiftyDropbox.xcscheme new file mode 100644 index 00000000..9b81af09 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/SwiftyDropbox.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SwiftyDropboxObjC.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SwiftyDropboxObjC.xcscheme new file mode 100644 index 00000000..f79f9c2a --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/SwiftyDropboxObjC.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/SwiftyDropbox/Shared/Handwritten/Errors.swift b/Source/SwiftyDropbox/Shared/Handwritten/Errors.swift index 85e9ce52..c7a0f015 100644 --- a/Source/SwiftyDropbox/Shared/Handwritten/Errors.swift +++ b/Source/SwiftyDropbox/Shared/Handwritten/Errors.swift @@ -106,6 +106,32 @@ public enum CallError: Error, CustomStringConvertible { return "\(err)" } } + + // Used for global error handlers which cannot know the type of the boxed route error + public var typeErased: CallError { + switch self { + case .internalServerError(let code, let msg, let requestId): + return .internalServerError(code, msg, requestId) + case .badInputError(let msg, let requestId): + return .badInputError(msg, requestId) + case .rateLimitError(let error, let locMsg, let msg, let requestId): + return .rateLimitError(error, locMsg, msg, requestId) + case .httpError(let code, let msg, let requestId): + return .httpError(code, msg, requestId) + case .authError(let error, let locMsg, let msg, let requestId): + return .authError(error, locMsg, msg, requestId) + case .accessError(let error, let locMsg, let msg, let requestId): + return .accessError(error, locMsg, msg, requestId) + case .routeError(let boxedError, let locMsg, let msg, let requestId): + return .routeError(Box(boxedError.unboxed as Any), locMsg, msg, requestId) + case .serializationError(let error): + return .serializationError(error) + case .reconnectionError(let error): + return .reconnectionError(error) + case .clientError(let error): + return .clientError(error) + } + } static func error(response: HTTPURLResponse, data: Data?, errorSerializer: ESerial) throws -> CallError { let requestId = requestId(from: response) diff --git a/Source/SwiftyDropbox/Shared/Handwritten/GlobalErrorResponseHandler.swift b/Source/SwiftyDropbox/Shared/Handwritten/GlobalErrorResponseHandler.swift new file mode 100644 index 00000000..f2844b37 --- /dev/null +++ b/Source/SwiftyDropbox/Shared/Handwritten/GlobalErrorResponseHandler.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Similar to `DBGlobalErrorResponseHandler` in the Objc SDK +/// It does not have special handling for route errors, which end up type-erased right now +/// It also does not allow you to easily retry requests yet, like Objc's does. +public class GlobalErrorResponseHandler { + static var shared = { GlobalErrorResponseHandler() }() + + private struct Handler { + let callback: (CallError) -> Void + let queue: OperationQueue + } + + // Locked state + private struct State { + var handlers: [Handler] = [] + } + + private var state = UnfairLock(value: State()) + + internal init() { } + + func reportGlobalError(_ error: CallError) { + state.read { lockedState in + lockedState.handlers.forEach { handler in + handler.queue.addOperation { + handler.callback(error) + } + } + } + } + + func registerGlobalErrorHandler(_ callback: @escaping (CallError) -> Void, queue: OperationQueue = .main) { + state.mutate { lockedState in + lockedState.handlers.append(Handler(callback: callback, queue: queue)) + } + } +} diff --git a/Source/SwiftyDropbox/Shared/Handwritten/Request.swift b/Source/SwiftyDropbox/Shared/Handwritten/Request.swift index 3ca67c42..15d690c1 100644 --- a/Source/SwiftyDropbox/Shared/Handwritten/Request.swift +++ b/Source/SwiftyDropbox/Shared/Handwritten/Request.swift @@ -72,6 +72,16 @@ public class Request { } func handleResponseError(networkTaskFailure: NetworkTaskFailure) -> CallError { + let callError = parseCallError(from: networkTaskFailure) + + // We call the global error response handler to alert it to an error + // But unlike in the objc SDK we do not stop the SDK from calling the per-route completion handler as well. + GlobalErrorResponseHandler.shared.reportGlobalError(callError.typeErased) + + return callError + } + + private func parseCallError(from networkTaskFailure: NetworkTaskFailure) -> CallError { switch networkTaskFailure { case .badStatusCode(let data, _, let response): return CallError(response, data: data, errorSerializer: errorSerializer) diff --git a/Source/SwiftyDropbox/Shared/Handwritten/Utilities.swift b/Source/SwiftyDropbox/Shared/Handwritten/Utilities.swift index 77d9b7ca..08047b06 100644 --- a/Source/SwiftyDropbox/Shared/Handwritten/Utilities.swift +++ b/Source/SwiftyDropbox/Shared/Handwritten/Utilities.swift @@ -124,3 +124,46 @@ public enum LogHelper { log(backgroundSessionLogLevel, "bg session - \(message)") } } + +// MARK: Locking +/// Wrapper around a value with a lock +/// +/// Copied from https://github.com/home-assistant/HAKit/blob/main/Source/Internal/HAProtected.swift +public class UnfairLock { + private var value: ValueType + private let lock: os_unfair_lock_t = { + let value = os_unfair_lock_t.allocate(capacity: 1) + value.initialize(to: os_unfair_lock()) + return value + }() + + /// Create a new protected value + /// - Parameter value: The initial value + public init(value: ValueType) { + self.value = value + } + + deinit { + lock.deinitialize(count: 1) + lock.deallocate() + } + + /// Get and optionally change the value + /// - Parameter handler: Will be invoked immediately with the current value as an inout parameter. + /// - Returns: The value returned by the handler block + @discardableResult + public func mutate(using handler: (inout ValueType) -> HandlerType) -> HandlerType { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return handler(&value) + } + + /// Read the value and get a result out of it + /// - Parameter handler: Will be invoked immediately with the current value. + /// - Returns: The value returned by the handler block + public func read(_ handler: (ValueType) -> T) -> T { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return handler(value) + } +} diff --git a/Source/SwiftyDropboxUnitTests/GlobalErrorResponseHandlerTests.swift b/Source/SwiftyDropboxUnitTests/GlobalErrorResponseHandlerTests.swift new file mode 100644 index 00000000..29b1087c --- /dev/null +++ b/Source/SwiftyDropboxUnitTests/GlobalErrorResponseHandlerTests.swift @@ -0,0 +1,63 @@ +import XCTest +@testable import SwiftyDropbox + +class GlobalErrorResponseHandlerTests: XCTestCase { + private var handler: GlobalErrorResponseHandler! + override func setUp() { + handler = GlobalErrorResponseHandler() + super.setUp() + } + + func testGlobalHandlerReportsNonRouteError() { + let error = CallError.internalServerError(567, "abc", "def") + let expectation = XCTestExpectation(description: "Callback is called") + handler.registerGlobalErrorHandler { error in + guard case .internalServerError(let code, let message, let requestId) = error else { + return XCTFail("Expected error") + } + XCTAssertEqual(code, 567) + XCTAssertEqual(message, "abc") + XCTAssertEqual(requestId, "def") + expectation.fulfill() + } + handler.reportGlobalError(error.typeErased) + } + + func testGlobalHandlerReportsRouteError() { + let error = CallError.routeError(Box("value"), LocalizedUserMessage(text: "ábc", locale: "EN-US"), "abc", "def") + let expectation = XCTestExpectation(description: "Callback is called") + handler.registerGlobalErrorHandler { error in + guard case .routeError(let boxedValue, let locMessage, let message, let requestId) = error else { + return XCTFail("Expected error") + } + XCTAssertEqual(boxedValue.unboxed as? String, "value") + XCTAssertEqual(locMessage?.text, "ábc") + XCTAssertEqual(locMessage?.locale, "EN-US") + XCTAssertEqual(message, "abc") + XCTAssertEqual(requestId, "def") + expectation.fulfill() + } + handler.reportGlobalError(error.typeErased) + } +} + +class CallErrorTypeErasureTests: XCTestCase { + let cases: [CallError] = [ + .internalServerError(567, "abc", "def"), + .badInputError("abc", "def"), + .rateLimitError(.init(reason: .tooManyRequests), LocalizedUserMessage(text: "ábc", locale: "EN-US"), "abc", "def"), + .httpError(456, "abc", "def"), + .authError(Auth.AuthError.userSuspended, LocalizedUserMessage(text: "ábc", locale: "EN-US"), "abc", "def"), + .accessError(Auth.AccessError.other, LocalizedUserMessage(text: "ábc", locale: "EN-US"), "abc", "def"), + .routeError(Box("value"), LocalizedUserMessage(text: "ábc", locale: "EN-US"), "abc", "def"), + .serializationError(SerializationError.missingResultData), + .reconnectionError(ReconnectionError(reconnectionErrorKind: .badPersistedStringFormat, taskDescription: "bad")), + .clientError(.unexpectedState), + ] + func testErrorTypeErasureProvidesSameDescription() { + for error in cases { + let typeErased = error.typeErased + XCTAssertEqual(typeErased.description, error.description) + } + } +}