import ComposableNotifications import Dependencies import Foundation import HTTPClient import HTTPTypes public extension NotificationsClient { /// Registers notification state with an authenticated HTTP server. /// /// The shared HTTP client remains responsible for authentication and retry interceptors. This /// adapter owns ordering: destination and subscription changes are synchronized independently, /// and only one synchronization pass is in flight at a time. static func http(baseURL: URL) -> Self { @Dependency(\.httpClient) var httpClient let coordinator = NotificationRegistrationHTTPCoordinator( baseURL: baseURL, httpClient: httpClient ) return Self( registerDestination: { destination in try await coordinator.update(destination: destination) }, replaceSubscriptions: { subscriptions in try await coordinator.update(subscriptions: subscriptions) }, synchronize: { try await coordinator.synchronize() }, signals: { AsyncStream { $0.finish() } } ) } } private actor NotificationRegistrationHTTPCoordinator { private let baseURL: URL private let httpClient: HTTPClient private var destination: Push.Destination? private var isSynchronizing = false private var synchronizedDestination: Push.Destination? private var synchronizedSubscriptions: Set? private var synchronizationWaiters: [CheckedContinuation] = [] private var subscriptions: Set = [] init(baseURL: URL, httpClient: HTTPClient) { self.baseURL = baseURL self.httpClient = httpClient } func update(destination: Push.Destination) async throws { self.destination = destination try await synchronize() } func update(subscriptions: Set) async throws { self.subscriptions = subscriptions try await synchronize() } func synchronize() async throws { if isSynchronizing { try await withCheckedThrowingContinuation { continuation in synchronizationWaiters.append(continuation) } return } isSynchronizing = true do { try await drainDesiredState() finishSynchronization(with: .success(())) } catch { finishSynchronization(with: .failure(error)) throw error } } private func drainDesiredState() async throws { while let destination { if destination != synchronizedDestination { try await replaceDestination(destination) synchronizedDestination = destination } let subscriptions = self.subscriptions if subscriptions != synchronizedSubscriptions { try await replaceSubscriptions(subscriptions) synchronizedSubscriptions = subscriptions } guard self.destination != synchronizedDestination || self.subscriptions != synchronizedSubscriptions else { return } } } private func finishSynchronization(with result: Result) { isSynchronizing = false let waiters = synchronizationWaiters synchronizationWaiters.removeAll() for waiter in waiters { waiter.resume(with: result) } } private func replaceDestination(_ destination: Push.Destination) async throws { try await put( NotificationDestinationPayload(destination), path: NotificationRegistrationEndpoint.destinationPath ) } private func replaceSubscriptions( _ subscriptions: Set ) async throws { try await put( NotificationSubscriptionsPayload(subscriptions), path: NotificationRegistrationEndpoint.subscriptionsPath ) } private func put(_ body: Body, path: String) async throws { @Dependency(\.notificationHTTPAuthorization) var notificationHTTPAuthorization let path = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) var request = HTTPRequest(url: baseURL.appendingPathComponent(path)) request.method = .put request.headerFields[.contentType] = "application/json" _ = try await withDependencies { $0.requestInterceptors = notificationHTTPAuthorization.requestInterceptors $0.errorInterceptors = notificationHTTPAuthorization.errorInterceptors } operation: { try await httpClient.run(request, JSONEncoder().encode(body)) } } }