Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
swift-composable-notifications Sources ComposableNotificationsHTTP NotificationRequestInterceptors.swift
11 kB · 292 lines
Swift
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293import ComposableNotificationsimport Dependenciesimport Foundationimport HTTPClientimport HTTPTypes
private enum NotificationHTTPAuthorizationKey: DependencyKey { static let liveValue = NotificationHTTPAuthorization( requestInterceptors: [ RequestInterceptor { _, _ in throw NotificationAuthenticationUnavailable() } ] ) static let testValue = NotificationHTTPAuthorization()}
/// Notification registration cannot run until the authentication feature installs its signer.public struct NotificationAuthenticationUnavailable: Error, Equatable, Sendable { public init() {}}
/// Request authorization and bounded credential recovery scoped to notification registration.public struct NotificationHTTPAuthorization: Sendable { public var errorInterceptors: [ErrorInterceptor] public var requestInterceptors: [RequestInterceptor]
public init( requestInterceptors: [RequestInterceptor] = [], errorInterceptors: [ErrorInterceptor] = [] ) { self.errorInterceptors = errorInterceptors self.requestInterceptors = requestInterceptors }
/// Authorizes notification requests with a persisted anonymous installation credential. /// /// The first request creates an installation through the server's public bootstrap endpoint. /// Concurrent callers share that result, and subsequent app launches load the credential from /// host-provided platform-protected storage. If the server rejects that credential, the client /// replaces it and retries the registration once. public static func anonymousNotificationInstallation( baseURL: URL, storage: NotificationInstallationCredentialStorage ) -> Self { let authorizer = AnonymousNotificationInstallationAuthorizer( baseURL: baseURL, storage: storage ) return Self( requestInterceptors: [ RequestInterceptor { request, _ in @Dependency(\.httpClient) var httpClient let credential = try await authorizer.credential(using: httpClient) request.headerFields[.authorization] = credential.authorizationHeader } ], errorInterceptors: [ ErrorInterceptor(retryBudget: 1) { failedRequest, failureCode, _, _, retry in guard failureCode == .unauthorized, failedRequest.method == .put, failedRequest.url?.isNotificationRegistrationURL == true, let rejectedCredential = failedRequest.notificationCredential else { return nil }
@Dependency(\.httpClient) var httpClient _ = try await authorizer.replacementCredential( for: rejectedCredential, using: httpClient )
// The HTTP client uses nil to mean an unhandled error-interceptor result, while // successful registration endpoints return 204 with no body. Preserve that // success as handled empty data; the client normalizes it back to nil later. return try await retry(failedRequest) ?? Data() } ] ) }}
public extension DependencyValues { /// Authentication and recovery scoped to notification registration requests. /// /// The authentication feature installs its anonymous-installation authorization here. The live /// default fails closed. Keeping it separate from general HTTP configuration also prevents /// unrelated authentication retries from handling notification installation failures. var notificationHTTPAuthorization: NotificationHTTPAuthorization { get { self[NotificationHTTPAuthorizationKey.self] } set { self[NotificationHTTPAuthorizationKey.self] = newValue } }
/// Request-only compatibility for clients that have not adopted credential recovery yet. @available( *, deprecated, message: "Use notificationHTTPAuthorization to include bounded credential recovery." ) var notificationRequestInterceptors: [RequestInterceptor] { get { notificationHTTPAuthorization.requestInterceptors } set { var authorization = notificationHTTPAuthorization authorization.requestInterceptors = newValue notificationHTTPAuthorization = authorization } }}
public extension RequestInterceptor { /// Request-only compatibility for clients that have not adopted credential recovery yet. @available( *, deprecated, message: "Use NotificationHTTPAuthorization.anonymousNotificationInstallation instead." ) static func anonymousNotificationInstallation( baseURL: URL, storage: NotificationInstallationCredentialStorage ) -> Self { NotificationHTTPAuthorization.anonymousNotificationInstallation( baseURL: baseURL, storage: storage ) .requestInterceptors[0] }}
private actor AnonymousNotificationInstallationAuthorizer { let baseURL: URL let storage: NotificationInstallationCredentialStorage
var cachedCredential: NotificationInstallation.Credential? var isResolvingCredential = false var waiters: [CheckedContinuation<NotificationInstallation.Credential, any Error>] = []
init( baseURL: URL, storage: NotificationInstallationCredentialStorage ) { self.baseURL = baseURL self.storage = storage }
func credential( using httpClient: HTTPClient ) async throws -> NotificationInstallation.Credential { if isResolvingCredential { return try await withCheckedThrowingContinuation { continuation in waiters.append(continuation) } } if let cachedCredential { return cachedCredential }
return try await resolveCredential(using: httpClient) }
func replacementCredential( for rejectedCredential: NotificationInstallation.Credential, using httpClient: HTTPClient ) async throws -> NotificationInstallation.Credential { if isResolvingCredential { let resolvedCredential = try await withCheckedThrowingContinuation { continuation in waiters.append(continuation) } if resolvedCredential != rejectedCredential { return resolvedCredential } return try await replacementCredential( for: rejectedCredential, using: httpClient ) } if let cachedCredential, cachedCredential != rejectedCredential { return cachedCredential }
cachedCredential = nil return try await resolveCredential( using: httpClient, replacing: rejectedCredential ) }
func resolveCredential( using httpClient: HTTPClient, replacing rejectedCredential: NotificationInstallation.Credential? = nil ) async throws -> NotificationInstallation.Credential { if isResolvingCredential { return try await withCheckedThrowingContinuation { continuation in waiters.append(continuation) } }
isResolvingCredential = true do { let credential: NotificationInstallation.Credential if let storedCredential = try await storage.credential(), storedCredential != rejectedCredential { credential = storedCredential } else { if rejectedCredential != nil { try await storage.setCredential(nil) } credential = try await createCredential(using: httpClient) try await storage.setCredential(credential) } cachedCredential = credential finishResolution(with: .success(credential)) return credential } catch { finishResolution(with: .failure(error)) throw error } }
func createCredential( using httpClient: HTTPClient ) async throws -> NotificationInstallation.Credential { let path = NotificationRegistrationEndpoint.installationsPath .trimmingCharacters(in: CharacterSet(charactersIn: "/")) var request = HTTPRequest(url: baseURL.appendingPathComponent(path)) request.method = .post request.headerFields[.accept] = "application/json"
let data = try await withDependencies { // Bootstrap carries no authorization and its response contains the reusable secret. // Do not run general request/response loggers for this exchange. $0.requestInterceptors = [] $0.responseInterceptors = [] $0.errorInterceptors = [] } operation: { try await httpClient.run(request, nil) } guard let data else { throw NotificationInstallationCredentialMissing() } return try JSONDecoder() .decode(NotificationInstallationCredentialPayload.self, from: data) .credential }
func finishResolution( with result: Result<NotificationInstallation.Credential, any Error> ) { isResolvingCredential = false let waiters = self.waiters self.waiters.removeAll() for waiter in waiters { waiter.resume(with: result) } }}
private struct NotificationInstallationCredentialMissing: Error {}
private extension NotificationInstallation.Credential { var authorizationHeader: String { "\(NotificationRegistrationEndpoint.authorizationScheme) \(rawValue)" }}
private extension HTTPRequest { var notificationCredential: NotificationInstallation.Credential? { let prefix = "\(NotificationRegistrationEndpoint.authorizationScheme) " guard let authorization = headerFields[.authorization], authorization.hasPrefix(prefix) else { return nil }
let rawValue = String(authorization.dropFirst(prefix.count)) guard !rawValue.isEmpty else { return nil } return NotificationInstallation.Credential(rawValue: rawValue) }}
private extension URL { var isNotificationRegistrationURL: Bool { path == NotificationRegistrationEndpoint.destinationPath || path == NotificationRegistrationEndpoint.subscriptionsPath }}