diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 013aec78..d76859b2 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -5,10 +5,29 @@ import Foundation import FoundationNetworking #endif +public final class AuthStateChangeListenerHandle { + var onCancel: (@Sendable () -> Void)? + + public func cancel() { + onCancel?() + onCancel = nil + } + + deinit { + cancel() + } +} + +public typealias AuthStateChangeListener = @Sendable ( + _ event: AuthChangeEvent, + _ session: Session? +) -> Void + public actor AuthClient { /// FetchHandler is a type alias for asynchronous network request handling. - public typealias FetchHandler = - @Sendable (_ request: URLRequest) async throws -> (Data, URLResponse) + public typealias FetchHandler = @Sendable ( + _ request: URLRequest + ) async throws -> (Data, URLResponse) /// Configuration struct represents the client configuration. public struct Configuration: Sendable { @@ -150,7 +169,7 @@ public actor AuthClient { sessionManager: .live, codeVerifierStorage: .live, api: api, - eventEmitter: .live, + eventEmitter: EventEmitter(), sessionStorage: .live, logger: configuration.logger ) @@ -187,6 +206,20 @@ public actor AuthClient { ) } + /// Listen for auth state changes. + /// + /// An `.initialSession` is always emitted when this method is called. + @discardableResult + public func onAuthStateChange( + _ listener: @escaping AuthStateChangeListener + ) -> AuthStateChangeListenerHandle { + let handle = eventEmitter.attachListener(listener) + Task { + await emitInitialSession(forHandle: handle) + } + return handle + } + /// Listen for auth state changes. /// /// An `.initialSession` is always emitted when this method is called. @@ -194,12 +227,17 @@ public actor AuthClient { event: AuthChangeEvent, session: Session? )> { - let (id, stream) = eventEmitter.attachListener() - logger?.debug("auth state change listener with id '\(id.uuidString)' attached.") + let (stream, continuation) = AsyncStream<( + event: AuthChangeEvent, + session: Session? + )>.makeStream() + + let handle = onAuthStateChange { event, session in + continuation.yield((event, session)) + } - Task { [id] in - await emitInitialSession(forStreamWithID: id) - logger?.debug("initial session for listener with id '\(id.uuidString)' emitted.") + continuation.onTermination = { _ in + handle.cancel() } return stream @@ -884,9 +922,9 @@ public actor AuthClient { return session } - private func emitInitialSession(forStreamWithID id: UUID) async { + private func emitInitialSession(forHandle handle: AuthStateChangeListenerHandle) async { let session = try? await session - eventEmitter.emit(.initialSession, session, id) + eventEmitter.emit(.initialSession, session: session, handle: handle) } private func prepareForPKCE() -> (codeChallenge: String?, codeChallengeMethod: String?) { diff --git a/Sources/Auth/Internal/EventEmitter.swift b/Sources/Auth/Internal/EventEmitter.swift index da391e5f..0eff28ad 100644 --- a/Sources/Auth/Internal/EventEmitter.swift +++ b/Sources/Auth/Internal/EventEmitter.swift @@ -1,62 +1,50 @@ import ConcurrencyExtras import Foundation -struct EventEmitter: Sendable { - var attachListener: @Sendable () -> ( - id: UUID, - stream: AsyncStream<(event: AuthChangeEvent, session: Session?)> - ) - var emit: @Sendable (_ event: AuthChangeEvent, _ session: Session?, _ id: UUID?) -> Void -} +class EventEmitter: @unchecked Sendable { + let listeners = LockIsolated<[ObjectIdentifier: AuthStateChangeListener]>([:]) + + func attachListener(_ listener: @escaping AuthStateChangeListener) + -> AuthStateChangeListenerHandle + { + let handle = AuthStateChangeListenerHandle() + let key = ObjectIdentifier(handle) + + handle.onCancel = { [weak self] in + self?.listeners.withValue { + $0[key] = nil + } + } -extension EventEmitter { - func emit(_ event: AuthChangeEvent, session: Session?) { - emit(event, session, nil) + listeners.withValue { + $0[key] = listener + } + + return handle } -} -extension EventEmitter { - static var live: Self = { - let continuations = LockIsolated( - [UUID: AsyncStream<(event: AuthChangeEvent, session: Session?)>.Continuation]() + func emit( + _ event: AuthChangeEvent, + session: Session?, + handle: AuthStateChangeListenerHandle? = nil + ) { + NotificationCenter.default.post( + name: AuthClient.didChangeAuthStateNotification, + object: nil, + userInfo: [ + AuthClient.authChangeEventInfoKey: event, + AuthClient.authChangeSessionInfoKey: session as Any, + ] ) - return Self( - attachListener: { - let id = UUID() - - let (stream, continuation) = AsyncStream<(event: AuthChangeEvent, session: Session?)> - .makeStream() - - continuation.onTermination = { [id] _ in - continuations.withValue { - $0[id] = nil - } - } - - continuations.withValue { - $0[id] = continuation - } - - return (id, stream) - }, - emit: { event, session, id in - NotificationCenter.default.post( - name: AuthClient.didChangeAuthStateNotification, - object: nil, - userInfo: [ - AuthClient.authChangeEventInfoKey: event, - AuthClient.authChangeSessionInfoKey: session as Any, - ] - ) - if let id { - continuations.value[id]?.yield((event, session)) - } else { - for continuation in continuations.value.values { - continuation.yield((event, session)) - } - } + let listeners = listeners.value + + if let handle { + listeners[ObjectIdentifier(handle)]?(event, session) + } else { + for listener in listeners.values { + listener(event, session) } - ) - }() + } + } } diff --git a/Tests/AuthTests/GoTrueClientTests.swift b/Tests/AuthTests/AuthClientTests.swift similarity index 70% rename from Tests/AuthTests/GoTrueClientTests.swift rename to Tests/AuthTests/AuthClientTests.swift index e0640a45..3d231557 100644 --- a/Tests/AuthTests/GoTrueClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -1,5 +1,5 @@ // -// GoTrueClientTests.swift +// AuthClientTests.swift // // // Created by Guilherme Souza on 23/10/23. @@ -16,18 +16,61 @@ import ConcurrencyExtras #endif final class AuthClientTests: XCTestCase { - fileprivate var api: APIClient! + var eventEmitter: MockEventEmitter! + + var sut: AuthClient! + + override func setUp() { + super.setUp() + + eventEmitter = MockEventEmitter() + sut = makeSUT() + } + + override func tearDown() { + super.tearDown() + + let completion = { [weak sut] in + XCTAssertNil(sut, "sut should not leak") + } + + defer { completion() } + + sut = nil + eventEmitter = nil + } + + func testOnAuthStateChanges() async { + let session = Session.validSession + + let events = LockIsolated([AuthChangeEvent]()) + + await withDependencies { + $0.sessionManager.session = { @Sendable _ in session } + } operation: { + let handle = await sut.onAuthStateChange { event, _ in + events.withValue { + $0.append(event) + } + } + addTeardownBlock { [weak handle] in + XCTAssertNil(handle, "handle should be deallocated") + } + + await Task.megaYield() + + XCTAssertEqual(events.value, [.initialSession]) + } + } func testAuthStateChanges() async throws { let session = Session.validSession - let sut = makeSUT() let events = ActorIsolated([AuthChangeEvent]()) let (stream, continuation) = AsyncStream.makeStream() await withDependencies { - $0.eventEmitter = .live $0.sessionManager.session = { @Sendable _ in session } } operation: { let authStateStream = await sut.authStateChanges @@ -52,18 +95,8 @@ final class AuthClientTests: XCTestCase { } func testSignOut() async throws { - let sut = makeSUT() - - let events = LockIsolated([AuthChangeEvent]()) - try await withDependencies { $0.api.execute = { _ in .stub() } - $0.eventEmitter = .mock - $0.eventEmitter.emit = { @Sendable event, _, _ in - events.withValue { - $0.append(event) - } - } $0.sessionManager = .live $0.sessionStorage = .inMemory try $0.sessionStorage.storeSession(StoredSession(session: .validSession)) @@ -77,13 +110,11 @@ final class AuthClientTests: XCTestCase { XCTFail("Unexpected error.") } - XCTAssertEqual(events.value, [.signedOut]) + XCTAssertEqual(eventEmitter.emitReceivedParams.value.map(\.0), [.signedOut]) } } func testSignOutWithOthersScopeShouldNotRemoveLocalSession() async throws { - let sut = makeSUT() - try await withDependencies { $0.api.execute = { _ in .stub() } $0.sessionManager = .live @@ -98,17 +129,10 @@ final class AuthClientTests: XCTestCase { } func testSignOutShouldRemoveSessionIfUserIsNotFound() async throws { - let sut = makeSUT() - - let emitReceivedParams = LockIsolated((AuthChangeEvent, Session?)?.none) - try await withDependencies { $0.api.execute = { _ in throw AuthError.api(AuthError.APIError(code: 404)) } $0.sessionManager = .live $0.sessionStorage = .inMemory - $0.eventEmitter.emit = { @Sendable event, session, _ in - emitReceivedParams.setValue((event, session)) - } try $0.sessionStorage.storeSession(StoredSession(session: .validSession)) } operation: { do { @@ -118,25 +142,22 @@ final class AuthClientTests: XCTestCase { XCTFail("Unexpected error: \(error)") } - let (event, session) = try XCTUnwrap(emitReceivedParams.value) - XCTAssertEqual(event, .signedOut) - XCTAssertNil(session) + let emitedParams = eventEmitter.emitReceivedParams.value + let emitedEvents = emitedParams.map(\.0) + let emitedSessions = emitedParams.map(\.1) + + XCTAssertEqual(emitedEvents, [.signedOut]) + XCTAssertEqual(emitedSessions.count, 1) + XCTAssertNil(emitedSessions[0]) XCTAssertNil(try Dependencies.current.value!.sessionStorage.getSession()) } } func testSignOutShouldRemoveSessionIfJWTIsInvalid() async throws { - let sut = makeSUT() - - let emitReceivedParams = LockIsolated((AuthChangeEvent, Session?)?.none) - try await withDependencies { $0.api.execute = { _ in throw AuthError.api(AuthError.APIError(code: 401)) } $0.sessionManager = .live $0.sessionStorage = .inMemory - $0.eventEmitter.emit = { @Sendable event, session, _ in - emitReceivedParams.setValue((event, session)) - } try $0.sessionStorage.storeSession(StoredSession(session: .validSession)) } operation: { do { @@ -146,9 +167,13 @@ final class AuthClientTests: XCTestCase { XCTFail("Unexpected error: \(error)") } - let (event, session) = try XCTUnwrap(emitReceivedParams.value) - XCTAssertEqual(event, .signedOut) - XCTAssertNil(session) + let emitedParams = eventEmitter.emitReceivedParams.value + let emitedEvents = emitedParams.map(\.0) + let emitedSessions = emitedParams.map(\.1) + + XCTAssertEqual(emitedEvents, [.signedOut]) + XCTAssertEqual(emitedSessions.count, 1) + XCTAssertNil(emitedSessions[0]) XCTAssertNil(try Dependencies.current.value!.sessionStorage.getSession()) } } @@ -157,7 +182,8 @@ final class AuthClientTests: XCTestCase { let configuration = AuthClient.Configuration( url: clientURL, headers: ["Apikey": "dummy.api.key"], - localStorage: Dependencies.localStorage + localStorage: Dependencies.localStorage, + logger: nil ) let sut = AuthClient( @@ -165,15 +191,11 @@ final class AuthClientTests: XCTestCase { sessionManager: .mock, codeVerifierStorage: .mock, api: .mock, - eventEmitter: .mock, + eventEmitter: eventEmitter, sessionStorage: .mock, logger: nil ) - addTeardownBlock { [weak sut] in - XCTAssertNil(sut, "sut should be deallocated.") - } - return sut } } diff --git a/Tests/AuthTests/Mocks/MockEventEmitter.swift b/Tests/AuthTests/Mocks/MockEventEmitter.swift new file mode 100644 index 00000000..199af704 --- /dev/null +++ b/Tests/AuthTests/Mocks/MockEventEmitter.swift @@ -0,0 +1,25 @@ +// +// MockEventEmitter.swift +// +// +// Created by Guilherme Souza on 15/02/24. +// + +@testable import Auth +import ConcurrencyExtras +import Foundation + +final class MockEventEmitter: EventEmitter { + let emitReceivedParams: LockIsolated<[(AuthChangeEvent, Session?)]> = .init([]) + + override func emit( + _ event: AuthChangeEvent, + session: Session?, + handle: AuthStateChangeListenerHandle? = nil + ) { + emitReceivedParams.withValue { + $0.append((event, session)) + } + super.emit(event, session: session, handle: handle) + } +} diff --git a/Tests/AuthTests/Mocks/Mocks.swift b/Tests/AuthTests/Mocks/Mocks.swift index 5adab90c..0b8c12b1 100644 --- a/Tests/AuthTests/Mocks/Mocks.swift +++ b/Tests/AuthTests/Mocks/Mocks.swift @@ -30,18 +30,6 @@ extension SessionManager { ) } -extension EventEmitter { - static let mock = Self( - attachListener: unimplemented("EventEmitter.attachListener"), - emit: unimplemented("EventEmitter.emit") - ) - - static let noop = Self( - attachListener: { (UUID(), AsyncStream.makeStream().stream) }, - emit: { _, _, _ in } - ) -} - extension SessionStorage { static let mock = Self( getSession: unimplemented("SessionStorage.getSession"), @@ -108,10 +96,14 @@ extension Dependencies { }() static let mock = Dependencies( - configuration: AuthClient.Configuration(url: clientURL, localStorage: Self.localStorage), + configuration: AuthClient.Configuration( + url: clientURL, + localStorage: Self.localStorage, + logger: nil + ), sessionManager: .mock, api: .mock, - eventEmitter: .mock, + eventEmitter: EventEmitter(), sessionStorage: .mock, sessionRefresher: .mock, codeVerifierStorage: .mock, diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift index 7c85e809..0360c343 100644 --- a/Tests/AuthTests/RequestsTests.swift +++ b/Tests/AuthTests/RequestsTests.swift @@ -179,7 +179,6 @@ final class RequestsTests: XCTestCase { $0.sessionManager.update = { _ in } $0.sessionStorage.storeSession = { _ in } $0.codeVerifierStorage.getCodeVerifier = { nil } - $0.eventEmitter = .live $0.currentDate = { currentDate } } operation: { let url = URL( @@ -255,7 +254,6 @@ final class RequestsTests: XCTestCase { await withDependencies { $0.sessionManager.session = { @Sendable _ in .validSession } $0.sessionManager.remove = {} - $0.eventEmitter = .noop } operation: { await assert { try await sut.signOut() @@ -268,7 +266,6 @@ final class RequestsTests: XCTestCase { await withDependencies { $0.sessionManager.session = { @Sendable _ in .validSession } $0.sessionManager.remove = {} - $0.eventEmitter = .noop } operation: { await assert { try await sut.signOut(scope: .local) @@ -280,7 +277,6 @@ final class RequestsTests: XCTestCase { let sut = makeSUT() await withDependencies { $0.sessionManager.session = { @Sendable _ in .validSession } - $0.eventEmitter = .noop } operation: { await assert { try await sut.signOut(scope: .others) @@ -438,7 +434,7 @@ final class RequestsTests: XCTestCase { sessionManager: .mock, codeVerifierStorage: .mock, api: api, - eventEmitter: .mock, + eventEmitter: EventEmitter(), sessionStorage: .mock, logger: nil )