From 1c67ea3a465ae44799711ae55f2cb2d8cc994262 Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 2 Feb 2026 08:07:51 -0800 Subject: [PATCH 01/23] Started implementing authentication --- .../Authentication/AuthenticatedSession.swift | 140 ++++++++++++++ .../Authentication/AuthenticationError.swift | 30 +++ .../Authentication/AuthenticationMethod.swift | 29 +++ .../Implementations/APIKeyAuth.swift | 49 +++++ .../Implementations/JWTAuth.swift | 171 ++++++++++++++++++ .../Implementations/NoAuth.swift | 22 +++ .../AuthenticatedSessionMockingTests.swift | 20 ++ .../AuthenticatedSessionTests.swift | 134 ++++++++++++++ .../EndpointsTests/AuthenticationTests.swift | 85 +++++++++ 9 files changed, 680 insertions(+) create mode 100644 Sources/Endpoints/Authentication/AuthenticatedSession.swift create mode 100644 Sources/Endpoints/Authentication/AuthenticationError.swift create mode 100644 Sources/Endpoints/Authentication/AuthenticationMethod.swift create mode 100644 Sources/Endpoints/Authentication/Implementations/APIKeyAuth.swift create mode 100644 Sources/Endpoints/Authentication/Implementations/JWTAuth.swift create mode 100644 Sources/Endpoints/Authentication/Implementations/NoAuth.swift create mode 100644 Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift create mode 100644 Tests/EndpointsTests/AuthenticatedSessionTests.swift create mode 100644 Tests/EndpointsTests/AuthenticationTests.swift diff --git a/Sources/Endpoints/Authentication/AuthenticatedSession.swift b/Sources/Endpoints/Authentication/AuthenticatedSession.swift new file mode 100644 index 0000000..27f41f9 --- /dev/null +++ b/Sources/Endpoints/Authentication/AuthenticatedSession.swift @@ -0,0 +1,140 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// A session wrapper that applies authentication to requests and handles token refresh. +public struct AuthenticatedSession: Sendable { + /// The underlying URLSession for network requests. + public let session: URLSession + + /// The authentication method to use. + public let auth: Auth + + /// Maximum number of retry attempts after reauthentication. Defaults to 1. + public let maxRetryAttempts: Int + + public init( + session: URLSession = .shared, + auth: Auth, + maxRetryAttempts: Int = 1 + ) { + self.session = session + self.auth = auth + self.maxRetryAttempts = maxRetryAttempts + } +} + +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) +public extension AuthenticatedSession { + + /// Performs an authenticated request expecting a Decodable response. + func response(with endpoint: T) async throws -> T.Response + where T.Response: Decodable { + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) + if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { + return mockResponse + } + #endif + + return try await performRequest(with: endpoint) { data in + try T.responseDecoder.decode(T.Response.self, from: data) + } + } + + /// Performs an authenticated request expecting a Void response. + func response(with endpoint: T) async throws + where T.Response == Void { + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) + if let _: T.Response = try await Mocking.shared.handlMock(for: T.self) { + return + } + #endif + + _ = try await performRequest(with: endpoint) { _ in () } + } + + /// Performs an authenticated request expecting raw Data. + func response(with endpoint: T) async throws -> T.Response + where T.Response == Data { + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) + if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { + return mockResponse + } + #endif + + return try await performRequest(with: endpoint) { $0 } + } + + private func performRequest( + with endpoint: T, + transform: (Data) throws -> R + ) async throws -> R { + for attempt in 0...maxRetryAttempts { + do { + let request = try createUrlRequest(for: endpoint) + let authenticatedRequest = try await auth.authenticate(request: request) + + let result: (data: Data, response: URLResponse) + do { + result = try await session.data(for: authenticatedRequest) + } catch { + if (error as NSError).code == URLError.Code.notConnectedToInternet.rawValue { + throw T.TaskError.internetConnectionOffline + } else { + throw T.TaskError.urlLoadError(error) + } + } + + let data = try T.definition.response( + data: result.data, + response: result.response, + error: nil + ).get() + + do { + return try transform(data) + } catch { + throw T.TaskError.responseParseError(data: data, error: error) + } + } catch let error as T.TaskError { + let httpResponse = extractHTTPResponse(from: error) + if auth.shouldReauthenticate(for: error, response: httpResponse), + attempt < maxRetryAttempts { + try await auth.reauthenticate() + continue + } + + throw error + } + } + + throw AuthenticationError.maxRetriesExceeded + } + + private func createUrlRequest(for endpoint: T) throws -> URLRequest { + do { + return try endpoint.urlRequest() + } catch { + guard let endpointError = error as? EndpointError else { + fatalError("Unhandled endpoint error: \(error)") + } + + throw T.TaskError.endpointError(endpointError) + } + } + + private func extractHTTPResponse(from error: EndpointTaskError) -> HTTPURLResponse? { + switch error { + case .errorResponse(let httpResponse, _): + return httpResponse + case .unexpectedResponse(let httpResponse): + return httpResponse + case .errorResponseParseError(let httpResponse, _, _): + return httpResponse + default: + return nil + } + } +} diff --git a/Sources/Endpoints/Authentication/AuthenticationError.swift b/Sources/Endpoints/Authentication/AuthenticationError.swift new file mode 100644 index 0000000..fbbab4a --- /dev/null +++ b/Sources/Endpoints/Authentication/AuthenticationError.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Errors that can occur during authentication operations. +public enum AuthenticationError: Error, Sendable { + /// No valid credentials are available to authenticate the request. + case notAuthenticated + + /// No refresh token is available to perform token refresh. + case noRefreshToken + + /// The token refresh operation failed. + case refreshFailed(underlying: Error) + + /// Maximum retry attempts exceeded. + case maxRetriesExceeded + + /// The authentication method does not support refresh. + case refreshNotSupported +} + +extension AuthenticationError: CustomNSError { + public var errorUserInfo: [String: Any] { + switch self { + case .refreshFailed(let underlying): + return [NSUnderlyingErrorKey: underlying] + default: + return [:] + } + } +} diff --git a/Sources/Endpoints/Authentication/AuthenticationMethod.swift b/Sources/Endpoints/Authentication/AuthenticationMethod.swift new file mode 100644 index 0000000..2f479ac --- /dev/null +++ b/Sources/Endpoints/Authentication/AuthenticationMethod.swift @@ -0,0 +1,29 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// A protocol that defines how to authenticate requests and handle token refresh. +public protocol AuthenticationMethod: Sendable { + + /// Applies authentication credentials to a request. + /// + /// - Parameter request: The URLRequest to authenticate. + /// - Returns: The authenticated URLRequest. + /// - Throws: `AuthenticationError.notAuthenticated` if no valid credentials are available. + func authenticate(request: URLRequest) async throws -> URLRequest + + /// Determines whether a failed request should trigger reauthentication. + /// + /// - Parameters: + /// - error: The error that occurred. + /// - response: The HTTP response, if available. + /// - Returns: `true` if reauthentication should be attempted. + func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool + + /// Performs reauthentication (e.g., token refresh). + /// + /// Implementations should coalesce concurrent calls into a single refresh operation. + func reauthenticate() async throws +} diff --git a/Sources/Endpoints/Authentication/Implementations/APIKeyAuth.swift b/Sources/Endpoints/Authentication/Implementations/APIKeyAuth.swift new file mode 100644 index 0000000..5cf13de --- /dev/null +++ b/Sources/Endpoints/Authentication/Implementations/APIKeyAuth.swift @@ -0,0 +1,49 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Authentication using a static API key. +public struct APIKeyAuth: AuthenticationMethod { + /// The API key value. + public let key: String + + /// The HTTP header to use. Defaults to `.authorization`. + public let header: Header + + /// Optional prefix before the key (e.g., "Bearer", "ApiKey"). + /// Set to nil for no prefix. + public let prefix: String? + + /// Creates an API key authentication method. + /// + /// - Parameters: + /// - key: The API key value. + /// - header: The HTTP header to use. Defaults to `.authorization`. + /// - prefix: Optional prefix (e.g., "Bearer"). Defaults to "Bearer". + public init( + key: String, + header: Header = .authorization, + prefix: String? = "Bearer" + ) { + self.key = key + self.header = header + self.prefix = prefix + } + + public func authenticate(request: URLRequest) async throws -> URLRequest { + var mutableRequest = request + let headerValue = prefix.map { "\($0) \(key)" } ?? key + mutableRequest.setValue(headerValue, forHTTPHeaderField: header.name) + return mutableRequest + } + + public func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool { + false + } + + public func reauthenticate() async throws { + throw AuthenticationError.refreshNotSupported + } +} diff --git a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift new file mode 100644 index 0000000..7d4516b --- /dev/null +++ b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift @@ -0,0 +1,171 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// JWT-based authentication with automatic token refresh. +public actor JWTAuth: AuthenticationMethod { + + // MARK: - Types + + /// A pair of access and refresh tokens. + public struct TokenPair: Sendable, Equatable { + public let accessToken: String + public let refreshToken: String + + public init(accessToken: String, refreshToken: String) { + self.accessToken = accessToken + self.refreshToken = refreshToken + } + } + + /// Configuration for JWT authentication behavior. + public struct Configuration: Sendable { + /// The HTTP header for the access token. Defaults to `.authorization`. + public let header: Header + + /// Prefix before the token (e.g., "Bearer"). Defaults to "Bearer". + public let tokenPrefix: String + + /// HTTP status codes that should trigger a token refresh. Defaults to [401]. + public let refreshTriggerStatusCodes: Set + + public init( + header: Header = .authorization, + tokenPrefix: String = "Bearer", + refreshTriggerStatusCodes: Set = [401] + ) { + self.header = header + self.tokenPrefix = tokenPrefix + self.refreshTriggerStatusCodes = refreshTriggerStatusCodes + } + + public static let `default` = Configuration() + } + + /// Closure type for performing token refresh. + /// + /// The closure receives the current refresh token and should return new tokens. + public typealias RefreshHandler = @Sendable (String) async throws -> TokenPair + + /// Closure type for handling token updates (e.g., persisting to Keychain). + public typealias TokenUpdateHandler = @Sendable (TokenPair) async -> Void + + /// Closure type for handling refresh failures (e.g., logout). + public typealias RefreshFailureHandler = @Sendable (Error) async -> Void + + // MARK: - State + + private var currentTokens: TokenPair? + private var pendingRefresh: Task? + + // MARK: - Configuration & Handlers + + private nonisolated let configuration: Configuration + private let refreshHandler: RefreshHandler + private let onTokensUpdated: TokenUpdateHandler? + private let onRefreshFailed: RefreshFailureHandler? + + // MARK: - Initialization + + public init( + initialTokens: TokenPair?, + configuration: Configuration = .default, + refreshHandler: @escaping RefreshHandler, + onTokensUpdated: TokenUpdateHandler? = nil, + onRefreshFailed: RefreshFailureHandler? = nil + ) { + self.currentTokens = initialTokens + self.configuration = configuration + self.refreshHandler = refreshHandler + self.onTokensUpdated = onTokensUpdated + self.onRefreshFailed = onRefreshFailed + } + + // MARK: - AuthenticationMethod + + public func authenticate(request: URLRequest) async throws -> URLRequest { + if let pendingRefresh { + do { + currentTokens = try await pendingRefresh.value + } catch { + // Refresh failed - fall through to notAuthenticated if no token is available. + } + } + + guard let accessToken = currentTokens?.accessToken else { + throw AuthenticationError.notAuthenticated + } + + var mutableRequest = request + let headerValue = "\(configuration.tokenPrefix) \(accessToken)" + mutableRequest.setValue(headerValue, forHTTPHeaderField: configuration.header.name) + return mutableRequest + } + + public nonisolated func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool { + guard let statusCode = response?.statusCode else { + return false + } + return configuration.refreshTriggerStatusCodes.contains(statusCode) + } + + public func reauthenticate() async throws { + if let existingRefresh = pendingRefresh { + currentTokens = try await existingRefresh.value + return + } + + guard let refreshToken = currentTokens?.refreshToken else { + throw AuthenticationError.noRefreshToken + } + + let refreshHandler = self.refreshHandler + let onTokensUpdated = self.onTokensUpdated + let onRefreshFailed = self.onRefreshFailed + + let refreshTask = Task { + do { + let newTokens = try await refreshHandler(refreshToken) + await onTokensUpdated?(newTokens) + return newTokens + } catch { + await onRefreshFailed?(error) + throw AuthenticationError.refreshFailed(underlying: error) + } + } + + pendingRefresh = refreshTask + + do { + currentTokens = try await refreshTask.value + pendingRefresh = nil + } catch { + pendingRefresh = nil + throw error + } + } + + // MARK: - Public Token Management + + public func setTokens(_ tokens: TokenPair) { + currentTokens = tokens + pendingRefresh?.cancel() + pendingRefresh = nil + } + + public func clearTokens() { + currentTokens = nil + pendingRefresh?.cancel() + pendingRefresh = nil + } + + public func getTokens() -> TokenPair? { + currentTokens + } + + public var isAuthenticated: Bool { + currentTokens != nil + } +} diff --git a/Sources/Endpoints/Authentication/Implementations/NoAuth.swift b/Sources/Endpoints/Authentication/Implementations/NoAuth.swift new file mode 100644 index 0000000..b2369f5 --- /dev/null +++ b/Sources/Endpoints/Authentication/Implementations/NoAuth.swift @@ -0,0 +1,22 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// A no-op authentication method that passes requests through unchanged. +public struct NoAuth: AuthenticationMethod { + public init() {} + + public func authenticate(request: URLRequest) async throws -> URLRequest { + request + } + + public func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool { + false + } + + public func reauthenticate() async throws { + throw AuthenticationError.refreshNotSupported + } +} diff --git a/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift b/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift new file mode 100644 index 0000000..b68a985 --- /dev/null +++ b/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift @@ -0,0 +1,20 @@ +import Testing +import Foundation +import Endpoints +@testable import EndpointsMocking + +@Suite("Authenticated Session Mocking") +struct AuthenticatedSessionMockingTests { + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func mockedResponseWorks() async throws { + let auth = APIKeyAuth(key: "test") + let session = AuthenticatedSession(auth: auth) + + try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "mocked"))) { + let endpoint = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let response = try await session.response(with: endpoint) + #expect(response.response1 == "mocked") + } + } +} diff --git a/Tests/EndpointsTests/AuthenticatedSessionTests.swift b/Tests/EndpointsTests/AuthenticatedSessionTests.swift new file mode 100644 index 0000000..3bcd5e7 --- /dev/null +++ b/Tests/EndpointsTests/AuthenticatedSessionTests.swift @@ -0,0 +1,134 @@ +#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) +import Testing +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +@testable import Endpoints + +@Suite("Authenticated Session") +struct AuthenticatedSessionTests { + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func retriesAfterReauthentication() async throws { + let url = URL(string: "https://api.velosmobile.com/auth/test")! + let errorData = try JSONEncoder().encode(AuthTestEndpoint.ErrorResponse(message: "unauthorized")) + let successData = try JSONEncoder().encode(AuthTestEndpoint.Response(value: "ok")) + + let responses = ResponseQueue([ + (HTTPURLResponse(url: url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), + (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + ]) + + TestURLProtocol.handler = { _ in + try responses.next() + } + defer { TestURLProtocol.handler = nil } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [TestURLProtocol.self] + let urlSession = URLSession(configuration: configuration) + + let auth = TestAuth() + let session = AuthenticatedSession(session: urlSession, auth: auth, maxRetryAttempts: 1) + + let response = try await session.response(with: AuthTestEndpoint()) + + #expect(response.value == "ok") + + let counts = await auth.counts() + #expect(counts.authenticate == 2) + #expect(counts.reauthenticate == 1) + } +} + +struct AuthTestEndpoint: Endpoint { + typealias Server = TestServer + + static let definition: Definition = Definition( + method: .get, + path: "auth/test" + ) + + struct Response: Codable, Sendable { + let value: String + } + + struct ErrorResponse: Codable, Sendable, Equatable { + let message: String + } +} + +actor TestAuth: AuthenticationMethod { + private var authenticateCount = 0 + private var reauthenticateCount = 0 + + func authenticate(request: URLRequest) async throws -> URLRequest { + authenticateCount += 1 + return request + } + + nonisolated func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool { + response?.statusCode == 401 + } + + func reauthenticate() async throws { + reauthenticateCount += 1 + } + + func counts() -> (authenticate: Int, reauthenticate: Int) { + (authenticateCount, reauthenticateCount) + } +} + +final class ResponseQueue { + private var responses: [(HTTPURLResponse, Data)] + private let lock = NSLock() + + init(_ responses: [(HTTPURLResponse, Data)]) { + self.responses = responses + } + + func next() throws -> (HTTPURLResponse, Data) { + lock.lock() + defer { lock.unlock() } + + guard !responses.isEmpty else { + throw URLError(.badServerResponse) + } + return responses.removeFirst() + } +} + +final class TestURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} +#endif diff --git a/Tests/EndpointsTests/AuthenticationTests.swift b/Tests/EndpointsTests/AuthenticationTests.swift new file mode 100644 index 0000000..9ddc277 --- /dev/null +++ b/Tests/EndpointsTests/AuthenticationTests.swift @@ -0,0 +1,85 @@ +import Testing +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +@testable import Endpoints + +@Suite("Authentication") +struct AuthenticationTests { + @Test + func apiKeyAuthAddsAuthorizationHeader() async throws { + let auth = APIKeyAuth(key: "test-key") + let request = URLRequest(url: URL(string: "https://example.com")!) + + let authenticated = try await auth.authenticate(request: request) + + #expect(authenticated.value(forHTTPHeaderField: "Authorization") == "Bearer test-key") + } + + @Test + func apiKeyAuthSupportsCustomHeaderAndNoPrefix() async throws { + let auth = APIKeyAuth(key: "secret", header: Header(name: "X-API-Key"), prefix: nil) + let request = URLRequest(url: URL(string: "https://example.com")!) + + let authenticated = try await auth.authenticate(request: request) + + #expect(authenticated.value(forHTTPHeaderField: "X-API-Key") == "secret") + #expect(authenticated.value(forHTTPHeaderField: "Authorization") == nil) + } + + @Test + func jwtAuthRequiresTokens() async throws { + let auth = JWTAuth(initialTokens: nil) { _ in + JWTAuth.TokenPair(accessToken: "new", refreshToken: "refresh") + } + + do { + _ = try await auth.authenticate(request: URLRequest(url: URL(string: "https://example.com")!)) + Issue.record("Expected notAuthenticated error") + } catch { + guard case AuthenticationError.notAuthenticated = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + + @Test + func jwtAuthCoalescesRefreshRequests() async throws { + let counter = RefreshCounter() + let auth = JWTAuth( + initialTokens: .init(accessToken: "old", refreshToken: "refresh"), + refreshHandler: { refreshToken in + await counter.increment() + try await Task.sleep(nanoseconds: 50_000_000) + return JWTAuth.TokenPair(accessToken: "new", refreshToken: refreshToken) + } + ) + + await withTaskGroup(of: Void.self) { group in + for _ in 0..<5 { + group.addTask { + try? await auth.reauthenticate() + } + } + } + + #expect(await counter.value() == 1) + #expect((await auth.getTokens())?.accessToken == "new") + } +} + +actor RefreshCounter { + private var count = 0 + + func increment() { + count += 1 + } + + func value() -> Int { + count + } +} From 6da6988ecb68079b16670855c41adf2efe8d0444 Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 2 Feb 2026 08:11:49 -0800 Subject: [PATCH 02/23] Added cookie auth --- .../Implementations/CookieAuth.swift | 55 +++++++++++++++++++ .../EndpointsTests/AuthenticationTests.swift | 21 +++++++ 2 files changed, 76 insertions(+) create mode 100644 Sources/Endpoints/Authentication/Implementations/CookieAuth.swift diff --git a/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift b/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift new file mode 100644 index 0000000..5b635ff --- /dev/null +++ b/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift @@ -0,0 +1,55 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Authentication using a static HTTP cookie. +public struct CookieAuth: AuthenticationMethod { + /// The cookie name. + public let name: String + + /// The cookie value. + public let value: String + + /// The HTTP header to use. Defaults to `.cookie`. + public let header: Header + + /// Whether to append to an existing Cookie header. Defaults to true. + public let appendToExisting: Bool + + public init( + name: String, + value: String, + header: Header = .cookie, + appendToExisting: Bool = true + ) { + self.name = name + self.value = value + self.header = header + self.appendToExisting = appendToExisting + } + + public func authenticate(request: URLRequest) async throws -> URLRequest { + var mutableRequest = request + let cookiePair = "\(name)=\(value)" + + if appendToExisting, + let existing = request.value(forHTTPHeaderField: header.name), + !existing.isEmpty { + mutableRequest.setValue("\(existing); \(cookiePair)", forHTTPHeaderField: header.name) + } else { + mutableRequest.setValue(cookiePair, forHTTPHeaderField: header.name) + } + + return mutableRequest + } + + public func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool { + false + } + + public func reauthenticate() async throws { + throw AuthenticationError.refreshNotSupported + } +} diff --git a/Tests/EndpointsTests/AuthenticationTests.swift b/Tests/EndpointsTests/AuthenticationTests.swift index 9ddc277..6a05326 100644 --- a/Tests/EndpointsTests/AuthenticationTests.swift +++ b/Tests/EndpointsTests/AuthenticationTests.swift @@ -70,6 +70,27 @@ struct AuthenticationTests { #expect(await counter.value() == 1) #expect((await auth.getTokens())?.accessToken == "new") } + + @Test + func cookieAuthSetsCookieHeader() async throws { + let auth = CookieAuth(name: "session", value: "abc123") + let request = URLRequest(url: URL(string: "https://example.com")!) + + let authenticated = try await auth.authenticate(request: request) + + #expect(authenticated.value(forHTTPHeaderField: Header.cookie.name) == "session=abc123") + } + + @Test + func cookieAuthAppendsToExistingCookies() async throws { + let auth = CookieAuth(name: "session", value: "abc123") + var request = URLRequest(url: URL(string: "https://example.com")!) + request.setValue("theme=dark", forHTTPHeaderField: Header.cookie.name) + + let authenticated = try await auth.authenticate(request: request) + + #expect(authenticated.value(forHTTPHeaderField: Header.cookie.name) == "theme=dark; session=abc123") + } } actor RefreshCounter { From 70e7a02f4895e0eacbb6cc031a3c53a929e896a8 Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 2 Feb 2026 21:02:24 -0800 Subject: [PATCH 03/23] APIKeyAuth to HeaderKeyAuth --- .../{APIKeyAuth.swift => HeaderKeyAuth.swift} | 10 +++++----- .../AuthenticatedSessionMockingTests.swift | 2 +- Tests/EndpointsTests/AuthenticationTests.swift | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) rename Sources/Endpoints/Authentication/Implementations/{APIKeyAuth.swift => HeaderKeyAuth.swift} (85%) diff --git a/Sources/Endpoints/Authentication/Implementations/APIKeyAuth.swift b/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift similarity index 85% rename from Sources/Endpoints/Authentication/Implementations/APIKeyAuth.swift rename to Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift index 5cf13de..e6b78b7 100644 --- a/Sources/Endpoints/Authentication/Implementations/APIKeyAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift @@ -4,9 +4,9 @@ import Foundation import FoundationNetworking #endif -/// Authentication using a static API key. -public struct APIKeyAuth: AuthenticationMethod { - /// The API key value. +/// Authentication using a static header key. +public struct HeaderKeyAuth: AuthenticationMethod { + /// The key value. public let key: String /// The HTTP header to use. Defaults to `.authorization`. @@ -16,10 +16,10 @@ public struct APIKeyAuth: AuthenticationMethod { /// Set to nil for no prefix. public let prefix: String? - /// Creates an API key authentication method. + /// Creates a header key authentication method. /// /// - Parameters: - /// - key: The API key value. + /// - key: The key value. /// - header: The HTTP header to use. Defaults to `.authorization`. /// - prefix: Optional prefix (e.g., "Bearer"). Defaults to "Bearer". public init( diff --git a/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift b/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift index b68a985..655508b 100644 --- a/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift +++ b/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift @@ -8,7 +8,7 @@ struct AuthenticatedSessionMockingTests { @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) @Test func mockedResponseWorks() async throws { - let auth = APIKeyAuth(key: "test") + let auth = HeaderKeyAuth(key: "test") let session = AuthenticatedSession(auth: auth) try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "mocked"))) { diff --git a/Tests/EndpointsTests/AuthenticationTests.swift b/Tests/EndpointsTests/AuthenticationTests.swift index 6a05326..12cfa5b 100644 --- a/Tests/EndpointsTests/AuthenticationTests.swift +++ b/Tests/EndpointsTests/AuthenticationTests.swift @@ -11,7 +11,7 @@ import FoundationNetworking struct AuthenticationTests { @Test func apiKeyAuthAddsAuthorizationHeader() async throws { - let auth = APIKeyAuth(key: "test-key") + let auth = HeaderKeyAuth(key: "test-key") let request = URLRequest(url: URL(string: "https://example.com")!) let authenticated = try await auth.authenticate(request: request) @@ -21,7 +21,7 @@ struct AuthenticationTests { @Test func apiKeyAuthSupportsCustomHeaderAndNoPrefix() async throws { - let auth = APIKeyAuth(key: "secret", header: Header(name: "X-API-Key"), prefix: nil) + let auth = HeaderKeyAuth(key: "secret", header: Header(name: "X-API-Key"), prefix: nil) let request = URLRequest(url: URL(string: "https://example.com")!) let authenticated = try await auth.authenticate(request: request) From 3d06bc3d21832b72e46bb28b206d45399239e28a Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 09:28:23 -0700 Subject: [PATCH 04/23] Adopt typed throws across the public API - Endpoint.urlRequest() now throws(EndpointError), removing the as?-cast/fatalError dance at call sites - URLSession.createUrlRequest, endpointTask, and the async response methods now throw T.TaskError as a typed error - Renamed Mocking.handlMock to handleMock (typo) with typed throws; the Combine variant is now handleMockPublisher to avoid overload ambiguity - Extracted shared URLSession data-loading/error-mapping into a loadData helper --- .../Authentication/AuthenticatedSession.swift | 14 ++--- Sources/Endpoints/Endpoint+URLRequest.swift | 25 ++++---- .../Extensions/URLSession+Async.swift | 59 +++++++------------ .../Extensions/URLSession+Combine.swift | 12 ++-- .../Extensions/URLSession+Endpoints.swift | 20 ++----- Sources/Endpoints/Mocking/Mocking.swift | 4 +- 6 files changed, 52 insertions(+), 82 deletions(-) diff --git a/Sources/Endpoints/Authentication/AuthenticatedSession.swift b/Sources/Endpoints/Authentication/AuthenticatedSession.swift index 27f41f9..a44ba77 100644 --- a/Sources/Endpoints/Authentication/AuthenticatedSession.swift +++ b/Sources/Endpoints/Authentication/AuthenticatedSession.swift @@ -33,7 +33,7 @@ public extension AuthenticatedSession { func response(with endpoint: T) async throws -> T.Response where T.Response: Decodable { #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { + if let mockResponse = try await Mocking.shared.handleMock(for: T.self) { return mockResponse } #endif @@ -47,7 +47,7 @@ public extension AuthenticatedSession { func response(with endpoint: T) async throws where T.Response == Void { #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - if let _: T.Response = try await Mocking.shared.handlMock(for: T.self) { + if let _: T.Response = try await Mocking.shared.handleMock(for: T.self) { return } #endif @@ -59,7 +59,7 @@ public extension AuthenticatedSession { func response(with endpoint: T) async throws -> T.Response where T.Response == Data { #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { + if let mockResponse = try await Mocking.shared.handleMock(for: T.self) { return mockResponse } #endif @@ -113,15 +113,11 @@ public extension AuthenticatedSession { throw AuthenticationError.maxRetriesExceeded } - private func createUrlRequest(for endpoint: T) throws -> URLRequest { + private func createUrlRequest(for endpoint: T) throws(T.TaskError) -> URLRequest { do { return try endpoint.urlRequest() } catch { - guard let endpointError = error as? EndpointError else { - fatalError("Unhandled endpoint error: \(error)") - } - - throw T.TaskError.endpointError(endpointError) + throw T.TaskError.endpointError(error) } } diff --git a/Sources/Endpoints/Endpoint+URLRequest.swift b/Sources/Endpoints/Endpoint+URLRequest.swift index 4e39d69..266acfb 100644 --- a/Sources/Endpoints/Endpoint+URLRequest.swift +++ b/Sources/Endpoints/Endpoint+URLRequest.swift @@ -18,12 +18,13 @@ extension Endpoint { /// - Parameter environment: The environment in which to create the request /// - Throws: An ``EndpointError`` which describes the error filling in data to the associated ``Definition``. /// - Returns: A `URLRequest` ready for requesting with all values from `self` filled in according to the associated ``Endpoint``. - public func urlRequest() throws -> URLRequest { + public func urlRequest() throws(EndpointError) -> URLRequest { var components = URLComponents() components.path = Self.definition.path.path(with: pathComponents) - let urlQueryItems: [URLQueryItem] = try Self.definition.parameters.compactMap { item in + var urlQueryItems: [URLQueryItem] = [] + for item in Self.definition.parameters { let value: Any let name: String @@ -35,7 +36,7 @@ extension Endpoint { value = queryValue name = queryName default: - return nil + continue } guard let queryValue = value as? ParameterRepresentable else { @@ -43,13 +44,12 @@ extension Endpoint { } if let encodedValue = queryValue.parameterValue { - return URLQueryItem(name: name, value: encodedValue) + urlQueryItems.append(URLQueryItem(name: name, value: encodedValue)) } - - return nil } - let bodyFormItems: [URLQueryItem] = try Self.definition.parameters.compactMap { item in + var bodyFormItems: [URLQueryItem] = [] + for item in Self.definition.parameters { let value: Any let name: String @@ -61,7 +61,7 @@ extension Endpoint { value = formValue name = formName default: - return nil + continue } guard let formValue = value as? ParameterRepresentable else { @@ -69,10 +69,8 @@ extension Endpoint { } if let encodedValue = formValue.parameterValue { - return URLQueryItem(name: name, value: encodedValue) + bodyFormItems.append(URLQueryItem(name: name, value: encodedValue)) } - - return nil } if !urlQueryItems.isEmpty { @@ -106,7 +104,8 @@ extension Endpoint { var urlRequest = URLRequest(url: url) urlRequest.httpMethod = Self.definition.method.methodString - let headerItems: [String: String] = try Self.definition.headers.reduce(into: [:]) { allHeaders, field in + var headerItems: [String: String] = [:] + for field in Self.definition.headers { let value: Any let name = field.key.name @@ -121,7 +120,7 @@ extension Endpoint { throw EndpointError.invalidHeader(named: name, type: type(of: value)) } - allHeaders[name] = headerValue.description + headerItems[name] = headerValue.description } for (name, value) in headerItems { diff --git a/Sources/Endpoints/Extensions/URLSession+Async.swift b/Sources/Endpoints/Extensions/URLSession+Async.swift index 40f3afb..4ec64ff 100644 --- a/Sources/Endpoints/Extensions/URLSession+Async.swift +++ b/Sources/Endpoints/Extensions/URLSession+Async.swift @@ -21,64 +21,55 @@ public extension URLSession { /// - Parameters: /// - environment: The environment in which to make the request /// - endpoint: The endpoint instance to be used to make the request - func response(with endpoint: T) async throws where T.Response == Void { + func response(with endpoint: T) async throws(T.TaskError) where T.Response == Void { let urlRequest = try createUrlRequest(for: endpoint) #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { + if let mockResponse = try await Mocking.shared.handleMock(for: T.self) { return mockResponse } #endif - let result: (data: Data, response: URLResponse) - do { - result = try await data(for: urlRequest) - } catch { - if (error as NSError).code == URLError.Code.notConnectedToInternet.rawValue { - throw T.TaskError.internetConnectionOffline - } else { - throw T.TaskError.urlLoadError(error) - } - } - + let result = try await loadData(for: urlRequest, endpoint: T.self) _ = try T.definition.response(data: result.data, response: result.response, error: nil).get() } - func response(with endpoint: T) async throws -> T.Response where T.Response == Data { + func response(with endpoint: T) async throws(T.TaskError) -> T.Response where T.Response == Data { let urlRequest = try createUrlRequest(for: endpoint) #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { + if let mockResponse = try await Mocking.shared.handleMock(for: T.self) { return mockResponse } #endif - let result: (data: Data, response: URLResponse) - do { - result = try await data(for: urlRequest) - } catch { - if (error as NSError).code == URLError.Code.notConnectedToInternet.rawValue { - throw T.TaskError.internetConnectionOffline - } else { - throw T.TaskError.urlLoadError(error) - } - } - + let result = try await loadData(for: urlRequest, endpoint: T.self) return try T.definition.response(data: result.data, response: result.response, error: nil).get() } - func response(with endpoint: T) async throws -> T.Response where T.Response: Decodable { + func response(with endpoint: T) async throws(T.TaskError) -> T.Response where T.Response: Decodable { let urlRequest = try createUrlRequest(for: endpoint) #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { + if let mockResponse = try await Mocking.shared.handleMock(for: T.self) { return mockResponse } #endif - let result: (data: Data, response: URLResponse) + let result = try await loadData(for: urlRequest, endpoint: T.self) + let data = try T.definition.response(data: result.data, response: result.response, error: nil).get() + + do { + return try T.responseDecoder.decode(T.Response.self, from: data) + } catch { + throw T.TaskError.responseParseError(data: data, error: error) + } + } + + /// Loads data for the request, mapping `URLSession` failures into the endpoint's ``EndpointTaskError``. + internal func loadData(for urlRequest: URLRequest, endpoint: T.Type) async throws(T.TaskError) -> (data: Data, response: URLResponse) { do { - result = try await data(for: urlRequest) + return try await data(for: urlRequest) } catch { if (error as NSError).code == URLError.Code.notConnectedToInternet.rawValue { throw T.TaskError.internetConnectionOffline @@ -86,13 +77,5 @@ public extension URLSession { throw T.TaskError.urlLoadError(error) } } - - let data = try T.definition.response(data: result.data, response: result.response, error: nil).get() - - do { - return try T.responseDecoder.decode(T.Response.self, from: data) - } catch { - throw T.TaskError.responseParseError(data: data, error: error) - } } } diff --git a/Sources/Endpoints/Extensions/URLSession+Combine.swift b/Sources/Endpoints/Extensions/URLSession+Combine.swift index 0163f3d..4d33a4c 100644 --- a/Sources/Endpoints/Extensions/URLSession+Combine.swift +++ b/Sources/Endpoints/Extensions/URLSession+Combine.swift @@ -24,7 +24,7 @@ public extension URLSession { do { urlRequest = try createUrlRequest(for: endpoint) } catch { - return Fail(outputType: T.Response.self, failure: error as! T.TaskError) + return Fail(outputType: T.Response.self, failure: error) .eraseToAnyPublisher() } @@ -45,7 +45,7 @@ public extension URLSession { .mapError { $0 as! T.TaskError } #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - return Mocking.shared.handleMock(for: T.self) + return Mocking.shared.handleMockPublisher(for: T.self) .flatMap { mock in if let mock { return Just(mock) @@ -74,7 +74,7 @@ public extension URLSession { do { urlRequest = try createUrlRequest(for: endpoint) } catch { - return Fail(outputType: T.Response.self, failure: error as! T.TaskError) + return Fail(outputType: T.Response.self, failure: error) .eraseToAnyPublisher() } @@ -95,7 +95,7 @@ public extension URLSession { .mapError { $0 as! T.TaskError } #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - return Mocking.shared.handleMock(for: T.self) + return Mocking.shared.handleMockPublisher(for: T.self) .flatMap { mock in if let mock { return Just(mock) @@ -124,7 +124,7 @@ public extension URLSession { do { urlRequest = try createUrlRequest(for: endpoint) } catch { - return Fail(outputType: T.Response.self, failure: error as! T.TaskError) + return Fail(outputType: T.Response.self, failure: error) .eraseToAnyPublisher() } @@ -151,7 +151,7 @@ public extension URLSession { .mapError { $0 as! T.TaskError } #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - return Mocking.shared.handleMock(for: T.self) + return Mocking.shared.handleMockPublisher(for: T.self) .flatMap { mock in if let mock { return Just(mock) diff --git a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift index 7e2b2af..77819a8 100644 --- a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift +++ b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift @@ -42,7 +42,7 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws -> URLSessionDataTask where T.Response == Void { + func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws(T.TaskError) -> URLSessionDataTask where T.Response == Void { let urlRequest = try createUrlRequest(for: endpoint) @@ -82,7 +82,7 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws -> URLSessionDataTask where T.Response == Data { + func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws(T.TaskError) -> URLSessionDataTask where T.Response == Data { let urlRequest = try createUrlRequest(for: endpoint) @@ -120,7 +120,7 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws -> URLSessionDataTask where T.Response: Decodable { + func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws(T.TaskError) -> URLSessionDataTask where T.Response: Decodable { let urlRequest = try createUrlRequest(for: endpoint) @@ -162,19 +162,11 @@ public extension URLSession { return task } - func createUrlRequest(for endpoint: T) throws -> URLRequest { - let urlRequest: URLRequest - + func createUrlRequest(for endpoint: T) throws(T.TaskError) -> URLRequest { do { - urlRequest = try endpoint.urlRequest() + return try endpoint.urlRequest() } catch { - guard let endpointError = error as? EndpointError else { - fatalError("Unhandled endpoint error: \(error)") - } - - throw T.TaskError.endpointError(endpointError) + throw T.TaskError.endpointError(error) } - - return urlRequest } } diff --git a/Sources/Endpoints/Mocking/Mocking.swift b/Sources/Endpoints/Mocking/Mocking.swift index f2c1548..f27fc48 100644 --- a/Sources/Endpoints/Mocking/Mocking.swift +++ b/Sources/Endpoints/Mocking/Mocking.swift @@ -44,7 +44,7 @@ struct Mocking { /// Handles a mock request for the specified endpoint type (async/await version). /// - Parameter endpointsOfType: The endpoint type being requested /// - Returns: The mock response, or nil if no mock is active - func handlMock(for endpointsOfType: T.Type) async throws -> T.Response? { + func handleMock(for endpointsOfType: T.Type) async throws(T.TaskError) -> T.Response? { guard let action = await actionForMock(for: T.self) else { return nil } @@ -100,7 +100,7 @@ extension Mocking { /// Handles a mock request for Combine publishers. /// - Parameter endpointsOfType: The endpoint type being requested /// - Returns: A publisher that emits the mock response or error - func handleMock(for endpointsOfType: T.Type) -> AnyPublisher { + func handleMockPublisher(for endpointsOfType: T.Type) -> AnyPublisher { guard shouldHandleMock(for: T.self) else { return Just(nil) .setFailureType(to: T.TaskError.self) From 765adf78ed7a55469494dc2408bf975f12ab8399 Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 09:30:35 -0700 Subject: [PATCH 05/23] Unify AuthenticatedSession error surface behind EndpointTaskError - New EndpointTaskError.authenticationError(AuthenticationError) case: AuthenticatedSession.response now throws T.TaskError exclusively (typed), instead of leaking raw AuthenticationError values - AuthenticationMethod.authenticate/reauthenticate now throw typed AuthenticationError - Restructured the retry loop so the unreachable maxRetriesExceeded throw is gone; the error case is removed entirely - Negative maxRetryAttempts values are clamped to 0 --- .../Authentication/AuthenticatedSession.swift | 77 ++++++++++--------- .../Authentication/AuthenticationError.swift | 3 - .../Authentication/AuthenticationMethod.swift | 6 +- .../Implementations/CookieAuth.swift | 4 +- .../Implementations/HeaderKeyAuth.swift | 4 +- .../Implementations/JWTAuth.swift | 19 ++++- .../Implementations/NoAuth.swift | 4 +- .../Extensions/URLSession+Endpoints.swift | 4 + .../AuthenticatedSessionTests.swift | 4 +- 9 files changed, 70 insertions(+), 55 deletions(-) diff --git a/Sources/Endpoints/Authentication/AuthenticatedSession.swift b/Sources/Endpoints/Authentication/AuthenticatedSession.swift index a44ba77..7fe53dd 100644 --- a/Sources/Endpoints/Authentication/AuthenticatedSession.swift +++ b/Sources/Endpoints/Authentication/AuthenticatedSession.swift @@ -5,6 +5,9 @@ import FoundationNetworking #endif /// A session wrapper that applies authentication to requests and handles token refresh. +/// +/// All errors — including authentication failures — surface as the endpoint's ``EndpointTaskError``. +/// Authentication-specific failures are wrapped in ``EndpointTaskError/authenticationError(_:)``. public struct AuthenticatedSession: Sendable { /// The underlying URLSession for network requests. public let session: URLSession @@ -12,7 +15,8 @@ public struct AuthenticatedSession: Sendable { /// The authentication method to use. public let auth: Auth - /// Maximum number of retry attempts after reauthentication. Defaults to 1. + /// Maximum number of retry attempts after reauthentication. + /// Negative values are treated as 0. Defaults to 1. public let maxRetryAttempts: Int public init( @@ -22,7 +26,7 @@ public struct AuthenticatedSession: Sendable { ) { self.session = session self.auth = auth - self.maxRetryAttempts = maxRetryAttempts + self.maxRetryAttempts = max(0, maxRetryAttempts) } } @@ -30,7 +34,7 @@ public struct AuthenticatedSession: Sendable { public extension AuthenticatedSession { /// Performs an authenticated request expecting a Decodable response. - func response(with endpoint: T) async throws -> T.Response + func response(with endpoint: T) async throws(T.TaskError) -> T.Response where T.Response: Decodable { #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) if let mockResponse = try await Mocking.shared.handleMock(for: T.self) { @@ -38,13 +42,17 @@ public extension AuthenticatedSession { } #endif - return try await performRequest(with: endpoint) { data in - try T.responseDecoder.decode(T.Response.self, from: data) + return try await performRequest(with: endpoint) { (data) throws(T.TaskError) in + do { + return try T.responseDecoder.decode(T.Response.self, from: data) + } catch { + throw T.TaskError.responseParseError(data: data, error: error) + } } } /// Performs an authenticated request expecting a Void response. - func response(with endpoint: T) async throws + func response(with endpoint: T) async throws(T.TaskError) where T.Response == Void { #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) if let _: T.Response = try await Mocking.shared.handleMock(for: T.self) { @@ -52,11 +60,11 @@ public extension AuthenticatedSession { } #endif - _ = try await performRequest(with: endpoint) { _ in () } + _ = try await performRequest(with: endpoint) { (_) throws(T.TaskError) in () } } /// Performs an authenticated request expecting raw Data. - func response(with endpoint: T) async throws -> T.Response + func response(with endpoint: T) async throws(T.TaskError) -> T.Response where T.Response == Data { #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) if let mockResponse = try await Mocking.shared.handleMock(for: T.self) { @@ -64,53 +72,48 @@ public extension AuthenticatedSession { } #endif - return try await performRequest(with: endpoint) { $0 } + return try await performRequest(with: endpoint) { (data) throws(T.TaskError) in data } } private func performRequest( with endpoint: T, - transform: (Data) throws -> R - ) async throws -> R { - for attempt in 0...maxRetryAttempts { - do { - let request = try createUrlRequest(for: endpoint) - let authenticatedRequest = try await auth.authenticate(request: request) + transform: (Data) throws(T.TaskError) -> R + ) async throws(T.TaskError) -> R { + var attempt = 0 + while true { + let request = try createUrlRequest(for: endpoint) - let result: (data: Data, response: URLResponse) - do { - result = try await session.data(for: authenticatedRequest) - } catch { - if (error as NSError).code == URLError.Code.notConnectedToInternet.rawValue { - throw T.TaskError.internetConnectionOffline - } else { - throw T.TaskError.urlLoadError(error) - } - } + let authenticatedRequest: URLRequest + do { + authenticatedRequest = try await auth.authenticate(request: request) + } catch { + throw T.TaskError.authenticationError(error) + } + do { + let result = try await session.loadData(for: authenticatedRequest, endpoint: T.self) let data = try T.definition.response( data: result.data, response: result.response, error: nil ).get() - do { - return try transform(data) - } catch { - throw T.TaskError.responseParseError(data: data, error: error) + return try transform(data) + } catch { + guard attempt < maxRetryAttempts, + auth.shouldReauthenticate(for: error, response: extractHTTPResponse(from: error)) else { + throw error } - } catch let error as T.TaskError { - let httpResponse = extractHTTPResponse(from: error) - if auth.shouldReauthenticate(for: error, response: httpResponse), - attempt < maxRetryAttempts { + + do { try await auth.reauthenticate() - continue + } catch { + throw T.TaskError.authenticationError(error) } - throw error + attempt += 1 } } - - throw AuthenticationError.maxRetriesExceeded } private func createUrlRequest(for endpoint: T) throws(T.TaskError) -> URLRequest { diff --git a/Sources/Endpoints/Authentication/AuthenticationError.swift b/Sources/Endpoints/Authentication/AuthenticationError.swift index fbbab4a..cc9994b 100644 --- a/Sources/Endpoints/Authentication/AuthenticationError.swift +++ b/Sources/Endpoints/Authentication/AuthenticationError.swift @@ -11,9 +11,6 @@ public enum AuthenticationError: Error, Sendable { /// The token refresh operation failed. case refreshFailed(underlying: Error) - /// Maximum retry attempts exceeded. - case maxRetriesExceeded - /// The authentication method does not support refresh. case refreshNotSupported } diff --git a/Sources/Endpoints/Authentication/AuthenticationMethod.swift b/Sources/Endpoints/Authentication/AuthenticationMethod.swift index 2f479ac..fb16fe4 100644 --- a/Sources/Endpoints/Authentication/AuthenticationMethod.swift +++ b/Sources/Endpoints/Authentication/AuthenticationMethod.swift @@ -11,8 +11,8 @@ public protocol AuthenticationMethod: Sendable { /// /// - Parameter request: The URLRequest to authenticate. /// - Returns: The authenticated URLRequest. - /// - Throws: `AuthenticationError.notAuthenticated` if no valid credentials are available. - func authenticate(request: URLRequest) async throws -> URLRequest + /// - Throws: ``AuthenticationError/notAuthenticated`` if no valid credentials are available. + func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest /// Determines whether a failed request should trigger reauthentication. /// @@ -25,5 +25,5 @@ public protocol AuthenticationMethod: Sendable { /// Performs reauthentication (e.g., token refresh). /// /// Implementations should coalesce concurrent calls into a single refresh operation. - func reauthenticate() async throws + func reauthenticate() async throws(AuthenticationError) } diff --git a/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift b/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift index 5b635ff..994f18b 100644 --- a/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift @@ -30,7 +30,7 @@ public struct CookieAuth: AuthenticationMethod { self.appendToExisting = appendToExisting } - public func authenticate(request: URLRequest) async throws -> URLRequest { + public func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { var mutableRequest = request let cookiePair = "\(name)=\(value)" @@ -49,7 +49,7 @@ public struct CookieAuth: AuthenticationMethod { false } - public func reauthenticate() async throws { + public func reauthenticate() async throws(AuthenticationError) { throw AuthenticationError.refreshNotSupported } } diff --git a/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift b/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift index e6b78b7..b3bbfa1 100644 --- a/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift @@ -32,7 +32,7 @@ public struct HeaderKeyAuth: AuthenticationMethod { self.prefix = prefix } - public func authenticate(request: URLRequest) async throws -> URLRequest { + public func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { var mutableRequest = request let headerValue = prefix.map { "\($0) \(key)" } ?? key mutableRequest.setValue(headerValue, forHTTPHeaderField: header.name) @@ -43,7 +43,7 @@ public struct HeaderKeyAuth: AuthenticationMethod { false } - public func reauthenticate() async throws { + public func reauthenticate() async throws(AuthenticationError) { throw AuthenticationError.refreshNotSupported } } diff --git a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift index 7d4516b..34f7d13 100644 --- a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift @@ -85,7 +85,7 @@ public actor JWTAuth: AuthenticationMethod { // MARK: - AuthenticationMethod - public func authenticate(request: URLRequest) async throws -> URLRequest { + public func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { if let pendingRefresh { do { currentTokens = try await pendingRefresh.value @@ -111,9 +111,9 @@ public actor JWTAuth: AuthenticationMethod { return configuration.refreshTriggerStatusCodes.contains(statusCode) } - public func reauthenticate() async throws { + public func reauthenticate() async throws(AuthenticationError) { if let existingRefresh = pendingRefresh { - currentTokens = try await existingRefresh.value + currentTokens = try await awaitRefresh(existingRefresh) return } @@ -139,7 +139,7 @@ public actor JWTAuth: AuthenticationMethod { pendingRefresh = refreshTask do { - currentTokens = try await refreshTask.value + currentTokens = try await awaitRefresh(refreshTask) pendingRefresh = nil } catch { pendingRefresh = nil @@ -147,6 +147,17 @@ public actor JWTAuth: AuthenticationMethod { } } + /// Awaits a refresh task, mapping its untyped failure back to ``AuthenticationError``. + private func awaitRefresh(_ task: Task) async throws(AuthenticationError) -> TokenPair { + do { + return try await task.value + } catch let error as AuthenticationError { + throw error + } catch { + throw .refreshFailed(underlying: error) + } + } + // MARK: - Public Token Management public func setTokens(_ tokens: TokenPair) { diff --git a/Sources/Endpoints/Authentication/Implementations/NoAuth.swift b/Sources/Endpoints/Authentication/Implementations/NoAuth.swift index b2369f5..530ebbd 100644 --- a/Sources/Endpoints/Authentication/Implementations/NoAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/NoAuth.swift @@ -8,7 +8,7 @@ import FoundationNetworking public struct NoAuth: AuthenticationMethod { public init() {} - public func authenticate(request: URLRequest) async throws -> URLRequest { + public func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { request } @@ -16,7 +16,7 @@ public struct NoAuth: AuthenticationMethod { false } - public func reauthenticate() async throws { + public func reauthenticate() async throws(AuthenticationError) { throw AuthenticationError.refreshNotSupported } } diff --git a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift index 77819a8..8f22361 100644 --- a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift +++ b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift @@ -24,6 +24,10 @@ public enum EndpointTaskError: Error, Sendable { case urlLoadError(Error) case internetConnectionOffline + + /// An authentication operation failed while performing the request + /// through an ``AuthenticatedSession``. + case authenticationError(AuthenticationError) } public extension Endpoint { diff --git a/Tests/EndpointsTests/AuthenticatedSessionTests.swift b/Tests/EndpointsTests/AuthenticatedSessionTests.swift index 3bcd5e7..2ebd805 100644 --- a/Tests/EndpointsTests/AuthenticatedSessionTests.swift +++ b/Tests/EndpointsTests/AuthenticatedSessionTests.swift @@ -65,7 +65,7 @@ actor TestAuth: AuthenticationMethod { private var authenticateCount = 0 private var reauthenticateCount = 0 - func authenticate(request: URLRequest) async throws -> URLRequest { + func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { authenticateCount += 1 return request } @@ -74,7 +74,7 @@ actor TestAuth: AuthenticationMethod { response?.statusCode == 401 } - func reauthenticate() async throws { + func reauthenticate() async throws(AuthenticationError) { reauthenticateCount += 1 } From 461d8fd5dca20bf1577782b11ee5bc033010968a Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 09:32:11 -0700 Subject: [PATCH 06/23] Fix refresh-token race for in-flight requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reauthenticate() is now reauthenticate(after:) and receives the failed authenticated request. JWTAuth compares the request's credentials against its current tokens and skips the refresh when they have already rotated — previously a request that was in flight during a successful refresh would 401 and burn a second (often single-use) refresh token. Also: JWTAuth.getTokens() is now a tokens property, and the stale-token comment in authenticate() now describes the actual behavior. --- .../Authentication/AuthenticatedSession.swift | 2 +- .../Authentication/AuthenticationMethod.swift | 8 ++++++- .../Implementations/CookieAuth.swift | 2 +- .../Implementations/HeaderKeyAuth.swift | 2 +- .../Implementations/JWTAuth.swift | 24 +++++++++++++++---- .../Implementations/NoAuth.swift | 2 +- .../AuthenticatedSessionTests.swift | 2 +- .../EndpointsTests/AuthenticationTests.swift | 9 ++++--- 8 files changed, 37 insertions(+), 14 deletions(-) diff --git a/Sources/Endpoints/Authentication/AuthenticatedSession.swift b/Sources/Endpoints/Authentication/AuthenticatedSession.swift index 7fe53dd..365a6ef 100644 --- a/Sources/Endpoints/Authentication/AuthenticatedSession.swift +++ b/Sources/Endpoints/Authentication/AuthenticatedSession.swift @@ -106,7 +106,7 @@ public extension AuthenticatedSession { } do { - try await auth.reauthenticate() + try await auth.reauthenticate(after: authenticatedRequest) } catch { throw T.TaskError.authenticationError(error) } diff --git a/Sources/Endpoints/Authentication/AuthenticationMethod.swift b/Sources/Endpoints/Authentication/AuthenticationMethod.swift index fb16fe4..f2569da 100644 --- a/Sources/Endpoints/Authentication/AuthenticationMethod.swift +++ b/Sources/Endpoints/Authentication/AuthenticationMethod.swift @@ -24,6 +24,12 @@ public protocol AuthenticationMethod: Sendable { /// Performs reauthentication (e.g., token refresh). /// + /// - Parameter failedRequest: The authenticated request that failed, as returned by + /// ``authenticate(request:)``. Implementations that rotate credentials should compare + /// the failed request's credentials against their current ones and skip refreshing + /// when they no longer match — the request failed with credentials that have already + /// been replaced, so refreshing again would needlessly consume a refresh token. + /// /// Implementations should coalesce concurrent calls into a single refresh operation. - func reauthenticate() async throws(AuthenticationError) + func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) } diff --git a/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift b/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift index 994f18b..a829768 100644 --- a/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift @@ -49,7 +49,7 @@ public struct CookieAuth: AuthenticationMethod { false } - public func reauthenticate() async throws(AuthenticationError) { + public func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { throw AuthenticationError.refreshNotSupported } } diff --git a/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift b/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift index b3bbfa1..e566fee 100644 --- a/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift @@ -43,7 +43,7 @@ public struct HeaderKeyAuth: AuthenticationMethod { false } - public func reauthenticate() async throws(AuthenticationError) { + public func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { throw AuthenticationError.refreshNotSupported } } diff --git a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift index 34f7d13..9d821f9 100644 --- a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift @@ -90,7 +90,9 @@ public actor JWTAuth: AuthenticationMethod { do { currentTokens = try await pendingRefresh.value } catch { - // Refresh failed - fall through to notAuthenticated if no token is available. + // The in-flight refresh failed. Keep the existing (possibly expired) + // tokens and send the request anyway; if it is rejected, the failure + // surfaces through shouldReauthenticate/reauthenticate. } } @@ -99,8 +101,7 @@ public actor JWTAuth: AuthenticationMethod { } var mutableRequest = request - let headerValue = "\(configuration.tokenPrefix) \(accessToken)" - mutableRequest.setValue(headerValue, forHTTPHeaderField: configuration.header.name) + mutableRequest.setValue(headerValue(for: accessToken), forHTTPHeaderField: configuration.header.name) return mutableRequest } @@ -111,7 +112,15 @@ public actor JWTAuth: AuthenticationMethod { return configuration.refreshTriggerStatusCodes.contains(statusCode) } - public func reauthenticate() async throws(AuthenticationError) { + public func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { + // If the tokens have rotated since the failed request was authenticated, the + // refresh that request needed has already happened. Refreshing again would + // consume another refresh token (often single-use), so skip. + if let accessToken = currentTokens?.accessToken, + failedRequest.value(forHTTPHeaderField: configuration.header.name) != headerValue(for: accessToken) { + return + } + if let existingRefresh = pendingRefresh { currentTokens = try await awaitRefresh(existingRefresh) return @@ -147,6 +156,10 @@ public actor JWTAuth: AuthenticationMethod { } } + private nonisolated func headerValue(for accessToken: String) -> String { + "\(configuration.tokenPrefix) \(accessToken)" + } + /// Awaits a refresh task, mapping its untyped failure back to ``AuthenticationError``. private func awaitRefresh(_ task: Task) async throws(AuthenticationError) -> TokenPair { do { @@ -172,7 +185,8 @@ public actor JWTAuth: AuthenticationMethod { pendingRefresh = nil } - public func getTokens() -> TokenPair? { + /// The current token pair, if any. + public var tokens: TokenPair? { currentTokens } diff --git a/Sources/Endpoints/Authentication/Implementations/NoAuth.swift b/Sources/Endpoints/Authentication/Implementations/NoAuth.swift index 530ebbd..78118d8 100644 --- a/Sources/Endpoints/Authentication/Implementations/NoAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/NoAuth.swift @@ -16,7 +16,7 @@ public struct NoAuth: AuthenticationMethod { false } - public func reauthenticate() async throws(AuthenticationError) { + public func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { throw AuthenticationError.refreshNotSupported } } diff --git a/Tests/EndpointsTests/AuthenticatedSessionTests.swift b/Tests/EndpointsTests/AuthenticatedSessionTests.swift index 2ebd805..3264174 100644 --- a/Tests/EndpointsTests/AuthenticatedSessionTests.swift +++ b/Tests/EndpointsTests/AuthenticatedSessionTests.swift @@ -74,7 +74,7 @@ actor TestAuth: AuthenticationMethod { response?.statusCode == 401 } - func reauthenticate() async throws(AuthenticationError) { + func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { reauthenticateCount += 1 } diff --git a/Tests/EndpointsTests/AuthenticationTests.swift b/Tests/EndpointsTests/AuthenticationTests.swift index 12cfa5b..891188e 100644 --- a/Tests/EndpointsTests/AuthenticationTests.swift +++ b/Tests/EndpointsTests/AuthenticationTests.swift @@ -59,16 +59,19 @@ struct AuthenticationTests { } ) - await withTaskGroup(of: Void.self) { group in + var failedRequest = URLRequest(url: URL(string: "https://example.com")!) + failedRequest.setValue("Bearer old", forHTTPHeaderField: Header.authorization.name) + + await withTaskGroup(of: Void.self) { [failedRequest] group in for _ in 0..<5 { group.addTask { - try? await auth.reauthenticate() + try? await auth.reauthenticate(after: failedRequest) } } } #expect(await counter.value() == 1) - #expect((await auth.getTokens())?.accessToken == "new") + #expect((await auth.tokens)?.accessToken == "new") } @Test From e251796e6a21a7b094ef66b3222b7c5fbffc3b39 Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 09:33:38 -0700 Subject: [PATCH 07/23] Add AuthenticationMethod defaults; polish CookieAuth and Header - Default implementations for shouldReauthenticate (false) and reauthenticate (refreshNotSupported): non-refreshing methods now only implement authenticate - CookieAuth replaces an existing same-name cookie instead of appending a duplicate, and drops the customizable header knob (cookies belong in the Cookie header) - Header.cookie/.setCookie categorized as request/response --- .../Authentication/AuthenticationMethod.swift | 15 +++++++++++ .../Implementations/CookieAuth.swift | 27 +++++++------------ .../Implementations/HeaderKeyAuth.swift | 8 ------ .../Implementations/NoAuth.swift | 8 ------ Sources/Endpoints/Header.swift | 4 +-- 5 files changed, 27 insertions(+), 35 deletions(-) diff --git a/Sources/Endpoints/Authentication/AuthenticationMethod.swift b/Sources/Endpoints/Authentication/AuthenticationMethod.swift index f2569da..dce2114 100644 --- a/Sources/Endpoints/Authentication/AuthenticationMethod.swift +++ b/Sources/Endpoints/Authentication/AuthenticationMethod.swift @@ -33,3 +33,18 @@ public protocol AuthenticationMethod: Sendable { /// Implementations should coalesce concurrent calls into a single refresh operation. func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) } + +public extension AuthenticationMethod { + + /// By default, failed requests never trigger reauthentication. + /// Override for credentials that can be refreshed. + func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool { + false + } + + /// By default, refresh is unsupported. + /// Override for credentials that can be refreshed. + func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { + throw AuthenticationError.refreshNotSupported + } +} diff --git a/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift b/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift index a829768..4662b9e 100644 --- a/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift @@ -12,21 +12,17 @@ public struct CookieAuth: AuthenticationMethod { /// The cookie value. public let value: String - /// The HTTP header to use. Defaults to `.cookie`. - public let header: Header - - /// Whether to append to an existing Cookie header. Defaults to true. + /// Whether to merge with cookies already on the request. Defaults to true. + /// An existing cookie with the same name is replaced. public let appendToExisting: Bool public init( name: String, value: String, - header: Header = .cookie, appendToExisting: Bool = true ) { self.name = name self.value = value - self.header = header self.appendToExisting = appendToExisting } @@ -35,21 +31,18 @@ public struct CookieAuth: AuthenticationMethod { let cookiePair = "\(name)=\(value)" if appendToExisting, - let existing = request.value(forHTTPHeaderField: header.name), + let existing = request.value(forHTTPHeaderField: Header.cookie.name), !existing.isEmpty { - mutableRequest.setValue("\(existing); \(cookiePair)", forHTTPHeaderField: header.name) + var pairs = existing + .components(separatedBy: ";") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty && $0 != name && !$0.hasPrefix("\(name)=") } + pairs.append(cookiePair) + mutableRequest.setValue(pairs.joined(separator: "; "), forHTTPHeaderField: Header.cookie.name) } else { - mutableRequest.setValue(cookiePair, forHTTPHeaderField: header.name) + mutableRequest.setValue(cookiePair, forHTTPHeaderField: Header.cookie.name) } return mutableRequest } - - public func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool { - false - } - - public func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { - throw AuthenticationError.refreshNotSupported - } } diff --git a/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift b/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift index e566fee..77fdef9 100644 --- a/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift @@ -38,12 +38,4 @@ public struct HeaderKeyAuth: AuthenticationMethod { mutableRequest.setValue(headerValue, forHTTPHeaderField: header.name) return mutableRequest } - - public func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool { - false - } - - public func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { - throw AuthenticationError.refreshNotSupported - } } diff --git a/Sources/Endpoints/Authentication/Implementations/NoAuth.swift b/Sources/Endpoints/Authentication/Implementations/NoAuth.swift index 78118d8..aeae4b7 100644 --- a/Sources/Endpoints/Authentication/Implementations/NoAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/NoAuth.swift @@ -11,12 +11,4 @@ public struct NoAuth: AuthenticationMethod { public func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { request } - - public func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool { - false - } - - public func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { - throw AuthenticationError.refreshNotSupported - } } diff --git a/Sources/Endpoints/Header.swift b/Sources/Endpoints/Header.swift index 95f763d..5126ff8 100644 --- a/Sources/Endpoints/Header.swift +++ b/Sources/Endpoints/Header.swift @@ -112,8 +112,8 @@ extension Header { public static let warning = Header(name: "Warning", category: .general) public static let keepAlive = Header(name: "Keep-Alive", category: .general) - public static let cookie = Header(name: "Cookie", category: .general) - public static let setCookie = Header(name: "Set-Cookie", category: .general) + public static let cookie = Header(name: "Cookie", category: .request) + public static let setCookie = Header(name: "Set-Cookie", category: .response) public static let clearSiteData = Header(name: "Clear-Site-Data", category: .general) // response From d530433a5d50ed354df8233a014c139cde54b3b0 Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 09:35:22 -0700 Subject: [PATCH 08/23] Fill authentication test gaps - Retry exhaustion: repeated 401s surface the final errorResponse and refresh exactly once - Refresh failure surfaces as EndpointTaskError.authenticationError - JWT end-to-end through AuthenticatedSession: 401 -> refresh -> retry with rotated Bearer token, asserting the exact Authorization headers sent on the wire - JWTAuth without tokens fails before any request is sent - JWTAuth skips refresh when the failed request used already-rotated credentials - CookieAuth replaces an existing same-name cookie The Authenticated Session suite is now .serialized since its tests share TestURLProtocol's static handler. --- .../AuthenticatedSessionTests.swift | 181 +++++++++++++++++- .../EndpointsTests/AuthenticationTests.swift | 32 ++++ 2 files changed, 203 insertions(+), 10 deletions(-) diff --git a/Tests/EndpointsTests/AuthenticatedSessionTests.swift b/Tests/EndpointsTests/AuthenticatedSessionTests.swift index 3264174..53366bc 100644 --- a/Tests/EndpointsTests/AuthenticatedSessionTests.swift +++ b/Tests/EndpointsTests/AuthenticatedSessionTests.swift @@ -8,18 +8,26 @@ import FoundationNetworking @testable import Endpoints -@Suite("Authenticated Session") +@Suite("Authenticated Session", .serialized) struct AuthenticatedSessionTests { + + private static func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [TestURLProtocol.self] + return URLSession(configuration: configuration) + } + + private static let url = URL(string: "https://api.velosmobile.com/auth/test")! + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) @Test func retriesAfterReauthentication() async throws { - let url = URL(string: "https://api.velosmobile.com/auth/test")! let errorData = try JSONEncoder().encode(AuthTestEndpoint.ErrorResponse(message: "unauthorized")) let successData = try JSONEncoder().encode(AuthTestEndpoint.Response(value: "ok")) let responses = ResponseQueue([ - (HTTPURLResponse(url: url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), - (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), + (HTTPURLResponse(url: Self.url, statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) ]) TestURLProtocol.handler = { _ in @@ -27,12 +35,8 @@ struct AuthenticatedSessionTests { } defer { TestURLProtocol.handler = nil } - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [TestURLProtocol.self] - let urlSession = URLSession(configuration: configuration) - let auth = TestAuth() - let session = AuthenticatedSession(session: urlSession, auth: auth, maxRetryAttempts: 1) + let session = AuthenticatedSession(session: Self.makeSession(), auth: auth, maxRetryAttempts: 1) let response = try await session.response(with: AuthTestEndpoint()) @@ -42,6 +46,132 @@ struct AuthenticatedSessionTests { #expect(counts.authenticate == 2) #expect(counts.reauthenticate == 1) } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func retryExhaustionSurfacesFinalErrorResponse() async throws { + let errorData = try JSONEncoder().encode(AuthTestEndpoint.ErrorResponse(message: "unauthorized")) + + let responses = ResponseQueue([ + (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), + (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) + ]) + + TestURLProtocol.handler = { _ in + try responses.next() + } + defer { TestURLProtocol.handler = nil } + + let auth = TestAuth() + let session = AuthenticatedSession(session: Self.makeSession(), auth: auth, maxRetryAttempts: 1) + + do { + _ = try await session.response(with: AuthTestEndpoint()) + Issue.record("Expected errorResponse to be thrown") + } catch { + guard case .errorResponse(let httpResponse, let errorResponse) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(httpResponse.statusCode == 401) + #expect(errorResponse.message == "unauthorized") + } + + let counts = await auth.counts() + #expect(counts.authenticate == 2) + #expect(counts.reauthenticate == 1) + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func refreshFailureSurfacesAsAuthenticationError() async throws { + let errorData = try JSONEncoder().encode(AuthTestEndpoint.ErrorResponse(message: "unauthorized")) + + let responses = ResponseQueue([ + (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) + ]) + + TestURLProtocol.handler = { _ in + try responses.next() + } + defer { TestURLProtocol.handler = nil } + + let session = AuthenticatedSession(session: Self.makeSession(), auth: FailingRefreshAuth(), maxRetryAttempts: 1) + + do { + _ = try await session.response(with: AuthTestEndpoint()) + Issue.record("Expected authenticationError to be thrown") + } catch { + guard case .authenticationError(.refreshFailed) = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func jwtRefreshFlowEndToEnd() async throws { + let errorData = try JSONEncoder().encode(AuthTestEndpoint.ErrorResponse(message: "unauthorized")) + let successData = try JSONEncoder().encode(AuthTestEndpoint.Response(value: "ok")) + + let responses = ResponseQueue([ + (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), + (HTTPURLResponse(url: Self.url, statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + ]) + + let recorder = RequestRecorder() + TestURLProtocol.handler = { request in + recorder.record(request) + return try responses.next() + } + defer { TestURLProtocol.handler = nil } + + let auth = JWTAuth( + initialTokens: .init(accessToken: "old-access", refreshToken: "old-refresh"), + refreshHandler: { refreshToken in + #expect(refreshToken == "old-refresh") + return JWTAuth.TokenPair(accessToken: "new-access", refreshToken: "new-refresh") + } + ) + let session = AuthenticatedSession(session: Self.makeSession(), auth: auth, maxRetryAttempts: 1) + + let response = try await session.response(with: AuthTestEndpoint()) + + #expect(response.value == "ok") + + let sentAuthorization = recorder.all().map { $0.value(forHTTPHeaderField: Header.authorization.name) } + #expect(sentAuthorization == ["Bearer old-access", "Bearer new-access"]) + #expect(await auth.tokens == JWTAuth.TokenPair(accessToken: "new-access", refreshToken: "new-refresh")) + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func jwtWithoutTokensFailsWithoutSendingRequest() async throws { + let recorder = RequestRecorder() + TestURLProtocol.handler = { request in + recorder.record(request) + throw URLError(.badServerResponse) + } + defer { TestURLProtocol.handler = nil } + + let auth = JWTAuth(initialTokens: nil) { _ in + JWTAuth.TokenPair(accessToken: "new", refreshToken: "refresh") + } + let session = AuthenticatedSession(session: Self.makeSession(), auth: auth) + + do { + _ = try await session.response(with: AuthTestEndpoint()) + Issue.record("Expected authenticationError to be thrown") + } catch { + guard case .authenticationError(.notAuthenticated) = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + + #expect(recorder.all().isEmpty) + } } struct AuthTestEndpoint: Endpoint { @@ -83,7 +213,21 @@ actor TestAuth: AuthenticationMethod { } } -final class ResponseQueue { +struct FailingRefreshAuth: AuthenticationMethod { + func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { + request + } + + func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool { + response?.statusCode == 401 + } + + func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { + throw .refreshFailed(underlying: URLError(.userAuthenticationRequired)) + } +} + +final class ResponseQueue: @unchecked Sendable { private var responses: [(HTTPURLResponse, Data)] private let lock = NSLock() @@ -102,6 +246,23 @@ final class ResponseQueue { } } +final class RequestRecorder: @unchecked Sendable { + private var requests: [URLRequest] = [] + private let lock = NSLock() + + func record(_ request: URLRequest) { + lock.lock() + defer { lock.unlock() } + requests.append(request) + } + + func all() -> [URLRequest] { + lock.lock() + defer { lock.unlock() } + return requests + } +} + final class TestURLProtocol: URLProtocol { nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? diff --git a/Tests/EndpointsTests/AuthenticationTests.swift b/Tests/EndpointsTests/AuthenticationTests.swift index 891188e..8128578 100644 --- a/Tests/EndpointsTests/AuthenticationTests.swift +++ b/Tests/EndpointsTests/AuthenticationTests.swift @@ -74,6 +74,27 @@ struct AuthenticationTests { #expect((await auth.tokens)?.accessToken == "new") } + @Test + func jwtAuthSkipsRefreshWhenTokensAlreadyRotated() async throws { + let counter = RefreshCounter() + let auth = JWTAuth( + initialTokens: .init(accessToken: "new", refreshToken: "refresh"), + refreshHandler: { refreshToken in + await counter.increment() + return JWTAuth.TokenPair(accessToken: "newer", refreshToken: refreshToken) + } + ) + + // A request authenticated with credentials that have since been replaced. + var staleRequest = URLRequest(url: URL(string: "https://example.com")!) + staleRequest.setValue("Bearer old", forHTTPHeaderField: Header.authorization.name) + + try await auth.reauthenticate(after: staleRequest) + + #expect(await counter.value() == 0) + #expect((await auth.tokens)?.accessToken == "new") + } + @Test func cookieAuthSetsCookieHeader() async throws { let auth = CookieAuth(name: "session", value: "abc123") @@ -94,6 +115,17 @@ struct AuthenticationTests { #expect(authenticated.value(forHTTPHeaderField: Header.cookie.name) == "theme=dark; session=abc123") } + + @Test + func cookieAuthReplacesExistingSameNameCookie() async throws { + let auth = CookieAuth(name: "session", value: "new") + var request = URLRequest(url: URL(string: "https://example.com")!) + request.setValue("session=old; theme=dark", forHTTPHeaderField: Header.cookie.name) + + let authenticated = try await auth.authenticate(request: request) + + #expect(authenticated.value(forHTTPHeaderField: Header.cookie.name) == "theme=dark; session=new") + } } actor RefreshCounter { From a5d8e79f5631ca2461561e29666d7471b9ada068 Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 09:36:20 -0700 Subject: [PATCH 09/23] Document authentication in the README - New Authentication section: AuthenticatedSession usage, built-in methods, JWTAuth refresh flow, custom methods, typed error handling - Clarify requestProcessor's role (static signing) vs AuthenticationMethod (refreshable credentials) - Note authentication is async/await-only - Fix stale install snippet version (2.0.0 -> 0.5.0) --- README.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ae6e5d6..7c796e9 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ The purpose of Endpoints is to, in a type-safe way, define how to create a `URLR - **Type-safe endpoint definitions** - Define endpoints with compile-time checking of paths, parameters, and headers - **Server definition with multiple environments** - Support for local, development, staging, and production environments with easy switching +- **Authentication with automatic token refresh** - Attach credentials to requests and transparently refresh and retry on expiry via `AuthenticatedSession` - **Built-in mocking support** - Comprehensive testing utilities through the `EndpointsMocking` module -- **Swift 6.0 compatible** - Built with modern Swift concurrency and Sendable support +- **Swift 6.0 compatible** - Built with modern Swift concurrency, Sendable support and typed throws - **Combine and async/await support** - Use either reactive or async patterns ## Getting Started @@ -43,7 +44,7 @@ struct ApiServer: ServerDefinition { To get started, first create a type (struct or class) conforming to `Endpoint`. There are only two required elements to conform: defining the `Response` and creating the `Definition`. -`Endpoints` and `Definitions` now include server information, eliminating the need to pass environments at call time. Servers implement a `requestProcessor` which has a final hook before `URLRequest` creation to modify the `URLRequest` to attach authentication or signatures. +`Endpoints` and `Definitions` now include server information, eliminating the need to pass environments at call time. Servers can implement a `requestProcessor`, a final synchronous hook after `URLRequest` creation for static request modification such as signing. For credentials that can expire and be refreshed, use [Authentication](#authentication) instead. ### Basic Endpoint Example @@ -92,6 +93,83 @@ do { } ``` +## Authentication + +`AuthenticatedSession` wraps a `URLSession` and applies an `AuthenticationMethod` to every request. It mirrors the async `URLSession.response(with:)` API (authentication is async/await-only; the Combine and closure-based APIs do not support it): + +```swift +let session = AuthenticatedSession(auth: HeaderKeyAuth(key: "my-api-key")) +let response = try await session.response(with: MyEndpoint()) +``` + +Built-in authentication methods: + +- `HeaderKeyAuth` - A static key in a header, with an optional prefix. Defaults to `Authorization: Bearer `; use `HeaderKeyAuth(key: "secret", header: "X-API-Key", prefix: nil)` for custom API-key headers. +- `CookieAuth` - A static cookie, merged with any cookies already on the request. +- `JWTAuth` - Access/refresh token pairs with automatic refresh (see below). +- `NoAuth` - Passes requests through unchanged. Useful as a generic placeholder. + +### Token refresh with JWTAuth + +`JWTAuth` holds an access/refresh token pair. When a request fails with a status code in `refreshTriggerStatusCodes` (401 by default), the session calls your `refreshHandler` and retries the request with the new tokens. Concurrent refreshes are coalesced into a single operation, and a request that fails with already-replaced tokens will not trigger a redundant refresh — important when your backend rotates single-use refresh tokens. + +```swift +let auth = JWTAuth( + initialTokens: loadTokensFromKeychain(), + refreshHandler: { refreshToken in + // Exchange the refresh token for new tokens against your backend. + let response = try await URLSession.shared.response(with: RefreshEndpoint(token: refreshToken)) + return JWTAuth.TokenPair(accessToken: response.access, refreshToken: response.refresh) + }, + onTokensUpdated: { tokens in + saveTokensToKeychain(tokens) + }, + onRefreshFailed: { error in + await logOut() + } +) + +let session = AuthenticatedSession(auth: auth) +``` + +After a login or logout, update the tokens with `await auth.setTokens(_:)` or `await auth.clearTokens()`. + +### Custom authentication methods + +Conform to `AuthenticationMethod` to implement your own scheme. Only `authenticate(request:)` is required; refreshable credentials also implement `shouldReauthenticate(for:response:)` and `reauthenticate(after:)`: + +```swift +struct SignatureAuth: AuthenticationMethod { + let secret: String + + func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { + var request = request + request.setValue(sign(request, with: secret), forHTTPHeaderField: "X-Signature") + return request + } +} +``` + +### Error handling + +All failures from `AuthenticatedSession` — including authentication failures — surface as the endpoint's typed `EndpointTaskError`, so a single `catch` covers everything: + +```swift +do { + let response = try await session.response(with: MyEndpoint()) +} catch { + // error is MyEndpoint.TaskError — no casting needed + switch error { + case .authenticationError(.refreshFailed(let underlying)): + // token refresh failed; underlying holds the refresh error + case .errorResponse(_, let errorResponse): + // typed server error response + default: + break + } +} +``` + ## Testing with EndpointsMocking Endpoints includes a comprehensive mocking system through the `EndpointsMocking` module: @@ -115,6 +193,7 @@ The mocking system supports: - Throwing network errors - Dynamic response generation - Combine publisher mocking +- Both `URLSession` extensions and `AuthenticatedSession` To find out more about the pieces of the `Endpoint`, check out [Defining a ResponseType](https://github.com/velos/Endpoints/wiki/DefiningResponseType) on the wiki. @@ -135,7 +214,7 @@ Add the following to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/velos/Endpoints.git", from: "2.0.0") + .package(url: "https://github.com/velos/Endpoints.git", from: "0.5.0") ] ``` From 3941ab09ac7086f8cb34c800dc42743df321ae28 Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 10:28:23 -0700 Subject: [PATCH 10/23] Require Sendable on ServerDefinition.Environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Environment values cross threads through the shared environment storage (ApiServer.environment can be read and switched from any thread), but the associatedtype was only constrained to Hashable — the nonisolated(unsafe) storage was silencing the requirement. Constrain it to Hashable & Sendable and enforce it at the EnvironmentStorage boundary too. Also drop the now-redundant 'any (ServerDefinition & Sendable)' in EndpointError since ServerDefinition already requires Sendable. --- Sources/Endpoints/Endpoint.swift | 2 +- Sources/Endpoints/EnvironmentType.swift | 5 ++++- Sources/Endpoints/Server.swift | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/Endpoints/Endpoint.swift b/Sources/Endpoints/Endpoint.swift index c7e6883..89616e7 100644 --- a/Sources/Endpoints/Endpoint.swift +++ b/Sources/Endpoints/Endpoint.swift @@ -18,7 +18,7 @@ public enum EndpointError: Error, Sendable { case invalidForm(named: String, type: Any.Type) case invalidHeader(named: String, type: Any.Type) case invalidBody(Error) - case misconfiguredServer(server: any (ServerDefinition & Sendable)) + case misconfiguredServer(server: any ServerDefinition) } public enum Parameter: Sendable { diff --git a/Sources/Endpoints/EnvironmentType.swift b/Sources/Endpoints/EnvironmentType.swift index ca49fea..9dbf74e 100644 --- a/Sources/Endpoints/EnvironmentType.swift +++ b/Sources/Endpoints/EnvironmentType.swift @@ -41,7 +41,10 @@ public enum TypicalEnvironments: String, CaseIterable, Sendable { /// ``` public protocol ServerDefinition: Sendable { /// The environment type for this server. Defaults to ``TypicalEnvironments``. - associatedtype Environments: Hashable = TypicalEnvironments + /// + /// Must be `Sendable`: the current environment is stored in shared state and may be + /// read and switched from any thread via ``environment``. + associatedtype Environments: Hashable & Sendable = TypicalEnvironments /// Required initializer for creating server instances. init() diff --git a/Sources/Endpoints/Server.swift b/Sources/Endpoints/Server.swift index 3f6f7d8..8e0a638 100644 --- a/Sources/Endpoints/Server.swift +++ b/Sources/Endpoints/Server.swift @@ -13,14 +13,14 @@ enum EnvironmentStorage { private static let lock = NSLock() nonisolated(unsafe) private static var environments: [ObjectIdentifier: Any] = [:] - static func getEnvironment(for type: T.Type) -> T? { + static func getEnvironment(for type: T.Type) -> T? { lock.lock() defer { lock.unlock() } let typeKey = ObjectIdentifier(type) return environments[typeKey] as? T } - static func setEnvironment(_ environment: T, for type: T.Type) { + static func setEnvironment(_ environment: T, for type: T.Type) { lock.lock() defer { lock.unlock() } let typeKey = ObjectIdentifier(type) From 51cdb79e943f5a49444babb676063422a4f54480 Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 11:00:34 -0700 Subject: [PATCH 11/23] Close auth-layer test coverage gaps - TestURLProtocol now routes handlers by URL path instead of a single global closure, so independent suites can run in parallel on disjoint paths; shared transport helpers moved to TestTransport.swift - New tests: Void and Data response variants through AuthenticatedSession, decode-failure surfacing, static auth (HeaderKey) not retrying a 401, JWT-native refresh failure invoking onRefreshFailed, authenticate awaiting an in-flight refresh, JWT token management (set/clear/isAuthenticated), NoAuth passthrough and the protocol default implementations, CustomNSError underlying-error bridging --- .../AuthenticatedSessionTests.swift | 258 ++++++++++++------ .../EndpointsTests/AuthenticationTests.swift | 116 ++++++++ Tests/EndpointsTests/TestTransport.swift | 106 +++++++ 3 files changed, 398 insertions(+), 82 deletions(-) create mode 100644 Tests/EndpointsTests/TestTransport.swift diff --git a/Tests/EndpointsTests/AuthenticatedSessionTests.swift b/Tests/EndpointsTests/AuthenticatedSessionTests.swift index 53366bc..822e63c 100644 --- a/Tests/EndpointsTests/AuthenticatedSessionTests.swift +++ b/Tests/EndpointsTests/AuthenticatedSessionTests.swift @@ -11,12 +11,6 @@ import FoundationNetworking @Suite("Authenticated Session", .serialized) struct AuthenticatedSessionTests { - private static func makeSession() -> URLSession { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [TestURLProtocol.self] - return URLSession(configuration: configuration) - } - private static let url = URL(string: "https://api.velosmobile.com/auth/test")! @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) @@ -30,13 +24,13 @@ struct AuthenticatedSessionTests { (HTTPURLResponse(url: Self.url, statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) ]) - TestURLProtocol.handler = { _ in + TestURLProtocol.register(path: "/auth/test") { _ in try responses.next() } - defer { TestURLProtocol.handler = nil } + defer { TestURLProtocol.unregister(path: "/auth/test") } let auth = TestAuth() - let session = AuthenticatedSession(session: Self.makeSession(), auth: auth, maxRetryAttempts: 1) + let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: auth, maxRetryAttempts: 1) let response = try await session.response(with: AuthTestEndpoint()) @@ -57,13 +51,13 @@ struct AuthenticatedSessionTests { (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) ]) - TestURLProtocol.handler = { _ in + TestURLProtocol.register(path: "/auth/test") { _ in try responses.next() } - defer { TestURLProtocol.handler = nil } + defer { TestURLProtocol.unregister(path: "/auth/test") } let auth = TestAuth() - let session = AuthenticatedSession(session: Self.makeSession(), auth: auth, maxRetryAttempts: 1) + let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: auth, maxRetryAttempts: 1) do { _ = try await session.response(with: AuthTestEndpoint()) @@ -91,12 +85,12 @@ struct AuthenticatedSessionTests { (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) ]) - TestURLProtocol.handler = { _ in + TestURLProtocol.register(path: "/auth/test") { _ in try responses.next() } - defer { TestURLProtocol.handler = nil } + defer { TestURLProtocol.unregister(path: "/auth/test") } - let session = AuthenticatedSession(session: Self.makeSession(), auth: FailingRefreshAuth(), maxRetryAttempts: 1) + let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: FailingRefreshAuth(), maxRetryAttempts: 1) do { _ = try await session.response(with: AuthTestEndpoint()) @@ -121,11 +115,11 @@ struct AuthenticatedSessionTests { ]) let recorder = RequestRecorder() - TestURLProtocol.handler = { request in + TestURLProtocol.register(path: "/auth/test") { request in recorder.record(request) return try responses.next() } - defer { TestURLProtocol.handler = nil } + defer { TestURLProtocol.unregister(path: "/auth/test") } let auth = JWTAuth( initialTokens: .init(accessToken: "old-access", refreshToken: "old-refresh"), @@ -134,7 +128,7 @@ struct AuthenticatedSessionTests { return JWTAuth.TokenPair(accessToken: "new-access", refreshToken: "new-refresh") } ) - let session = AuthenticatedSession(session: Self.makeSession(), auth: auth, maxRetryAttempts: 1) + let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: auth, maxRetryAttempts: 1) let response = try await session.response(with: AuthTestEndpoint()) @@ -149,16 +143,16 @@ struct AuthenticatedSessionTests { @Test func jwtWithoutTokensFailsWithoutSendingRequest() async throws { let recorder = RequestRecorder() - TestURLProtocol.handler = { request in + TestURLProtocol.register(path: "/auth/test") { request in recorder.record(request) throw URLError(.badServerResponse) } - defer { TestURLProtocol.handler = nil } + defer { TestURLProtocol.unregister(path: "/auth/test") } let auth = JWTAuth(initialTokens: nil) { _ in JWTAuth.TokenPair(accessToken: "new", refreshToken: "refresh") } - let session = AuthenticatedSession(session: Self.makeSession(), auth: auth) + let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: auth) do { _ = try await session.response(with: AuthTestEndpoint()) @@ -172,6 +166,142 @@ struct AuthenticatedSessionTests { #expect(recorder.all().isEmpty) } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func jwtRefreshFailureInvokesFailureHandler() async throws { + let errorData = try JSONEncoder().encode(AuthTestEndpoint.ErrorResponse(message: "unauthorized")) + + let responses = ResponseQueue([ + (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) + ]) + + TestURLProtocol.register(path: "/auth/test") { _ in + try responses.next() + } + defer { TestURLProtocol.unregister(path: "/auth/test") } + + let failureBox = ErrorBox() + let auth = JWTAuth( + initialTokens: .init(accessToken: "old", refreshToken: "refresh"), + refreshHandler: { _ in throw TestRefreshError() }, + onRefreshFailed: { error in await failureBox.set(error) } + ) + let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: auth, maxRetryAttempts: 1) + + do { + _ = try await session.response(with: AuthTestEndpoint()) + Issue.record("Expected authenticationError to be thrown") + } catch { + guard case .authenticationError(.refreshFailed(let underlying)) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(underlying is TestRefreshError) + } + + #expect(await failureBox.error is TestRefreshError) + #expect((await auth.tokens)?.accessToken == "old") + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func staticAuthDoesNotRetryOn401() async throws { + let errorData = try JSONEncoder().encode(AuthTestEndpoint.ErrorResponse(message: "unauthorized")) + + let responses = ResponseQueue([ + (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) + ]) + + let recorder = RequestRecorder() + TestURLProtocol.register(path: "/auth/test") { request in + recorder.record(request) + return try responses.next() + } + defer { TestURLProtocol.unregister(path: "/auth/test") } + + let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: HeaderKeyAuth(key: "static-key"), maxRetryAttempts: 1) + + do { + _ = try await session.response(with: AuthTestEndpoint()) + Issue.record("Expected errorResponse to be thrown") + } catch { + guard case .errorResponse(let httpResponse, _) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(httpResponse.statusCode == 401) + } + + let sent = recorder.all() + #expect(sent.count == 1) + #expect(sent.first?.value(forHTTPHeaderField: Header.authorization.name) == "Bearer static-key") + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func voidResponse() async throws { + let voidUrl = URL(string: "https://api.velosmobile.com/auth/void")! + let responses = ResponseQueue([ + (HTTPURLResponse(url: voidUrl, statusCode: 204, httpVersion: nil, headerFields: nil)!, Data()) + ]) + + TestURLProtocol.register(path: "/auth/void") { _ in + try responses.next() + } + defer { TestURLProtocol.unregister(path: "/auth/void") } + + let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: NoAuth()) + + try await session.response(with: AuthVoidEndpoint()) + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func dataResponse() async throws { + let dataUrl = URL(string: "https://api.velosmobile.com/auth/data")! + let payload = Data([0xde, 0xad, 0xbe, 0xef]) + let responses = ResponseQueue([ + (HTTPURLResponse(url: dataUrl, statusCode: 200, httpVersion: nil, headerFields: nil)!, payload) + ]) + + TestURLProtocol.register(path: "/auth/data") { _ in + try responses.next() + } + defer { TestURLProtocol.unregister(path: "/auth/data") } + + let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: NoAuth()) + + let response = try await session.response(with: AuthDataEndpoint()) + #expect(response == payload) + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func decodeFailureSurfacesAsResponseParseError() async throws { + let garbage = Data("not json".utf8) + let responses = ResponseQueue([ + (HTTPURLResponse(url: Self.url, statusCode: 200, httpVersion: nil, headerFields: nil)!, garbage) + ]) + + TestURLProtocol.register(path: "/auth/test") { _ in + try responses.next() + } + defer { TestURLProtocol.unregister(path: "/auth/test") } + + let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: NoAuth()) + + do { + _ = try await session.response(with: AuthTestEndpoint()) + Issue.record("Expected responseParseError to be thrown") + } catch { + guard case .responseParseError(let data, _) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(data == garbage) + } + } } struct AuthTestEndpoint: Endpoint { @@ -191,6 +321,26 @@ struct AuthTestEndpoint: Endpoint { } } +struct AuthVoidEndpoint: Endpoint { + typealias Server = TestServer + typealias Response = Void + + static let definition: Definition = Definition( + method: .post, + path: "auth/void" + ) +} + +struct AuthDataEndpoint: Endpoint { + typealias Server = TestServer + typealias Response = Data + + static let definition: Definition = Definition( + method: .get, + path: "auth/data" + ) +} + actor TestAuth: AuthenticationMethod { private var authenticateCount = 0 private var reauthenticateCount = 0 @@ -227,69 +377,13 @@ struct FailingRefreshAuth: AuthenticationMethod { } } -final class ResponseQueue: @unchecked Sendable { - private var responses: [(HTTPURLResponse, Data)] - private let lock = NSLock() - - init(_ responses: [(HTTPURLResponse, Data)]) { - self.responses = responses - } - - func next() throws -> (HTTPURLResponse, Data) { - lock.lock() - defer { lock.unlock() } - - guard !responses.isEmpty else { - throw URLError(.badServerResponse) - } - return responses.removeFirst() - } -} - -final class RequestRecorder: @unchecked Sendable { - private var requests: [URLRequest] = [] - private let lock = NSLock() +struct TestRefreshError: Error {} - func record(_ request: URLRequest) { - lock.lock() - defer { lock.unlock() } - requests.append(request) - } - - func all() -> [URLRequest] { - lock.lock() - defer { lock.unlock() } - return requests - } -} - -final class TestURLProtocol: URLProtocol { - nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? - - override class func canInit(with request: URLRequest) -> Bool { - true - } - - override class func canonicalRequest(for request: URLRequest) -> URLRequest { - request - } - - override func startLoading() { - guard let handler = Self.handler else { - client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) - return - } +actor ErrorBox { + private(set) var error: Error? - do { - let (response, data) = try handler(request) - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) - } catch { - client?.urlProtocol(self, didFailWithError: error) - } + func set(_ error: Error) { + self.error = error } - - override func stopLoading() {} } #endif diff --git a/Tests/EndpointsTests/AuthenticationTests.swift b/Tests/EndpointsTests/AuthenticationTests.swift index 8128578..176df5c 100644 --- a/Tests/EndpointsTests/AuthenticationTests.swift +++ b/Tests/EndpointsTests/AuthenticationTests.swift @@ -126,6 +126,122 @@ struct AuthenticationTests { #expect(authenticated.value(forHTTPHeaderField: Header.cookie.name) == "theme=dark; session=new") } + + @Test + func noAuthPassesThroughAndDoesNotSupportRefresh() async throws { + let auth = NoAuth() + var request = URLRequest(url: URL(string: "https://example.com")!) + request.setValue("value", forHTTPHeaderField: "X-Existing") + + let authenticated = try await auth.authenticate(request: request) + #expect(authenticated == request) + + // Default implementations: never reauthenticate, refresh unsupported. + #expect(auth.shouldReauthenticate(for: URLError(.userAuthenticationRequired), response: nil) == false) + + do { + try await auth.reauthenticate(after: request) + Issue.record("Expected refreshNotSupported error") + } catch { + guard case .refreshNotSupported = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + + @Test + func jwtAuthTokenManagement() async throws { + let auth = JWTAuth(initialTokens: nil) { refreshToken in + JWTAuth.TokenPair(accessToken: "refreshed", refreshToken: refreshToken) + } + + #expect(await auth.isAuthenticated == false) + #expect(await auth.tokens == nil) + + let tokens = JWTAuth.TokenPair(accessToken: "access", refreshToken: "refresh") + await auth.setTokens(tokens) + #expect(await auth.isAuthenticated == true) + #expect(await auth.tokens == tokens) + + let authenticated = try await auth.authenticate(request: URLRequest(url: URL(string: "https://example.com")!)) + #expect(authenticated.value(forHTTPHeaderField: Header.authorization.name) == "Bearer access") + + await auth.clearTokens() + #expect(await auth.isAuthenticated == false) + #expect(await auth.tokens == nil) + + do { + _ = try await auth.authenticate(request: URLRequest(url: URL(string: "https://example.com")!)) + Issue.record("Expected notAuthenticated error") + } catch { + guard case .notAuthenticated = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + + @Test + func jwtAuthenticateAwaitsInFlightRefresh() async throws { + let refreshStarted = Gate() + let releaseRefresh = Gate() + + let auth = JWTAuth( + initialTokens: .init(accessToken: "old", refreshToken: "refresh"), + refreshHandler: { refreshToken in + await refreshStarted.open() + await releaseRefresh.wait() + return JWTAuth.TokenPair(accessToken: "new", refreshToken: refreshToken) + } + ) + + var staleRequest = URLRequest(url: URL(string: "https://example.com")!) + staleRequest.setValue("Bearer old", forHTTPHeaderField: Header.authorization.name) + + let refreshTask = Task { [staleRequest] in + try await auth.reauthenticate(after: staleRequest) + } + + await refreshStarted.wait() + + // While the refresh is still in flight, authenticate must wait for it + // and pick up the new access token. + async let authenticated = auth.authenticate(request: URLRequest(url: URL(string: "https://example.com")!)) + await Task.yield() + await releaseRefresh.open() + + let request = try await authenticated + #expect(request.value(forHTTPHeaderField: Header.authorization.name) == "Bearer new") + + try await refreshTask.value + #expect((await auth.tokens)?.accessToken == "new") + } + + @Test + func authenticationErrorExposesUnderlyingError() { + let underlying = URLError(.timedOut) + let bridged = AuthenticationError.refreshFailed(underlying: underlying) as NSError + + #expect((bridged.userInfo[NSUnderlyingErrorKey] as? URLError) == underlying) + #expect((AuthenticationError.notAuthenticated as NSError).userInfo[NSUnderlyingErrorKey] == nil) + } +} + +actor Gate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + if isOpen { return } + await withCheckedContinuation { waiters.append($0) } + } + + func open() { + isOpen = true + waiters.forEach { $0.resume() } + waiters.removeAll() + } } actor RefreshCounter { diff --git a/Tests/EndpointsTests/TestTransport.swift b/Tests/EndpointsTests/TestTransport.swift new file mode 100644 index 0000000..7e76398 --- /dev/null +++ b/Tests/EndpointsTests/TestTransport.swift @@ -0,0 +1,106 @@ +#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// A URLProtocol that routes requests to handlers registered per URL path. +/// +/// Handlers are keyed by path so independent test suites can run in parallel +/// without racing on shared state, as long as they use disjoint paths. Tests +/// that reuse a path must run serialized. +final class TestURLProtocol: URLProtocol { + typealias Handler = @Sendable (URLRequest) throws -> (HTTPURLResponse, Data) + + private static let lock = NSLock() + nonisolated(unsafe) private static var handlers: [String: Handler] = [:] + + static func register(path: String, handler: @escaping Handler) { + lock.lock() + defer { lock.unlock() } + handlers[path] = handler + } + + static func unregister(path: String) { + lock.lock() + defer { lock.unlock() } + handlers[path] = nil + } + + private static func handler(for request: URLRequest) -> Handler? { + lock.lock() + defer { lock.unlock() } + guard let path = request.url?.path else { return nil } + return handlers[path] + } + + static func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [TestURLProtocol.self] + return URLSession(configuration: configuration) + } + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler(for: request) else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +final class ResponseQueue: @unchecked Sendable { + private var responses: [(HTTPURLResponse, Data)] + private let lock = NSLock() + + init(_ responses: [(HTTPURLResponse, Data)]) { + self.responses = responses + } + + func next() throws -> (HTTPURLResponse, Data) { + lock.lock() + defer { lock.unlock() } + + guard !responses.isEmpty else { + throw URLError(.badServerResponse) + } + return responses.removeFirst() + } +} + +final class RequestRecorder: @unchecked Sendable { + private var requests: [URLRequest] = [] + private let lock = NSLock() + + func record(_ request: URLRequest) { + lock.lock() + defer { lock.unlock() } + requests.append(request) + } + + func all() -> [URLRequest] { + lock.lock() + defer { lock.unlock() } + return requests + } +} +#endif From fd82680d054e33053785d6ce3d97984bb3c31b98 Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 11:01:38 -0700 Subject: [PATCH 12/23] Cover the async URLSession transport paths New serialized suite exercising URLSession.response(with:) through TestURLProtocol rather than the mock short-circuit: decodable/Void/Data successes, response and error-response parse failures, decoded error responses, urlLoadError mapping, and offline detection. Raises URLSession+Async line coverage from 23% to 96% and package total from 62% to 68%. --- .../URLSessionAsyncTransportTests.swift | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 Tests/EndpointsTests/URLSessionAsyncTransportTests.swift diff --git a/Tests/EndpointsTests/URLSessionAsyncTransportTests.swift b/Tests/EndpointsTests/URLSessionAsyncTransportTests.swift new file mode 100644 index 0000000..e0359a3 --- /dev/null +++ b/Tests/EndpointsTests/URLSessionAsyncTransportTests.swift @@ -0,0 +1,196 @@ +#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) +import Testing +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +@testable import Endpoints + +/// Exercises the real (non-mocked) request paths of the async `URLSession` extension +/// through `TestURLProtocol`: response parsing, error decoding, and load-error mapping. +@Suite("Async URLSession Transport", .serialized) +struct URLSessionAsyncTransportTests { + + private static let url = URL(string: "https://api.velosmobile.com/transport/test")! + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func decodableSuccess() async throws { + let successData = try JSONEncoder().encode(TransportEndpoint.Response(value: "ok")) + TestURLProtocol.register(path: "/transport/test") { _ in + (HTTPURLResponse(url: Self.url, statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + } + defer { TestURLProtocol.unregister(path: "/transport/test") } + + let response = try await TestURLProtocol.makeSession().response(with: TransportEndpoint()) + #expect(response.value == "ok") + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func decodeFailureSurfacesAsResponseParseError() async throws { + let garbage = Data("not json".utf8) + TestURLProtocol.register(path: "/transport/test") { _ in + (HTTPURLResponse(url: Self.url, statusCode: 200, httpVersion: nil, headerFields: nil)!, garbage) + } + defer { TestURLProtocol.unregister(path: "/transport/test") } + + do { + _ = try await TestURLProtocol.makeSession().response(with: TransportEndpoint()) + Issue.record("Expected responseParseError to be thrown") + } catch { + guard case .responseParseError(let data, _) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(data == garbage) + } + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func errorResponseIsDecoded() async throws { + let errorData = try JSONEncoder().encode(TransportEndpoint.ErrorResponse(message: "unprocessable")) + TestURLProtocol.register(path: "/transport/test") { _ in + (HTTPURLResponse(url: Self.url, statusCode: 422, httpVersion: nil, headerFields: nil)!, errorData) + } + defer { TestURLProtocol.unregister(path: "/transport/test") } + + do { + _ = try await TestURLProtocol.makeSession().response(with: TransportEndpoint()) + Issue.record("Expected errorResponse to be thrown") + } catch { + guard case .errorResponse(let httpResponse, let errorResponse) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(httpResponse.statusCode == 422) + #expect(errorResponse.message == "unprocessable") + } + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func undecodableErrorResponseSurfacesAsErrorResponseParseError() async throws { + let garbage = Data("ise".utf8) + TestURLProtocol.register(path: "/transport/test") { _ in + (HTTPURLResponse(url: Self.url, statusCode: 500, httpVersion: nil, headerFields: nil)!, garbage) + } + defer { TestURLProtocol.unregister(path: "/transport/test") } + + do { + _ = try await TestURLProtocol.makeSession().response(with: TransportEndpoint()) + Issue.record("Expected errorResponseParseError to be thrown") + } catch { + guard case .errorResponseParseError(let httpResponse, let data, _) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(httpResponse.statusCode == 500) + #expect(data == garbage) + } + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func voidResponse() async throws { + TestURLProtocol.register(path: "/transport/void") { _ in + (HTTPURLResponse(url: URL(string: "https://api.velosmobile.com/transport/void")!, statusCode: 204, httpVersion: nil, headerFields: nil)!, Data()) + } + defer { TestURLProtocol.unregister(path: "/transport/void") } + + try await TestURLProtocol.makeSession().response(with: TransportVoidEndpoint()) + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func dataResponse() async throws { + let payload = Data([0xca, 0xfe]) + TestURLProtocol.register(path: "/transport/data") { _ in + (HTTPURLResponse(url: URL(string: "https://api.velosmobile.com/transport/data")!, statusCode: 200, httpVersion: nil, headerFields: nil)!, payload) + } + defer { TestURLProtocol.unregister(path: "/transport/data") } + + let response = try await TestURLProtocol.makeSession().response(with: TransportDataEndpoint()) + #expect(response == payload) + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func loadFailureSurfacesAsUrlLoadError() async throws { + TestURLProtocol.register(path: "/transport/test") { _ in + throw URLError(.timedOut) + } + defer { TestURLProtocol.unregister(path: "/transport/test") } + + do { + _ = try await TestURLProtocol.makeSession().response(with: TransportEndpoint()) + Issue.record("Expected urlLoadError to be thrown") + } catch { + guard case .urlLoadError(let underlying) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect((underlying as NSError).code == URLError.Code.timedOut.rawValue) + } + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func offlineSurfacesAsInternetConnectionOffline() async throws { + TestURLProtocol.register(path: "/transport/test") { _ in + throw URLError(.notConnectedToInternet) + } + defer { TestURLProtocol.unregister(path: "/transport/test") } + + do { + _ = try await TestURLProtocol.makeSession().response(with: TransportEndpoint()) + Issue.record("Expected internetConnectionOffline to be thrown") + } catch { + guard case .internetConnectionOffline = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + } +} + +struct TransportEndpoint: Endpoint { + typealias Server = TestServer + + static let definition: Definition = Definition( + method: .get, + path: "transport/test" + ) + + struct Response: Codable, Sendable { + let value: String + } + + struct ErrorResponse: Codable, Sendable { + let message: String + } +} + +struct TransportVoidEndpoint: Endpoint { + typealias Server = TestServer + typealias Response = Void + + static let definition: Definition = Definition( + method: .post, + path: "transport/void" + ) +} + +struct TransportDataEndpoint: Endpoint { + typealias Server = TestServer + typealias Response = Data + + static let definition: Definition = Definition( + method: .get, + path: "transport/data" + ) +} +#endif From 10f735019756a4232b69032f9d3e5a41fdbd3bb7 Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 12:49:41 -0700 Subject: [PATCH 13/23] Key runtime environment storage by server type EnvironmentStorage was keyed by the Environments type, so two servers sharing an environment type (e.g. the default TypicalEnvironments) shared one environment slot: switching ApiServer.environment silently switched every such server, and a server without a URL for the leaked environment failed with misconfiguredServer. Surfaced as a parallel-test flake between the environment-switching test and the mocking tests. Now keyed by the server type itself, so servers switch independently. --- Sources/Endpoints/Server.swift | 21 ++++++++++++--------- Tests/EndpointsTests/EndpointsTests.swift | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/Sources/Endpoints/Server.swift b/Sources/Endpoints/Server.swift index 8e0a638..3c29a6a 100644 --- a/Sources/Endpoints/Server.swift +++ b/Sources/Endpoints/Server.swift @@ -8,23 +8,26 @@ import Foundation /// Thread-safe storage for server environments. -/// Maps environment types to their current values, allowing runtime switching. +/// Maps server types to their current environment values, allowing runtime switching. +/// +/// Keyed by the server type — not its `Environments` type — so servers that share an +/// environment type (e.g. the default ``TypicalEnvironments``) switch independently. enum EnvironmentStorage { private static let lock = NSLock() nonisolated(unsafe) private static var environments: [ObjectIdentifier: Any] = [:] - static func getEnvironment(for type: T.Type) -> T? { + static func getEnvironment(for server: S.Type) -> S.Environments? { lock.lock() defer { lock.unlock() } - let typeKey = ObjectIdentifier(type) - return environments[typeKey] as? T + let serverKey = ObjectIdentifier(server) + return environments[serverKey] as? S.Environments } - static func setEnvironment(_ environment: T, for type: T.Type) { + static func setEnvironment(_ environment: S.Environments, for server: S.Type) { lock.lock() defer { lock.unlock() } - let typeKey = ObjectIdentifier(type) - environments[typeKey] = environment + let serverKey = ObjectIdentifier(server) + environments[serverKey] = environment } } @@ -40,10 +43,10 @@ extension ServerDefinition { /// ``` public static var environment: Self.Environments { get { - EnvironmentStorage.getEnvironment(for: Self.Environments.self) ?? Self.defaultEnvironment + EnvironmentStorage.getEnvironment(for: Self.self) ?? Self.defaultEnvironment } set { - EnvironmentStorage.setEnvironment(newValue, for: Self.Environments.self) + EnvironmentStorage.setEnvironment(newValue, for: Self.self) } } } diff --git a/Tests/EndpointsTests/EndpointsTests.swift b/Tests/EndpointsTests/EndpointsTests.swift index 19df9f3..1c8344c 100644 --- a/Tests/EndpointsTests/EndpointsTests.swift +++ b/Tests/EndpointsTests/EndpointsTests.swift @@ -266,4 +266,24 @@ struct EndpointsTests { func defaultEnvironment() throws { #expect(TestServer.defaultEnvironment == .production) } + + @Test + func environmentSwitchingIsPerServer() { + // Both servers use TypicalEnvironments; switching one must not affect + // the other, which stays on its default. + EnvironmentIsolationServerA.environment = .staging + + #expect(EnvironmentIsolationServerA.environment == .staging) + #expect(EnvironmentIsolationServerB.environment == .production) + } +} + +struct EnvironmentIsolationServerA: ServerDefinition { + var baseUrls: [Environments: URL] { [.production: URL(string: "https://a.example.com")!] } + static var defaultEnvironment: Environments { .production } +} + +struct EnvironmentIsolationServerB: ServerDefinition { + var baseUrls: [Environments: URL] { [.production: URL(string: "https://b.example.com")!] } + static var defaultEnvironment: Environments { .production } } From f524bab45319109196b0c9ea37799da1eb94240f Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 12:49:41 -0700 Subject: [PATCH 14/23] Add per-endpoint mock registry with batch registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mocks are now stored per endpoint type in the TaskLocal scope instead of as a single type-erased closure. This fixes a crash where any request for endpoint B inside withMock(A.self) force-cast B's continuation to A's type — the shape every multi-endpoint flow (e.g. token refresh) takes. Unmocked endpoint types now pass through to the real transport, nested scopes merge, and an inner same-type mock shadows the outer one. New public API for mocking several endpoints in one scope: try await withMock { mocks in mocks.register(RefreshEndpoint.self, action: .return(...)) mocks.register(ProfileEndpoint.self, action: .return(...)) } test: { ... } Also adds MockContinuation.resume(with:) and documents that mocks bypass authentication and the refresh/retry loop. --- README.md | 15 ++ .../Endpoints/Mocking/MockContinuation.swift | 6 + Sources/Endpoints/Mocking/MockRegistry.swift | 49 +++++ Sources/Endpoints/Mocking/Mocking.swift | 37 +++- .../EndpointsMocking/EndpointsMocking.swift | 37 +++- .../AuthenticatedSessionMockingTests.swift | 38 ++++ .../MockRegistryTests.swift | 173 ++++++++++++++++++ 7 files changed, 339 insertions(+), 16 deletions(-) create mode 100644 Sources/Endpoints/Mocking/MockRegistry.swift create mode 100644 Tests/EndpointsMockingTests/MockRegistryTests.swift diff --git a/README.md b/README.md index 7c796e9..c93ddcc 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,19 @@ import EndpointsMocking } ``` +When a flow touches several endpoints, register them together with a `MockRegistry` instead of nesting `withMock` calls: + +```swift +try await withMock { mocks in + mocks.register(RefreshEndpoint.self, action: .return(.init(access: "new", refresh: "next"))) + mocks.register(ProfileEndpoint.self, action: .return(.init(name: "Zac"))) +} test: { + let profile = try await session.response(with: ProfileEndpoint()) +} +``` + +Mocks are scoped per endpoint type: endpoints without a registered mock pass through to the real transport, nested `withMock` scopes merge, and an inner mock for the same endpoint type shadows the outer one for the duration of its scope. + The mocking system supports: - Returning successful responses - Returning error responses @@ -195,6 +208,8 @@ The mocking system supports: - Combine publisher mocking - Both `URLSession` extensions and `AuthenticatedSession` +Note that mocks bypass authentication entirely: a mocked request never invokes the `AuthenticationMethod`, and mock errors do not trigger the refresh/retry loop. To simulate an authentication failure, throw one directly with `.throw(.authenticationError(.notAuthenticated))`; to test the refresh flow itself, use a `URLProtocol`-based fake transport. + To find out more about the pieces of the `Endpoint`, check out [Defining a ResponseType](https://github.com/velos/Endpoints/wiki/DefiningResponseType) on the wiki. ## Examples diff --git a/Sources/Endpoints/Mocking/MockContinuation.swift b/Sources/Endpoints/Mocking/MockContinuation.swift index 57d6805..488646d 100644 --- a/Sources/Endpoints/Mocking/MockContinuation.swift +++ b/Sources/Endpoints/Mocking/MockContinuation.swift @@ -65,6 +65,12 @@ public class MockContinuation where T.Response: Sendable { public func resume(throwing error: EndpointTaskError) where T.ErrorResponse: Sendable { action = .throw(error) } + + /// Resumes the mock with a pre-configured action. + /// - Parameter action: The mock action to perform (return, fail, throw, or none) + public func resume(with action: MockAction) { + self.action = action + } } #endif diff --git a/Sources/Endpoints/Mocking/MockRegistry.swift b/Sources/Endpoints/Mocking/MockRegistry.swift new file mode 100644 index 0000000..06d6ac1 --- /dev/null +++ b/Sources/Endpoints/Mocking/MockRegistry.swift @@ -0,0 +1,49 @@ +// +// MockRegistry.swift +// Endpoints +// + +#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) + +import Foundation + +/// Collects mocks for multiple endpoint types within a single `withMock(registering:test:)` scope. +/// +/// Register a mock per endpoint type — either a pre-configured ``MockAction`` or a +/// dynamic continuation-based closure. Endpoint types without a registered mock pass +/// through to the real transport. Registering the same endpoint type twice replaces +/// the earlier registration. +/// +/// ```swift +/// try await withMock { mocks in +/// mocks.register(RefreshEndpoint.self, action: .return(.init(access: "new", refresh: "next"))) +/// mocks.register(ProfileEndpoint.self, action: .return(.init(name: "Zac"))) +/// } test: { +/// let profile = try await session.response(with: ProfileEndpoint()) +/// } +/// ``` +public final class MockRegistry { + var wrappers: [ObjectIdentifier: ToReturnWrapper] = [:] + + init() {} + + /// Registers a pre-configured mock action for an endpoint type. + /// - Parameters: + /// - type: The endpoint type to mock + /// - action: The mock action to perform (return, fail, throw, or none) + public func register(_ type: T.Type, action: MockAction) { + register(type) { continuation in + continuation.resume(with: action) + } + } + + /// Registers a dynamic, continuation-based mock for an endpoint type. + /// - Parameters: + /// - type: The endpoint type to mock + /// - body: A closure that receives a ``MockContinuation`` to configure the mock response + public func register(_ type: T.Type, _ body: @Sendable @escaping (MockContinuation) async -> Void) { + wrappers[ObjectIdentifier(type)] = ToReturnWrapper(body) + } +} + +#endif diff --git a/Sources/Endpoints/Mocking/Mocking.swift b/Sources/Endpoints/Mocking/Mocking.swift index f27fc48..09f6925 100644 --- a/Sources/Endpoints/Mocking/Mocking.swift +++ b/Sources/Endpoints/Mocking/Mocking.swift @@ -29,12 +29,16 @@ struct ToReturnWrapper: Sendable { /// This type manages the mock state and coordinates between the `withMock` functions /// and the URLSession task interception. It uses TaskLocal storage to track active mocks /// and method swizzling to intercept data task resume calls. +/// +/// Mocks are keyed by endpoint type: a scope can mock any number of endpoint types, +/// nested scopes merge with (and shadow same-type mocks from) enclosing scopes, and +/// endpoint types without a registered mock pass through to the real transport. struct Mocking { static let shared = Mocking() @TaskLocal - static private var current: ToReturnWrapper? + static private var current: [ObjectIdentifier: ToReturnWrapper] = [:] init() { // Initialize URLSession swizzling on first use @@ -62,12 +66,33 @@ struct Mocking { } /// Sets up a mock context and executes the test block within it. + /// + /// The mock is merged with any mocks inherited from enclosing scopes; a mock for + /// the same endpoint type shadows the inherited one for the duration of the scope. /// - Parameters: /// - ofType: The endpoint type to mock /// - body: Closure that configures the mock response /// - test: The test code to execute with mocking enabled func withMock(_ ofType: T.Type, _ body: @Sendable @escaping (MockContinuation) async -> Void, test: @escaping () async throws -> R) async rethrows -> R { - try await Self.$current.withValue(ToReturnWrapper(body)) { + var mocks = Self.current + mocks[ObjectIdentifier(T.self)] = ToReturnWrapper(body) + return try await Self.$current.withValue(mocks) { + try await test() + } + } + + /// Sets up a mock context for multiple endpoint types and executes the test block within it. + /// + /// The registered mocks are merged with any mocks inherited from enclosing scopes; + /// a mock for the same endpoint type shadows the inherited one for the duration of the scope. + /// - Parameters: + /// - registering: Closure that registers mocks per endpoint type on the provided ``MockRegistry`` + /// - test: The test code to execute with mocking enabled + func withMock(registering: (MockRegistry) -> Void, test: @escaping () async throws -> R) async rethrows -> R { + let registry = MockRegistry() + registering(registry) + let mocks = Self.current.merging(registry.wrappers) { _, inner in inner } + return try await Self.$current.withValue(mocks) { try await test() } } @@ -76,19 +101,19 @@ struct Mocking { extension Mocking { /// Checks if a mock is currently active for the given endpoint type. func shouldHandleMock(for endpointsOfType: T.Type) -> Bool { - Self.current != nil + Self.current[ObjectIdentifier(T.self)] != nil } /// Retrieves the mock action for the specified endpoint type. /// - Parameter endpointsOfType: The endpoint type being requested - /// - Returns: The configured mock action, or nil if no mock is active + /// - Returns: The configured mock action, or nil if no mock is registered for this endpoint type func actionForMock(for endpointsOfType: T.Type) async -> MockAction? { - guard let current = Self.current else { + guard let wrapper = Self.current[ObjectIdentifier(T.self)] else { return nil } let continuation = MockContinuation() - await current.toReturn(for: T.self)(continuation) + await wrapper.toReturn(for: T.self)(continuation) return continuation.action } } diff --git a/Sources/EndpointsMocking/EndpointsMocking.swift b/Sources/EndpointsMocking/EndpointsMocking.swift index 09a8d8e..54691ea 100644 --- a/Sources/EndpointsMocking/EndpointsMocking.swift +++ b/Sources/EndpointsMocking/EndpointsMocking.swift @@ -51,15 +51,32 @@ public func withMock(_ ofType: T.Type, _ body: @Sendab /// - Returns: The value returned by the test block public func withMock(_ ofType: T.Type, action: MockAction, test: @Sendable @escaping () async throws -> R) async rethrows -> R { return try await Mocking.shared.withMock(T.self, { continuation in - switch action { - case .none: - return - case .fail(let errorResponse): - continuation.resume(failingWith: errorResponse) - case .return(let value): - continuation.resume(returning: value) - case .throw(let error): - continuation.resume(throwing: error) - } + continuation.resume(with: action) }, test: test) } + +/// Executes a test block with mocks registered for multiple endpoint types. +/// +/// Use this instead of nesting `withMock` calls when a flow touches several endpoints — +/// for example an authenticated request whose token-refresh handler calls a second +/// endpoint. Endpoint types without a registered mock pass through to the real +/// transport, and nested mock scopes merge with (and shadow same-type mocks from) +/// enclosing scopes. +/// +/// ```swift +/// try await withMock { mocks in +/// mocks.register(RefreshEndpoint.self, action: .return(.init(access: "new", refresh: "next"))) +/// mocks.register(ProfileEndpoint.self, action: .return(.init(name: "Zac"))) +/// } test: { +/// let profile = try await session.response(with: ProfileEndpoint()) +/// #expect(profile.name == "Zac") +/// } +/// ``` +/// +/// - Parameters: +/// - registering: A closure that registers mocks per endpoint type on the provided ``MockRegistry`` +/// - test: The test code that will execute with mocking enabled +/// - Returns: The value returned by the test block +public func withMock(registering: (MockRegistry) -> Void, test: @Sendable @escaping () async throws -> R) async rethrows -> R { + return try await Mocking.shared.withMock(registering: registering, test: test) +} diff --git a/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift b/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift index 655508b..03820d3 100644 --- a/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift +++ b/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift @@ -17,4 +17,42 @@ struct AuthenticatedSessionMockingTests { #expect(response.response1 == "mocked") } } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func multipleEndpointsThroughAuthenticatedSession() async throws { + let auth = JWTAuth(initialTokens: .init(accessToken: "access", refreshToken: "refresh")) { refreshToken in + JWTAuth.TokenPair(accessToken: "new", refreshToken: refreshToken) + } + let session = AuthenticatedSession(auth: auth) + + try await withMock { mocks in + mocks.register(MockSimpleEndpoint.self, action: .return(.init(response1: "profile"))) + mocks.register(MockSecondEndpoint.self, action: .return(.init(value: 99))) + } test: { + let simple = try await session.response(with: MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b"))) + #expect(simple.response1 == "profile") + + let second = try await session.response(with: MockSecondEndpoint()) + #expect(second.value == 99) + } + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func mockedAuthenticationErrorSurfacesTyped() async throws { + let session = AuthenticatedSession(auth: HeaderKeyAuth(key: "test")) + + try await withMock(MockSimpleEndpoint.self, action: .throw(.authenticationError(.notAuthenticated))) { + do throws(MockSimpleEndpoint.TaskError) { + _ = try await session.response(with: MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b"))) + Issue.record("Expected authenticationError to be thrown") + } catch { + guard case .authenticationError(.notAuthenticated) = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + } } diff --git a/Tests/EndpointsMockingTests/MockRegistryTests.swift b/Tests/EndpointsMockingTests/MockRegistryTests.swift new file mode 100644 index 0000000..77dd736 --- /dev/null +++ b/Tests/EndpointsMockingTests/MockRegistryTests.swift @@ -0,0 +1,173 @@ +// +// MockRegistryTests.swift +// Endpoints +// + +import Testing +import Endpoints +import Foundation +@testable import EndpointsMocking + +struct MockSecondEndpoint: Endpoint { + typealias Server = MockTestServer + + static let definition: Definition = Definition( + method: .get, + path: "second" + ) + + struct Response: Codable { + let value: Int + } + + struct ErrorResponse: Codable, Equatable { + let message: String + } +} + +struct MockThirdEndpoint: Endpoint { + typealias Server = MockTestServer + + static let definition: Definition = Definition( + method: .get, + path: "third" + ) + + struct Response: Codable { + let flag: Bool + } +} + +/// A server whose base URL refuses connections immediately, so pass-through +/// requests fail fast without touching the network. +struct UnroutableServer: ServerDefinition { + var baseUrls: [Environments: URL] { + return [.production: URL(string: "http://127.0.0.1:1")!] + } + + static var defaultEnvironment: Environments { .production } +} + +struct PassthroughEndpoint: Endpoint { + typealias Server = UnroutableServer + + static let definition: Definition = Definition( + method: .get, + path: "passthrough" + ) + + struct Response: Codable { + let value: String + } +} + +@Suite("Mock Registry") +struct MockRegistryTests { + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func routesEachEndpointToItsOwnMock() async throws { + try await withMock { mocks in + mocks.register(MockSimpleEndpoint.self, action: .return(.init(response1: "first"))) + mocks.register(MockSecondEndpoint.self, action: .fail(.init(message: "second failed"))) + mocks.register(MockThirdEndpoint.self, action: .throw(.internetConnectionOffline)) + } test: { + let simple = try await URLSession.shared.response(with: MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b"))) + #expect(simple.response1 == "first") + + do throws(MockSecondEndpoint.TaskError) { + _ = try await URLSession.shared.response(with: MockSecondEndpoint()) + Issue.record("Expected errorResponse to be thrown") + } catch { + guard case .errorResponse(_, let errorResponse) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(errorResponse.message == "second failed") + } + + do throws(MockThirdEndpoint.TaskError) { + _ = try await URLSession.shared.response(with: MockThirdEndpoint()) + Issue.record("Expected internetConnectionOffline to be thrown") + } catch { + guard case .internetConnectionOffline = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func nestedScopesCompose() async throws { + try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "outer"))) { + try await withMock(MockSecondEndpoint.self, action: .return(.init(value: 42))) { + // Both the outer and inner mocks are active. + let simple = try await URLSession.shared.response(with: MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b"))) + #expect(simple.response1 == "outer") + + let second = try await URLSession.shared.response(with: MockSecondEndpoint()) + #expect(second.value == 42) + } + } + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func innerScopeShadowsSameEndpoint() async throws { + try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "outer"))) { + try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "inner"))) { + let response = try await URLSession.shared.response(with: MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b"))) + #expect(response.response1 == "inner") + } + + // The outer mock is restored once the inner scope exits. + let response = try await URLSession.shared.response(with: MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b"))) + #expect(response.response1 == "outer") + } + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func unmockedEndpointPassesThrough() async throws { + try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "mocked"))) { + // An endpoint type with no registered mock goes to the real transport, + // which for UnroutableServer fails with a connection error. + do throws(PassthroughEndpoint.TaskError) { + _ = try await URLSession.shared.response(with: PassthroughEndpoint()) + Issue.record("Expected urlLoadError to be thrown") + } catch { + guard case .urlLoadError = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func registeringSameEndpointTwiceKeepsTheLatest() async throws { + try await withMock { mocks in + mocks.register(MockSimpleEndpoint.self, action: .return(.init(response1: "first"))) + mocks.register(MockSimpleEndpoint.self, action: .return(.init(response1: "second"))) + } test: { + let response = try await URLSession.shared.response(with: MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b"))) + #expect(response.response1 == "second") + } + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func continuationBasedRegistrationWorks() async throws { + try await withMock { mocks in + mocks.register(MockSecondEndpoint.self) { continuation in + continuation.resume(returning: .init(value: 7)) + } + } test: { + let response = try await URLSession.shared.response(with: MockSecondEndpoint()) + #expect(response.value == 7) + } + } +} From cec1f49d44e19797706b1b600ad543d637a558b0 Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 13:37:09 -0700 Subject: [PATCH 15/23] Add BasicAuth and AuthenticationError.custom - BasicAuth sends RFC 7617 Basic credentials, UTF-8 encoded - New AuthenticationError.custom(underlying:) case so custom AuthenticationMethod implementations can surface failures that aren't token-shaped (credential storage, signing) through the typed error without misusing refreshFailed; exposed via NSUnderlyingErrorKey - New conformance test in EndpointsMockingTests compiles a custom method against the public API only (no @testable), verifying external packages can implement their own authentication methods --- README.md | 3 ++ .../Authentication/AuthenticationError.swift | 8 ++- .../Implementations/BasicAuth.swift | 29 +++++++++++ .../AuthenticatedSessionMockingTests.swift | 51 +++++++++++++++++++ .../EndpointsTests/AuthenticationTests.swift | 23 +++++++++ 5 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 Sources/Endpoints/Authentication/Implementations/BasicAuth.swift diff --git a/README.md b/README.md index c93ddcc..3a655ba 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ let response = try await session.response(with: MyEndpoint()) Built-in authentication methods: - `HeaderKeyAuth` - A static key in a header, with an optional prefix. Defaults to `Authorization: Bearer `; use `HeaderKeyAuth(key: "secret", header: "X-API-Key", prefix: nil)` for custom API-key headers. +- `BasicAuth` - HTTP Basic credentials (RFC 7617), UTF-8 encoded. - `CookieAuth` - A static cookie, merged with any cookies already on the request. - `JWTAuth` - Access/refresh token pairs with automatic refresh (see below). - `NoAuth` - Passes requests through unchanged. Useful as a generic placeholder. @@ -150,6 +151,8 @@ struct SignatureAuth: AuthenticationMethod { } ``` +For failures that don't fit the built-in `AuthenticationError` cases (credential storage errors, signing failures), wrap them in `AuthenticationError.custom(underlying:)`. + ### Error handling All failures from `AuthenticatedSession` — including authentication failures — surface as the endpoint's typed `EndpointTaskError`, so a single `catch` covers everything: diff --git a/Sources/Endpoints/Authentication/AuthenticationError.swift b/Sources/Endpoints/Authentication/AuthenticationError.swift index cc9994b..397bedb 100644 --- a/Sources/Endpoints/Authentication/AuthenticationError.swift +++ b/Sources/Endpoints/Authentication/AuthenticationError.swift @@ -13,12 +13,18 @@ public enum AuthenticationError: Error, Sendable { /// The authentication method does not support refresh. case refreshNotSupported + + /// An implementation-specific authentication failure. + /// + /// Use this from custom ``AuthenticationMethod`` implementations for failures + /// that don't fit the other cases (e.g. credential storage or signing errors). + case custom(underlying: Error) } extension AuthenticationError: CustomNSError { public var errorUserInfo: [String: Any] { switch self { - case .refreshFailed(let underlying): + case .refreshFailed(let underlying), .custom(let underlying): return [NSUnderlyingErrorKey: underlying] default: return [:] diff --git a/Sources/Endpoints/Authentication/Implementations/BasicAuth.swift b/Sources/Endpoints/Authentication/Implementations/BasicAuth.swift new file mode 100644 index 0000000..3183f98 --- /dev/null +++ b/Sources/Endpoints/Authentication/Implementations/BasicAuth.swift @@ -0,0 +1,29 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Authentication using HTTP Basic credentials ([RFC 7617](https://www.rfc-editor.org/rfc/rfc7617)). +/// +/// Sends `Authorization: Basic ` with the credentials +/// encoded as UTF-8. +public struct BasicAuth: AuthenticationMethod { + /// The username. Must not contain a colon (RFC 7617, section 2). + public let username: String + + /// The password. + public let password: String + + public init(username: String, password: String) { + self.username = username + self.password = password + } + + public func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { + var mutableRequest = request + let credentials = Data("\(username):\(password)".utf8).base64EncodedString() + mutableRequest.setValue("Basic \(credentials)", forHTTPHeaderField: Header.authorization.name) + return mutableRequest + } +} diff --git a/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift b/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift index 03820d3..2b92fc1 100644 --- a/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift +++ b/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift @@ -38,6 +38,57 @@ struct AuthenticatedSessionMockingTests { } } + /// Conforms to AuthenticationMethod against the public API only (this target does + /// not use @testable import Endpoints), proving external packages can implement + /// custom authentication methods. + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func externalCustomAuthMethodConformance() async throws { + struct StampAuth: AuthenticationMethod { + let stamp: String + let failSigning: Bool + + struct SigningError: Error {} + + func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { + guard !failSigning else { + throw .custom(underlying: SigningError()) + } + var request = request + request.setValue(stamp, forHTTPHeaderField: "X-Stamp") + return request + } + } + + let authenticated = try await StampAuth(stamp: "stamped", failSigning: false) + .authenticate(request: URLRequest(url: URL(string: "https://example.com")!)) + #expect(authenticated.value(forHTTPHeaderField: "X-Stamp") == "stamped") + + // The default implementations come along for free. + do { + try await StampAuth(stamp: "stamped", failSigning: false) + .reauthenticate(after: authenticated) + Issue.record("Expected refreshNotSupported error") + } catch { + guard case .refreshNotSupported = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + + // Implementation-specific failures surface through the custom case. + do { + _ = try await StampAuth(stamp: "stamped", failSigning: true) + .authenticate(request: URLRequest(url: URL(string: "https://example.com")!)) + Issue.record("Expected custom error") + } catch { + guard case .custom(let underlying) = error, underlying is StampAuth.SigningError else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) @Test func mockedAuthenticationErrorSurfacesTyped() async throws { diff --git a/Tests/EndpointsTests/AuthenticationTests.swift b/Tests/EndpointsTests/AuthenticationTests.swift index 176df5c..c5cf457 100644 --- a/Tests/EndpointsTests/AuthenticationTests.swift +++ b/Tests/EndpointsTests/AuthenticationTests.swift @@ -127,6 +127,29 @@ struct AuthenticationTests { #expect(authenticated.value(forHTTPHeaderField: Header.cookie.name) == "theme=dark; session=new") } + @Test + func basicAuthEncodesCredentials() async throws { + let auth = BasicAuth(username: "user", password: "pass") + let request = URLRequest(url: URL(string: "https://example.com")!) + + let authenticated = try await auth.authenticate(request: request) + + #expect(authenticated.value(forHTTPHeaderField: Header.authorization.name) == "Basic dXNlcjpwYXNz") + } + + @Test + func basicAuthEncodesUTF8AndColonsInPassword() async throws { + let utf8Auth = BasicAuth(username: "müller", password: "pässwörd") + let request = URLRequest(url: URL(string: "https://example.com")!) + + let utf8Authenticated = try await utf8Auth.authenticate(request: request) + #expect(utf8Authenticated.value(forHTTPHeaderField: Header.authorization.name) == "Basic bcO8bGxlcjpww6Rzc3fDtnJk") + + let colonAuth = BasicAuth(username: "user", password: "pa:ss") + let colonAuthenticated = try await colonAuth.authenticate(request: request) + #expect(colonAuthenticated.value(forHTTPHeaderField: Header.authorization.name) == "Basic dXNlcjpwYTpzcw==") + } + @Test func noAuthPassesThroughAndDoesNotSupportRefresh() async throws { let auth = NoAuth() From 48b8f8192f115a497d87805f849753fa05ecefbe Mon Sep 17 00:00:00 2001 From: Zac White Date: Fri, 24 Jul 2026 13:44:51 -0700 Subject: [PATCH 16/23] Proactively refresh expiring tokens before sending requests TokenPair gains an optional expiresAt; when set and the token is within Configuration.expiryLeeway (default 30s) of expiring, authenticate refreshes before the request goes out instead of spending a round trip on a guaranteed rejection. Without expiresAt, behavior is unchanged (reactive refresh only). The refresh internals are unified into a single join-or-start helper whose existence check and task creation share one synchronous stretch of actor isolation, so concurrent near-expiry requests coalesce into one refresh. A failed proactive refresh falls back to sending the stale token, keeping the server as the source of truth. Also documents that RefreshHandler must not run through a session authenticated by the same JWTAuth (task deadlock: the session would await the refresh that awaits the handler). --- README.md | 4 + .../Implementations/JWTAuth.swift | 94 +++++++++++++++---- .../AuthenticatedSessionMockingTests.swift | 4 +- .../MockRegistryTests.swift | 4 +- .../AuthenticatedSessionTests.swift | 34 +++++++ .../EndpointsTests/AuthenticationTests.swift | 75 +++++++++++++++ 6 files changed, 191 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 3a655ba..81d1562 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,10 @@ Built-in authentication methods: `JWTAuth` holds an access/refresh token pair. When a request fails with a status code in `refreshTriggerStatusCodes` (401 by default), the session calls your `refreshHandler` and retries the request with the new tokens. Concurrent refreshes are coalesced into a single operation, and a request that fails with already-replaced tokens will not trigger a redundant refresh — important when your backend rotates single-use refresh tokens. +If you know when the access token expires, set `TokenPair.expiresAt`: tokens within `expiryLeeway` (30 seconds by default) of expiring are then refreshed *before* the request is sent, skipping the round trip that would have been rejected. Without `expiresAt`, refresh is purely reactive. + +> **Important:** the `refreshHandler` must not perform its request through the same `AuthenticatedSession` (or any session authenticated by the same `JWTAuth`) — the session would wait on the very refresh that is waiting on the handler. Use a plain `URLSession` for the refresh call; it authenticates with the refresh token, not the access token. + ```swift let auth = JWTAuth( initialTokens: loadTokensFromKeychain(), diff --git a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift index 9d821f9..11ed436 100644 --- a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift @@ -5,6 +5,18 @@ import FoundationNetworking #endif /// JWT-based authentication with automatic token refresh. +/// +/// Tokens are refreshed reactively when a response's status code is in +/// ``Configuration/refreshTriggerStatusCodes``, and proactively when +/// ``TokenPair/expiresAt`` is set and the token is within +/// ``Configuration/expiryLeeway`` of expiring — the refresh then happens +/// *before* the request is sent, avoiding a round trip that would be rejected. +/// +/// > Important: The ``RefreshHandler`` must not perform its request through the same +/// > ``AuthenticatedSession`` (or any session authenticated by this `JWTAuth`): the +/// > session would wait for the in-flight refresh that is itself waiting on the +/// > handler, deadlocking the task. Use a plain `URLSession` for the refresh call — +/// > it authenticates with the refresh token, not the access token. public actor JWTAuth: AuthenticationMethod { // MARK: - Types @@ -14,9 +26,24 @@ public actor JWTAuth: AuthenticationMethod { public let accessToken: String public let refreshToken: String - public init(accessToken: String, refreshToken: String) { + /// When the access token expires, if known. + /// + /// When set, requests authenticated within ``Configuration/expiryLeeway`` of + /// this date proactively refresh before being sent. When nil, tokens are only + /// refreshed reactively after a rejected response. + public let expiresAt: Date? + + public init(accessToken: String, refreshToken: String, expiresAt: Date? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken + self.expiresAt = expiresAt + } + + /// Whether the access token is expired or will expire within the given leeway. + /// Always false when ``expiresAt`` is nil. + public func isExpiring(within leeway: TimeInterval) -> Bool { + guard let expiresAt else { return false } + return expiresAt.timeIntervalSinceNow <= leeway } } @@ -31,14 +58,20 @@ public actor JWTAuth: AuthenticationMethod { /// HTTP status codes that should trigger a token refresh. Defaults to [401]. public let refreshTriggerStatusCodes: Set + /// How long before ``TokenPair/expiresAt`` a token is treated as expiring and + /// proactively refreshed. Defaults to 30 seconds. + public let expiryLeeway: TimeInterval + public init( header: Header = .authorization, tokenPrefix: String = "Bearer", - refreshTriggerStatusCodes: Set = [401] + refreshTriggerStatusCodes: Set = [401], + expiryLeeway: TimeInterval = 30 ) { self.header = header self.tokenPrefix = tokenPrefix self.refreshTriggerStatusCodes = refreshTriggerStatusCodes + self.expiryLeeway = expiryLeeway } public static let `default` = Configuration() @@ -47,6 +80,9 @@ public actor JWTAuth: AuthenticationMethod { /// Closure type for performing token refresh. /// /// The closure receives the current refresh token and should return new tokens. + /// + /// > Important: Perform the refresh request with a plain `URLSession`, never + /// > through a session authenticated by this `JWTAuth` — see ``JWTAuth``. public typealias RefreshHandler = @Sendable (String) async throws -> TokenPair /// Closure type for handling token updates (e.g., persisting to Keychain). @@ -86,13 +122,16 @@ public actor JWTAuth: AuthenticationMethod { // MARK: - AuthenticationMethod public func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { - if let pendingRefresh { + if let tokens = currentTokens, + pendingRefresh != nil || tokens.isExpiring(within: configuration.expiryLeeway) { do { - currentTokens = try await pendingRefresh.value + // Join an in-flight refresh, or proactively refresh an expiring token + // rather than sending a request that is likely to be rejected. + currentTokens = try await refresh(with: tokens.refreshToken) } catch { - // The in-flight refresh failed. Keep the existing (possibly expired) - // tokens and send the request anyway; if it is rejected, the failure - // surfaces through shouldReauthenticate/reauthenticate. + // The refresh failed. Keep the existing (possibly expired) tokens and + // send the request anyway; if it is rejected, the failure surfaces + // through shouldReauthenticate/reauthenticate. } } @@ -121,15 +160,37 @@ public actor JWTAuth: AuthenticationMethod { return } - if let existingRefresh = pendingRefresh { - currentTokens = try await awaitRefresh(existingRefresh) - return - } - guard let refreshToken = currentTokens?.refreshToken else { throw AuthenticationError.noRefreshToken } + currentTokens = try await refresh(with: refreshToken) + } + + // MARK: - Refresh + + /// Joins the in-flight refresh if one exists, otherwise starts a new one. + /// + /// The existence check and task creation happen in one synchronous stretch of + /// actor isolation, so concurrent callers cannot start duplicate refreshes. + private func refresh(with refreshToken: String) async throws(AuthenticationError) -> TokenPair { + let refreshTask: Task + if let pendingRefresh { + refreshTask = pendingRefresh + } else { + refreshTask = startRefresh(refreshToken: refreshToken) + } + + defer { + if pendingRefresh == refreshTask { + pendingRefresh = nil + } + } + + return try await awaitRefresh(refreshTask) + } + + private func startRefresh(refreshToken: String) -> Task { let refreshHandler = self.refreshHandler let onTokensUpdated = self.onTokensUpdated let onRefreshFailed = self.onRefreshFailed @@ -146,14 +207,7 @@ public actor JWTAuth: AuthenticationMethod { } pendingRefresh = refreshTask - - do { - currentTokens = try await awaitRefresh(refreshTask) - pendingRefresh = nil - } catch { - pendingRefresh = nil - throw error - } + return refreshTask } private nonisolated func headerValue(for accessToken: String) -> String { diff --git a/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift b/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift index 2b92fc1..22f0a74 100644 --- a/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift +++ b/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift @@ -91,10 +91,10 @@ struct AuthenticatedSessionMockingTests { @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) @Test - func mockedAuthenticationErrorSurfacesTyped() async throws { + func mockedAuthenticationErrorSurfacesTyped() async { let session = AuthenticatedSession(auth: HeaderKeyAuth(key: "test")) - try await withMock(MockSimpleEndpoint.self, action: .throw(.authenticationError(.notAuthenticated))) { + await withMock(MockSimpleEndpoint.self, action: .throw(.authenticationError(.notAuthenticated))) { do throws(MockSimpleEndpoint.TaskError) { _ = try await session.response(with: MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b"))) Issue.record("Expected authenticationError to be thrown") diff --git a/Tests/EndpointsMockingTests/MockRegistryTests.swift b/Tests/EndpointsMockingTests/MockRegistryTests.swift index 77dd736..09f763a 100644 --- a/Tests/EndpointsMockingTests/MockRegistryTests.swift +++ b/Tests/EndpointsMockingTests/MockRegistryTests.swift @@ -130,8 +130,8 @@ struct MockRegistryTests { @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) @Test - func unmockedEndpointPassesThrough() async throws { - try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "mocked"))) { + func unmockedEndpointPassesThrough() async { + await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "mocked"))) { // An endpoint type with no registered mock goes to the real transport, // which for UnroutableServer fails with a connection error. do throws(PassthroughEndpoint.TaskError) { diff --git a/Tests/EndpointsTests/AuthenticatedSessionTests.swift b/Tests/EndpointsTests/AuthenticatedSessionTests.swift index 822e63c..0365449 100644 --- a/Tests/EndpointsTests/AuthenticatedSessionTests.swift +++ b/Tests/EndpointsTests/AuthenticatedSessionTests.swift @@ -204,6 +204,40 @@ struct AuthenticatedSessionTests { #expect((await auth.tokens)?.accessToken == "old") } + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func jwtProactiveRefreshHappensBeforeRequest() async throws { + let successData = try JSONEncoder().encode(AuthTestEndpoint.Response(value: "ok")) + + let responses = ResponseQueue([ + (HTTPURLResponse(url: Self.url, statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + ]) + + let recorder = RequestRecorder() + TestURLProtocol.register(path: "/auth/test") { request in + recorder.record(request) + return try responses.next() + } + defer { TestURLProtocol.unregister(path: "/auth/test") } + + let auth = JWTAuth( + initialTokens: .init(accessToken: "expired-access", refreshToken: "old-refresh", expiresAt: Date(timeIntervalSinceNow: -60)), + refreshHandler: { refreshToken in + #expect(refreshToken == "old-refresh") + return JWTAuth.TokenPair(accessToken: "new-access", refreshToken: "new-refresh", expiresAt: Date(timeIntervalSinceNow: 3600)) + } + ) + let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: auth) + + let response = try await session.response(with: AuthTestEndpoint()) + + #expect(response.value == "ok") + + // The expired token never went over the wire: one request, already refreshed. + let sentAuthorization = recorder.all().map { $0.value(forHTTPHeaderField: Header.authorization.name) } + #expect(sentAuthorization == ["Bearer new-access"]) + } + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) @Test func staticAuthDoesNotRetryOn401() async throws { diff --git a/Tests/EndpointsTests/AuthenticationTests.swift b/Tests/EndpointsTests/AuthenticationTests.swift index c5cf457..9117813 100644 --- a/Tests/EndpointsTests/AuthenticationTests.swift +++ b/Tests/EndpointsTests/AuthenticationTests.swift @@ -241,6 +241,81 @@ struct AuthenticationTests { #expect((await auth.tokens)?.accessToken == "new") } + @Test + func jwtProactivelyRefreshesExpiringToken() async throws { + let counter = RefreshCounter() + let auth = JWTAuth( + initialTokens: .init(accessToken: "old", refreshToken: "refresh", expiresAt: Date(timeIntervalSinceNow: -60)), + refreshHandler: { refreshToken in + await counter.increment() + return JWTAuth.TokenPair(accessToken: "new", refreshToken: refreshToken, expiresAt: Date(timeIntervalSinceNow: 3600)) + } + ) + + let authenticated = try await auth.authenticate(request: URLRequest(url: URL(string: "https://example.com")!)) + + #expect(authenticated.value(forHTTPHeaderField: Header.authorization.name) == "Bearer new") + #expect(await counter.value() == 1) + } + + @Test + func jwtDoesNotRefreshFreshToken() async throws { + let counter = RefreshCounter() + let auth = JWTAuth( + initialTokens: .init(accessToken: "fresh", refreshToken: "refresh", expiresAt: Date(timeIntervalSinceNow: 3600)), + refreshHandler: { refreshToken in + await counter.increment() + return JWTAuth.TokenPair(accessToken: "new", refreshToken: refreshToken) + } + ) + + let authenticated = try await auth.authenticate(request: URLRequest(url: URL(string: "https://example.com")!)) + + #expect(authenticated.value(forHTTPHeaderField: Header.authorization.name) == "Bearer fresh") + #expect(await counter.value() == 0) + } + + @Test + func jwtProactiveRefreshFailureFallsBackToStaleToken() async throws { + struct ProactiveRefreshError: Error {} + let auth = JWTAuth( + initialTokens: .init(accessToken: "stale", refreshToken: "refresh", expiresAt: Date(timeIntervalSinceNow: -60)), + refreshHandler: { _ in throw ProactiveRefreshError() } + ) + + // The proactive refresh fails; the request still goes out with the stale + // token so the server stays the source of truth. + let authenticated = try await auth.authenticate(request: URLRequest(url: URL(string: "https://example.com")!)) + + #expect(authenticated.value(forHTTPHeaderField: Header.authorization.name) == "Bearer stale") + } + + @Test + func jwtProactiveRefreshCoalesces() async throws { + let counter = RefreshCounter() + let auth = JWTAuth( + initialTokens: .init(accessToken: "old", refreshToken: "refresh", expiresAt: Date(timeIntervalSinceNow: -60)), + refreshHandler: { refreshToken in + await counter.increment() + try await Task.sleep(nanoseconds: 50_000_000) + return JWTAuth.TokenPair(accessToken: "new", refreshToken: refreshToken, expiresAt: Date(timeIntervalSinceNow: 3600)) + } + ) + + let headers = await withTaskGroup(of: String?.self) { group in + for _ in 0..<5 { + group.addTask { + let request = try? await auth.authenticate(request: URLRequest(url: URL(string: "https://example.com")!)) + return request?.value(forHTTPHeaderField: Header.authorization.name) + } + } + return await group.reduce(into: [String?]()) { $0.append($1) } + } + + #expect(await counter.value() == 1) + #expect(headers.allSatisfy { $0 == "Bearer new" }) + } + @Test func authenticationErrorExposesUnderlyingError() { let underlying = URLError(.timedOut) From f133b794fb38f4ebd700318bd1610fdcf3aab4fd Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 27 Jul 2026 10:50:49 -0700 Subject: [PATCH 17/23] Declare authentication per endpoint instead of via a session wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authentication is now declared on the endpoint the same way decoders are: ServerDefinition gains an Auth associatedtype (defaulted to NoAuth) plus a shared static instance, and Endpoint.Auth defaults to Server.Auth so endpoints inherit their server's method and can override it — with NoAuth on a login or refresh endpoint, or with a different method entirely. This replaces AuthenticatedSession, which is deleted. The retry and reauthentication loop moves into URLSession.response(with:), so there is one request API again rather than a parallel session type; with NoAuth the path is behaviorally identical to the previous unauthenticated one. Declaring the method as a static let means every endpoint on a server shares one instance, which is what lets JWTAuth coalesce refreshes across endpoints. Because the Combine and closure-based APIs cannot await an asynchronous authenticate, they are now constrained where T.Auth == NoAuth: calling them with an authenticated endpoint is a compile error instead of a request that silently skips its credentials. maxRetryAttempts moves onto AuthenticationMethod (defaulted to 1), since the retry bound is a property of the strategy rather than of a session. EndpointTaskError gains a public httpResponse accessor, previously a private helper on the session type. --- README.md | 96 +++- .../Authentication/AuthenticatedSession.swift | 139 ----- .../Authentication/AuthenticationMethod.swift | 9 + .../Implementations/JWTAuth.swift | 10 +- Sources/Endpoints/Endpoint.swift | 28 + Sources/Endpoints/EnvironmentType.swift | 26 +- .../Extensions/URLSession+Async.swift | 75 ++- .../Extensions/URLSession+Combine.swift | 6 +- .../Extensions/URLSession+Endpoints.swift | 28 +- ...> AuthenticatedEndpointMockingTests.swift} | 63 ++- .../AuthenticatedEndpointTests.swift | 511 ++++++++++++++++++ .../AuthenticatedSessionTests.swift | 423 --------------- 12 files changed, 777 insertions(+), 637 deletions(-) delete mode 100644 Sources/Endpoints/Authentication/AuthenticatedSession.swift rename Tests/EndpointsMockingTests/{AuthenticatedSessionMockingTests.swift => AuthenticatedEndpointMockingTests.swift} (62%) create mode 100644 Tests/EndpointsTests/AuthenticatedEndpointTests.swift delete mode 100644 Tests/EndpointsTests/AuthenticatedSessionTests.swift diff --git a/README.md b/README.md index 81d1562..18b67a5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The purpose of Endpoints is to, in a type-safe way, define how to create a `URLR - **Type-safe endpoint definitions** - Define endpoints with compile-time checking of paths, parameters, and headers - **Server definition with multiple environments** - Support for local, development, staging, and production environments with easy switching -- **Authentication with automatic token refresh** - Attach credentials to requests and transparently refresh and retry on expiry via `AuthenticatedSession` +- **Authentication with automatic token refresh** - Declare an authentication method per server or per endpoint; credentials are applied, refreshed, and retried transparently - **Built-in mocking support** - Comprehensive testing utilities through the `EndpointsMocking` module - **Swift 6.0 compatible** - Built with modern Swift concurrency, Sendable support and typed throws - **Combine and async/await support** - Use either reactive or async patterns @@ -95,13 +95,52 @@ do { ## Authentication -`AuthenticatedSession` wraps a `URLSession` and applies an `AuthenticationMethod` to every request. It mirrors the async `URLSession.response(with:)` API (authentication is async/await-only; the Combine and closure-based APIs do not support it): +Authentication is declared on the endpoint, the same way decoders are. A server names the `AuthenticationMethod` its endpoints use, and every endpoint inherits it: ```swift -let session = AuthenticatedSession(auth: HeaderKeyAuth(key: "my-api-key")) -let response = try await session.response(with: MyEndpoint()) +struct ApiServer: ServerDefinition { + static let auth = HeaderKeyAuth(key: "my-api-key") + + var baseUrls: [Environments: URL] { ... } + static var defaultEnvironment: Environments { .production } +} + +struct ProfileEndpoint: Endpoint { + typealias Server = ApiServer // authenticated, nothing else to declare + + static let definition: Definition = Definition(method: .get, path: "profile") + struct Response: Decodable { let name: String } +} +``` + +Requests then go through the ordinary `URLSession` API — there is no separate session type, and credentials are applied automatically: + +```swift +let response = try await URLSession.shared.response(with: ProfileEndpoint()) +``` + +An individual endpoint can override its server's method — to opt out on a login or refresh endpoint, or to use a different scheme entirely: + +```swift +struct LoginEndpoint: Endpoint { + typealias Server = ApiServer + static var auth: NoAuth { NoAuth() } // unauthenticated + ... +} + +struct MetricsEndpoint: Endpoint { + typealias Server = ApiServer + static let auth = HeaderKeyAuth(key: clientKey, header: "X-Client-Key", prefix: nil) + ... +} ``` +Declare the method as a `static let` so all endpoints on a server share one instance. That shared instance is what allows a stateful method like `JWTAuth` to coalesce concurrent token refreshes across every endpoint. + +Servers that declare no `auth` use `NoAuth`, so existing endpoints keep working unchanged. + +Authentication requires the async/await API. Because the Combine and closure-based APIs cannot await an asynchronous `authenticate`, they are constrained to unauthenticated endpoints — calling `endpointPublisher` or `endpointTask` with an authenticated endpoint is a compile error rather than a request that silently skips its credentials. + Built-in authentication methods: - `HeaderKeyAuth` - A static key in a header, with an optional prefix. Defaults to `Authorization: Bearer `; use `HeaderKeyAuth(key: "secret", header: "X-API-Key", prefix: nil)` for custom API-key headers. @@ -112,32 +151,35 @@ Built-in authentication methods: ### Token refresh with JWTAuth -`JWTAuth` holds an access/refresh token pair. When a request fails with a status code in `refreshTriggerStatusCodes` (401 by default), the session calls your `refreshHandler` and retries the request with the new tokens. Concurrent refreshes are coalesced into a single operation, and a request that fails with already-replaced tokens will not trigger a redundant refresh — important when your backend rotates single-use refresh tokens. +`JWTAuth` holds an access/refresh token pair. When a request fails with a status code in `refreshTriggerStatusCodes` (401 by default), your `refreshHandler` is called and the request is retried with the new tokens. Concurrent refreshes are coalesced into a single operation, and a request that fails with already-replaced tokens will not trigger a redundant refresh — important when your backend rotates single-use refresh tokens. If you know when the access token expires, set `TokenPair.expiresAt`: tokens within `expiryLeeway` (30 seconds by default) of expiring are then refreshed *before* the request is sent, skipping the round trip that would have been rejected. Without `expiresAt`, refresh is purely reactive. -> **Important:** the `refreshHandler` must not perform its request through the same `AuthenticatedSession` (or any session authenticated by the same `JWTAuth`) — the session would wait on the very refresh that is waiting on the handler. Use a plain `URLSession` for the refresh call; it authenticates with the refresh token, not the access token. - ```swift -let auth = JWTAuth( - initialTokens: loadTokensFromKeychain(), - refreshHandler: { refreshToken in - // Exchange the refresh token for new tokens against your backend. - let response = try await URLSession.shared.response(with: RefreshEndpoint(token: refreshToken)) - return JWTAuth.TokenPair(accessToken: response.access, refreshToken: response.refresh) - }, - onTokensUpdated: { tokens in - saveTokensToKeychain(tokens) - }, - onRefreshFailed: { error in - await logOut() - } -) +struct ApiServer: ServerDefinition { + static let auth = JWTAuth( + initialTokens: loadTokensFromKeychain(), + refreshHandler: { refreshToken in + // Exchange the refresh token for new tokens against your backend. + let response = try await URLSession.shared.response(with: RefreshEndpoint(token: refreshToken)) + return JWTAuth.TokenPair(accessToken: response.access, refreshToken: response.refresh) + }, + onTokensUpdated: { tokens in + saveTokensToKeychain(tokens) + }, + onRefreshFailed: { error in + await logOut() + } + ) -let session = AuthenticatedSession(auth: auth) + var baseUrls: [Environments: URL] { ... } + static var defaultEnvironment: Environments { .production } +} ``` -After a login or logout, update the tokens with `await auth.setTokens(_:)` or `await auth.clearTokens()`. +> **Important:** the refresh endpoint must not be authenticated by the same `JWTAuth` — the request would wait on the very refresh that is waiting on it, deadlocking the task. Give it `static var auth: NoAuth { NoAuth() }`; it authenticates with the refresh token, not the access token. + +After a login or logout, update the tokens with `await ApiServer.auth.setTokens(_:)` or `await ApiServer.auth.clearTokens()`. ### Custom authentication methods @@ -159,11 +201,11 @@ For failures that don't fit the built-in `AuthenticationError` cases (credential ### Error handling -All failures from `AuthenticatedSession` — including authentication failures — surface as the endpoint's typed `EndpointTaskError`, so a single `catch` covers everything: +All failures — including authentication failures — surface as the endpoint's typed `EndpointTaskError`, so a single `catch` covers everything: ```swift do { - let response = try await session.response(with: MyEndpoint()) + let response = try await URLSession.shared.response(with: MyEndpoint()) } catch { // error is MyEndpoint.TaskError — no casting needed switch error { @@ -213,9 +255,9 @@ The mocking system supports: - Throwing network errors - Dynamic response generation - Combine publisher mocking -- Both `URLSession` extensions and `AuthenticatedSession` +- Authenticated and unauthenticated endpoints alike -Note that mocks bypass authentication entirely: a mocked request never invokes the `AuthenticationMethod`, and mock errors do not trigger the refresh/retry loop. To simulate an authentication failure, throw one directly with `.throw(.authenticationError(.notAuthenticated))`; to test the refresh flow itself, use a `URLProtocol`-based fake transport. +Note that mocks bypass authentication entirely: a mocked request never invokes the endpoint's `AuthenticationMethod`, and mock errors do not trigger the refresh/retry loop. To simulate an authentication failure, throw one directly with `.throw(.authenticationError(.notAuthenticated))`; to test the refresh flow itself, use a `URLProtocol`-based fake transport. To find out more about the pieces of the `Endpoint`, check out [Defining a ResponseType](https://github.com/velos/Endpoints/wiki/DefiningResponseType) on the wiki. diff --git a/Sources/Endpoints/Authentication/AuthenticatedSession.swift b/Sources/Endpoints/Authentication/AuthenticatedSession.swift deleted file mode 100644 index 365a6ef..0000000 --- a/Sources/Endpoints/Authentication/AuthenticatedSession.swift +++ /dev/null @@ -1,139 +0,0 @@ -import Foundation - -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -/// A session wrapper that applies authentication to requests and handles token refresh. -/// -/// All errors — including authentication failures — surface as the endpoint's ``EndpointTaskError``. -/// Authentication-specific failures are wrapped in ``EndpointTaskError/authenticationError(_:)``. -public struct AuthenticatedSession: Sendable { - /// The underlying URLSession for network requests. - public let session: URLSession - - /// The authentication method to use. - public let auth: Auth - - /// Maximum number of retry attempts after reauthentication. - /// Negative values are treated as 0. Defaults to 1. - public let maxRetryAttempts: Int - - public init( - session: URLSession = .shared, - auth: Auth, - maxRetryAttempts: Int = 1 - ) { - self.session = session - self.auth = auth - self.maxRetryAttempts = max(0, maxRetryAttempts) - } -} - -@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) -public extension AuthenticatedSession { - - /// Performs an authenticated request expecting a Decodable response. - func response(with endpoint: T) async throws(T.TaskError) -> T.Response - where T.Response: Decodable { - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - if let mockResponse = try await Mocking.shared.handleMock(for: T.self) { - return mockResponse - } - #endif - - return try await performRequest(with: endpoint) { (data) throws(T.TaskError) in - do { - return try T.responseDecoder.decode(T.Response.self, from: data) - } catch { - throw T.TaskError.responseParseError(data: data, error: error) - } - } - } - - /// Performs an authenticated request expecting a Void response. - func response(with endpoint: T) async throws(T.TaskError) - where T.Response == Void { - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - if let _: T.Response = try await Mocking.shared.handleMock(for: T.self) { - return - } - #endif - - _ = try await performRequest(with: endpoint) { (_) throws(T.TaskError) in () } - } - - /// Performs an authenticated request expecting raw Data. - func response(with endpoint: T) async throws(T.TaskError) -> T.Response - where T.Response == Data { - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - if let mockResponse = try await Mocking.shared.handleMock(for: T.self) { - return mockResponse - } - #endif - - return try await performRequest(with: endpoint) { (data) throws(T.TaskError) in data } - } - - private func performRequest( - with endpoint: T, - transform: (Data) throws(T.TaskError) -> R - ) async throws(T.TaskError) -> R { - var attempt = 0 - while true { - let request = try createUrlRequest(for: endpoint) - - let authenticatedRequest: URLRequest - do { - authenticatedRequest = try await auth.authenticate(request: request) - } catch { - throw T.TaskError.authenticationError(error) - } - - do { - let result = try await session.loadData(for: authenticatedRequest, endpoint: T.self) - let data = try T.definition.response( - data: result.data, - response: result.response, - error: nil - ).get() - - return try transform(data) - } catch { - guard attempt < maxRetryAttempts, - auth.shouldReauthenticate(for: error, response: extractHTTPResponse(from: error)) else { - throw error - } - - do { - try await auth.reauthenticate(after: authenticatedRequest) - } catch { - throw T.TaskError.authenticationError(error) - } - - attempt += 1 - } - } - } - - private func createUrlRequest(for endpoint: T) throws(T.TaskError) -> URLRequest { - do { - return try endpoint.urlRequest() - } catch { - throw T.TaskError.endpointError(error) - } - } - - private func extractHTTPResponse(from error: EndpointTaskError) -> HTTPURLResponse? { - switch error { - case .errorResponse(let httpResponse, _): - return httpResponse - case .unexpectedResponse(let httpResponse): - return httpResponse - case .errorResponseParseError(let httpResponse, _, _): - return httpResponse - default: - return nil - } - } -} diff --git a/Sources/Endpoints/Authentication/AuthenticationMethod.swift b/Sources/Endpoints/Authentication/AuthenticationMethod.swift index dce2114..043f3c1 100644 --- a/Sources/Endpoints/Authentication/AuthenticationMethod.swift +++ b/Sources/Endpoints/Authentication/AuthenticationMethod.swift @@ -32,6 +32,12 @@ public protocol AuthenticationMethod: Sendable { /// /// Implementations should coalesce concurrent calls into a single refresh operation. func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) + + /// How many times a request may be retried after reauthenticating. Defaults to 1. + /// + /// Bounds the retry loop so that a server which keeps rejecting credentials cannot + /// cause an infinite request/refresh cycle. Negative values are treated as 0. + var maxRetryAttempts: Int { get } } public extension AuthenticationMethod { @@ -47,4 +53,7 @@ public extension AuthenticationMethod { func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { throw AuthenticationError.refreshNotSupported } + + /// By default, a request is retried once after reauthenticating. + var maxRetryAttempts: Int { 1 } } diff --git a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift index 11ed436..0ec7d1b 100644 --- a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift @@ -12,11 +12,11 @@ import FoundationNetworking /// ``Configuration/expiryLeeway`` of expiring — the refresh then happens /// *before* the request is sent, avoiding a round trip that would be rejected. /// -/// > Important: The ``RefreshHandler`` must not perform its request through the same -/// > ``AuthenticatedSession`` (or any session authenticated by this `JWTAuth`): the -/// > session would wait for the in-flight refresh that is itself waiting on the -/// > handler, deadlocking the task. Use a plain `URLSession` for the refresh call — -/// > it authenticates with the refresh token, not the access token. +/// > Important: The ``RefreshHandler`` must not perform its request with an endpoint +/// > authenticated by this same `JWTAuth`: the request would wait for the in-flight +/// > refresh that is itself waiting on the handler, deadlocking the task. Give the +/// > refresh endpoint `static var auth: NoAuth { NoAuth() }` — it authenticates with +/// > the refresh token, not the access token. public actor JWTAuth: AuthenticationMethod { // MARK: - Types diff --git a/Sources/Endpoints/Endpoint.swift b/Sources/Endpoints/Endpoint.swift index 89616e7..360b9e3 100644 --- a/Sources/Endpoints/Endpoint.swift +++ b/Sources/Endpoints/Endpoint.swift @@ -127,6 +127,20 @@ public protocol Endpoint: Sendable { /// The ``DecoderType`` to use when decoding the response. Defaults to `JSONDecoder`. associatedtype ResponseDecoder: DecoderType = JSONDecoder + /// The ``AuthenticationMethod`` used to authenticate requests for this endpoint. + /// + /// Defaults to the ``Server``'s authentication method, which itself defaults to ``NoAuth``. + /// Override on an individual endpoint to opt out of the server's authentication (for + /// example on a login endpoint) or to use a different method entirely: + /// + /// ```swift + /// struct LoginEndpoint: Endpoint { + /// typealias Server = ApiServer + /// static var auth: NoAuth { NoAuth() } + /// } + /// ``` + associatedtype Auth: AuthenticationMethod = Server.Auth + /// A ``Definition`` which pieces together all the components defined in the endpoint. static var definition: Definition { get } @@ -152,6 +166,13 @@ public protocol Endpoint: Sendable { /// The decoder instance to use when decoding the associated ``Endpoint/Response`` type static var responseDecoder: ResponseDecoder { get } + /// The authentication method instance used to authenticate requests for this endpoint. + /// + /// Defaults to ``ServerDefinition/auth`` so that all endpoints on a server share a + /// single instance — important for stateful methods like ``JWTAuth``, where sharing + /// is what lets concurrent refreshes coalesce across endpoints. + static var auth: Auth { get } + /// A strategy for encoding query parameters. Defaults to `QueryEncodingStrategy.default` static var queryEncodingStrategy: QueryEncodingStrategy { get } } @@ -189,6 +210,13 @@ public extension Endpoint where BodyEncoder == JSONEncoder { } } +public extension Endpoint where Auth == Server.Auth { + /// Endpoints inherit their server's authentication method instance by default. + static var auth: Auth { + return Server.auth + } +} + public extension Endpoint { static var queryEncodingStrategy: QueryEncodingStrategy { return .default diff --git a/Sources/Endpoints/EnvironmentType.swift b/Sources/Endpoints/EnvironmentType.swift index 9dbf74e..73bc6d1 100644 --- a/Sources/Endpoints/EnvironmentType.swift +++ b/Sources/Endpoints/EnvironmentType.swift @@ -46,6 +46,9 @@ public protocol ServerDefinition: Sendable { /// read and switched from any thread via ``environment``. associatedtype Environments: Hashable & Sendable = TypicalEnvironments + /// The ``AuthenticationMethod`` applied to endpoints on this server. Defaults to ``NoAuth``. + associatedtype Auth: AuthenticationMethod = NoAuth + /// Required initializer for creating server instances. init() @@ -53,9 +56,25 @@ public protocol ServerDefinition: Sendable { var baseUrls: [Environments: URL] { get } /// Optional request processor to modify requests before sending. - /// Use this to add authentication headers or signatures. + /// + /// Use this for static, synchronous request modification such as signing. For + /// credentials that can expire and be refreshed, use ``auth`` instead. var requestProcessor: @Sendable (URLRequest) -> URLRequest { get } + /// The authentication method instance shared by all endpoints on this server. + /// + /// Declare this as a `static let` so that stateful methods such as ``JWTAuth`` + /// share one instance across every endpoint — that shared instance is what allows + /// concurrent token refreshes to coalesce. + /// + /// ```swift + /// struct ApiServer: ServerDefinition { + /// static let auth = JWTAuth(initialTokens: loadTokens(), refreshHandler: refresh) + /// ... + /// } + /// ``` + static var auth: Auth { get } + /// The default environment to use when none is explicitly set. static var defaultEnvironment: Environments { get } } @@ -65,6 +84,11 @@ public extension ServerDefinition { var requestProcessor: @Sendable (URLRequest) -> URLRequest { return { $0 } } } +public extension ServerDefinition where Auth == NoAuth { + /// Servers are unauthenticated unless they declare an authentication method. + static var auth: NoAuth { return NoAuth() } +} + struct ApiServer: ServerDefinition { var baseUrls: [Environments: URL] { return [ diff --git a/Sources/Endpoints/Extensions/URLSession+Async.swift b/Sources/Endpoints/Extensions/URLSession+Async.swift index 4ec64ff..ed1d163 100644 --- a/Sources/Endpoints/Extensions/URLSession+Async.swift +++ b/Sources/Endpoints/Extensions/URLSession+Async.swift @@ -22,47 +22,88 @@ public extension URLSession { /// - environment: The environment in which to make the request /// - endpoint: The endpoint instance to be used to make the request func response(with endpoint: T) async throws(T.TaskError) where T.Response == Void { - let urlRequest = try createUrlRequest(for: endpoint) - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - if let mockResponse = try await Mocking.shared.handleMock(for: T.self) { + if let mockResponse: T.Response = try await Mocking.shared.handleMock(for: T.self) { return mockResponse } #endif - let result = try await loadData(for: urlRequest, endpoint: T.self) - _ = try T.definition.response(data: result.data, response: result.response, error: nil).get() + _ = try await performRequest(with: endpoint) { (_) throws(T.TaskError) in () } } func response(with endpoint: T) async throws(T.TaskError) -> T.Response where T.Response == Data { - let urlRequest = try createUrlRequest(for: endpoint) - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) if let mockResponse = try await Mocking.shared.handleMock(for: T.self) { return mockResponse } #endif - let result = try await loadData(for: urlRequest, endpoint: T.self) - return try T.definition.response(data: result.data, response: result.response, error: nil).get() + return try await performRequest(with: endpoint) { (data) throws(T.TaskError) in data } } func response(with endpoint: T) async throws(T.TaskError) -> T.Response where T.Response: Decodable { - let urlRequest = try createUrlRequest(for: endpoint) - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) if let mockResponse = try await Mocking.shared.handleMock(for: T.self) { return mockResponse } #endif - let result = try await loadData(for: urlRequest, endpoint: T.self) - let data = try T.definition.response(data: result.data, response: result.response, error: nil).get() + return try await performRequest(with: endpoint) { (data) throws(T.TaskError) in + do { + return try T.responseDecoder.decode(T.Response.self, from: data) + } catch { + throw T.TaskError.responseParseError(data: data, error: error) + } + } + } - do { - return try T.responseDecoder.decode(T.Response.self, from: data) - } catch { - throw T.TaskError.responseParseError(data: data, error: error) + /// Authenticates and performs the request, retrying after reauthentication when the + /// endpoint's ``Endpoint/Auth`` asks for it. + /// + /// With the default ``NoAuth``, `authenticate` returns the request unchanged and + /// `shouldReauthenticate` is always false, so this is a single pass through the + /// unauthenticated request path. + private func performRequest( + with endpoint: T, + transform: (Data) throws(T.TaskError) -> R + ) async throws(T.TaskError) -> R { + let auth = T.auth + let maxRetryAttempts = max(0, auth.maxRetryAttempts) + + var attempt = 0 + while true { + let request = try createUrlRequest(for: endpoint) + + let authenticatedRequest: URLRequest + do { + authenticatedRequest = try await auth.authenticate(request: request) + } catch { + throw T.TaskError.authenticationError(error) + } + + do { + let result = try await loadData(for: authenticatedRequest, endpoint: T.self) + let data = try T.definition.response( + data: result.data, + response: result.response, + error: nil + ).get() + + return try transform(data) + } catch { + guard attempt < maxRetryAttempts, + auth.shouldReauthenticate(for: error, response: error.httpResponse) else { + throw error + } + + do { + try await auth.reauthenticate(after: authenticatedRequest) + } catch { + throw T.TaskError.authenticationError(error) + } + + attempt += 1 + } } } diff --git a/Sources/Endpoints/Extensions/URLSession+Combine.swift b/Sources/Endpoints/Extensions/URLSession+Combine.swift index 4d33a4c..19376f9 100644 --- a/Sources/Endpoints/Extensions/URLSession+Combine.swift +++ b/Sources/Endpoints/Extensions/URLSession+Combine.swift @@ -19,7 +19,7 @@ public extension URLSession { /// - environment: The environment with which to make the request /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` - func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Void { + func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Void, T.Auth == NoAuth { let urlRequest: URLRequest do { urlRequest = try createUrlRequest(for: endpoint) @@ -68,7 +68,7 @@ public extension URLSession { /// - environment: The environment with which to make the request /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` - func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Data { + func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Data, T.Auth == NoAuth { let urlRequest: URLRequest do { @@ -118,7 +118,7 @@ public extension URLSession { /// - environment: The environment with which to make the request /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` - func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response: Decodable { + func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response: Decodable, T.Auth == NoAuth { let urlRequest: URLRequest do { diff --git a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift index 8f22361..6faea54 100644 --- a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift +++ b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift @@ -25,11 +25,29 @@ public enum EndpointTaskError: Error, Sendable { case urlLoadError(Error) case internetConnectionOffline - /// An authentication operation failed while performing the request - /// through an ``AuthenticatedSession``. + /// An authentication operation failed while applying or refreshing the + /// endpoint's ``Endpoint/Auth`` credentials. case authenticationError(AuthenticationError) } +public extension EndpointTaskError { + /// The HTTP response associated with this error, when the failure came from a + /// response the server actually returned. + /// + /// `nil` for failures that happened before or instead of a response, such as + /// request construction, transport errors, and being offline. + var httpResponse: HTTPURLResponse? { + switch self { + case .errorResponse(let httpResponse, _), + .unexpectedResponse(let httpResponse), + .errorResponseParseError(let httpResponse, _, _): + return httpResponse + case .endpointError, .responseParseError, .urlLoadError, .internetConnectionOffline, .authenticationError: + return nil + } + } +} + public extension Endpoint { /// Shorthand for an ``EndpointTaskError`` with the request's generic ``Endpoint/ErrorResponse`` typealias TaskError = EndpointTaskError @@ -46,7 +64,7 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws(T.TaskError) -> URLSessionDataTask where T.Response == Void { + func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws(T.TaskError) -> URLSessionDataTask where T.Response == Void, T.Auth == NoAuth { let urlRequest = try createUrlRequest(for: endpoint) @@ -86,7 +104,7 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws(T.TaskError) -> URLSessionDataTask where T.Response == Data { + func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws(T.TaskError) -> URLSessionDataTask where T.Response == Data, T.Auth == NoAuth { let urlRequest = try createUrlRequest(for: endpoint) @@ -124,7 +142,7 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws(T.TaskError) -> URLSessionDataTask where T.Response: Decodable { + func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws(T.TaskError) -> URLSessionDataTask where T.Response: Decodable, T.Auth == NoAuth { let urlRequest = try createUrlRequest(for: endpoint) diff --git a/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift b/Tests/EndpointsMockingTests/AuthenticatedEndpointMockingTests.swift similarity index 62% rename from Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift rename to Tests/EndpointsMockingTests/AuthenticatedEndpointMockingTests.swift index 22f0a74..1d60b2e 100644 --- a/Tests/EndpointsMockingTests/AuthenticatedSessionMockingTests.swift +++ b/Tests/EndpointsMockingTests/AuthenticatedEndpointMockingTests.swift @@ -3,38 +3,69 @@ import Foundation import Endpoints @testable import EndpointsMocking -@Suite("Authenticated Session Mocking") -struct AuthenticatedSessionMockingTests { +/// A server whose endpoints are authenticated, to confirm mocking short-circuits +/// before authentication runs. +struct MockAuthenticatedServer: ServerDefinition { + static let auth = JWTAuth(initialTokens: nil) { refreshToken in + JWTAuth.TokenPair(accessToken: "new", refreshToken: refreshToken) + } + + var baseUrls: [Environments: URL] { + [.production: URL(string: "https://api.velosmobile.com")!] + } + + static var defaultEnvironment: Environments { .production } +} + +struct MockAuthenticatedEndpoint: Endpoint { + typealias Server = MockAuthenticatedServer + + static let definition: Definition = Definition( + method: .get, + path: "authenticated" + ) + + struct Response: Codable { + let value: String + } +} + +@Suite("Authenticated Endpoint Mocking") +struct AuthenticatedEndpointMockingTests { @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) @Test func mockedResponseWorks() async throws { - let auth = HeaderKeyAuth(key: "test") - let session = AuthenticatedSession(auth: auth) - try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "mocked"))) { let endpoint = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) - let response = try await session.response(with: endpoint) + let response = try await URLSession.shared.response(with: endpoint) #expect(response.response1 == "mocked") } } + /// The endpoint's auth has no tokens, so a real request would throw + /// `.authenticationError(.notAuthenticated)`. Getting a mocked value back proves + /// mocking short-circuits ahead of authentication. @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) @Test - func multipleEndpointsThroughAuthenticatedSession() async throws { - let auth = JWTAuth(initialTokens: .init(accessToken: "access", refreshToken: "refresh")) { refreshToken in - JWTAuth.TokenPair(accessToken: "new", refreshToken: refreshToken) + func mockingBypassesAuthentication() async throws { + try await withMock(MockAuthenticatedEndpoint.self, action: .return(.init(value: "mocked"))) { + let response = try await URLSession.shared.response(with: MockAuthenticatedEndpoint()) + #expect(response.value == "mocked") } - let session = AuthenticatedSession(auth: auth) + } + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func multipleEndpointsIncludingAuthenticatedOnes() async throws { try await withMock { mocks in mocks.register(MockSimpleEndpoint.self, action: .return(.init(response1: "profile"))) - mocks.register(MockSecondEndpoint.self, action: .return(.init(value: 99))) + mocks.register(MockAuthenticatedEndpoint.self, action: .return(.init(value: "authed"))) } test: { - let simple = try await session.response(with: MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b"))) + let simple = try await URLSession.shared.response(with: MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b"))) #expect(simple.response1 == "profile") - let second = try await session.response(with: MockSecondEndpoint()) - #expect(second.value == 99) + let authed = try await URLSession.shared.response(with: MockAuthenticatedEndpoint()) + #expect(authed.value == "authed") } } @@ -92,11 +123,9 @@ struct AuthenticatedSessionMockingTests { @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) @Test func mockedAuthenticationErrorSurfacesTyped() async { - let session = AuthenticatedSession(auth: HeaderKeyAuth(key: "test")) - await withMock(MockSimpleEndpoint.self, action: .throw(.authenticationError(.notAuthenticated))) { do throws(MockSimpleEndpoint.TaskError) { - _ = try await session.response(with: MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b"))) + _ = try await URLSession.shared.response(with: MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b"))) Issue.record("Expected authenticationError to be thrown") } catch { guard case .authenticationError(.notAuthenticated) = error else { diff --git a/Tests/EndpointsTests/AuthenticatedEndpointTests.swift b/Tests/EndpointsTests/AuthenticatedEndpointTests.swift new file mode 100644 index 0000000..5176b07 --- /dev/null +++ b/Tests/EndpointsTests/AuthenticatedEndpointTests.swift @@ -0,0 +1,511 @@ +#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) +import Testing +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +@testable import Endpoints + +/// Exercises endpoint-declared authentication end to end: each endpoint below declares +/// its own auth instance, so tests stay isolated while sharing one transport. +@Suite("Authenticated Endpoints", .serialized) +struct AuthenticatedEndpointTests { + + private static func url(_ path: String) -> URL { + URL(string: "https://api.velosmobile.com\(path)")! + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func retriesAfterReauthentication() async throws { + let errorData = try JSONEncoder().encode(AuthTestErrorResponse(message: "unauthorized")) + let successData = try JSONEncoder().encode(AuthTestResponse(value: "ok")) + + let responses = ResponseQueue([ + (HTTPURLResponse(url: Self.url("/auth/retry"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), + (HTTPURLResponse(url: Self.url("/auth/retry"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + ]) + + TestURLProtocol.register(path: "/auth/retry") { _ in try responses.next() } + defer { TestURLProtocol.unregister(path: "/auth/retry") } + + let response = try await TestURLProtocol.makeSession().response(with: RetryEndpoint()) + + #expect(response.value == "ok") + + let counts = await RetryEndpoint.auth.counts() + #expect(counts.authenticate == 2) + #expect(counts.reauthenticate == 1) + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func retryExhaustionSurfacesFinalErrorResponse() async throws { + let errorData = try JSONEncoder().encode(AuthTestErrorResponse(message: "unauthorized")) + + let responses = ResponseQueue([ + (HTTPURLResponse(url: Self.url("/auth/exhaustion"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), + (HTTPURLResponse(url: Self.url("/auth/exhaustion"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) + ]) + + TestURLProtocol.register(path: "/auth/exhaustion") { _ in try responses.next() } + defer { TestURLProtocol.unregister(path: "/auth/exhaustion") } + + do { + _ = try await TestURLProtocol.makeSession().response(with: ExhaustionEndpoint()) + Issue.record("Expected errorResponse to be thrown") + } catch { + guard case .errorResponse(let httpResponse, let errorResponse) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(httpResponse.statusCode == 401) + #expect(errorResponse.message == "unauthorized") + } + + let counts = await ExhaustionEndpoint.auth.counts() + #expect(counts.authenticate == 2) + #expect(counts.reauthenticate == 1) + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func refreshFailureSurfacesAsAuthenticationError() async throws { + let errorData = try JSONEncoder().encode(AuthTestErrorResponse(message: "unauthorized")) + + TestURLProtocol.register(path: "/auth/failing-refresh") { _ in + (HTTPURLResponse(url: Self.url("/auth/failing-refresh"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) + } + defer { TestURLProtocol.unregister(path: "/auth/failing-refresh") } + + do { + _ = try await TestURLProtocol.makeSession().response(with: FailingRefreshEndpoint()) + Issue.record("Expected authenticationError to be thrown") + } catch { + guard case .authenticationError(.refreshFailed) = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func jwtRefreshFlowEndToEnd() async throws { + let errorData = try JSONEncoder().encode(AuthTestErrorResponse(message: "unauthorized")) + let successData = try JSONEncoder().encode(AuthTestResponse(value: "ok")) + + let responses = ResponseQueue([ + (HTTPURLResponse(url: Self.url("/auth/jwt"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), + (HTTPURLResponse(url: Self.url("/auth/jwt"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + ]) + + let recorder = RequestRecorder() + TestURLProtocol.register(path: "/auth/jwt") { request in + recorder.record(request) + return try responses.next() + } + defer { TestURLProtocol.unregister(path: "/auth/jwt") } + + await JWTEndpoint.auth.setTokens(.init(accessToken: "old-access", refreshToken: "old-refresh")) + + let response = try await TestURLProtocol.makeSession().response(with: JWTEndpoint()) + + #expect(response.value == "ok") + + let sentAuthorization = recorder.all().map { $0.value(forHTTPHeaderField: Header.authorization.name) } + #expect(sentAuthorization == ["Bearer old-access", "Bearer new-access"]) + #expect(await JWTEndpoint.auth.tokens?.accessToken == "new-access") + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func jwtWithoutTokensFailsWithoutSendingRequest() async throws { + let recorder = RequestRecorder() + TestURLProtocol.register(path: "/auth/unauthenticated-jwt") { request in + recorder.record(request) + throw URLError(.badServerResponse) + } + defer { TestURLProtocol.unregister(path: "/auth/unauthenticated-jwt") } + + do { + _ = try await TestURLProtocol.makeSession().response(with: UnauthenticatedJWTEndpoint()) + Issue.record("Expected authenticationError to be thrown") + } catch { + guard case .authenticationError(.notAuthenticated) = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + + #expect(recorder.all().isEmpty) + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func jwtRefreshFailureInvokesFailureHandler() async throws { + let errorData = try JSONEncoder().encode(AuthTestErrorResponse(message: "unauthorized")) + + TestURLProtocol.register(path: "/auth/refresh-failure") { _ in + (HTTPURLResponse(url: Self.url("/auth/refresh-failure"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) + } + defer { TestURLProtocol.unregister(path: "/auth/refresh-failure") } + + await RefreshFailureEndpoint.auth.setTokens(.init(accessToken: "old", refreshToken: "refresh")) + + do { + _ = try await TestURLProtocol.makeSession().response(with: RefreshFailureEndpoint()) + Issue.record("Expected authenticationError to be thrown") + } catch { + guard case .authenticationError(.refreshFailed(let underlying)) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(underlying is TestRefreshError) + } + + #expect(await RefreshFailureRecorder.shared.error is TestRefreshError) + #expect(await RefreshFailureEndpoint.auth.tokens?.accessToken == "old") + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func jwtProactiveRefreshHappensBeforeRequest() async throws { + let successData = try JSONEncoder().encode(AuthTestResponse(value: "ok")) + + let recorder = RequestRecorder() + TestURLProtocol.register(path: "/auth/proactive") { request in + recorder.record(request) + return (HTTPURLResponse(url: Self.url("/auth/proactive"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + } + defer { TestURLProtocol.unregister(path: "/auth/proactive") } + + await ProactiveEndpoint.auth.setTokens( + .init(accessToken: "expired-access", refreshToken: "old-refresh", expiresAt: Date(timeIntervalSinceNow: -60)) + ) + + let response = try await TestURLProtocol.makeSession().response(with: ProactiveEndpoint()) + + #expect(response.value == "ok") + + // The expired token never went over the wire: one request, already refreshed. + let sentAuthorization = recorder.all().map { $0.value(forHTTPHeaderField: Header.authorization.name) } + #expect(sentAuthorization == ["Bearer new-access"]) + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func staticAuthDoesNotRetryOn401() async throws { + let errorData = try JSONEncoder().encode(AuthTestErrorResponse(message: "unauthorized")) + + let recorder = RequestRecorder() + TestURLProtocol.register(path: "/auth/static-key") { request in + recorder.record(request) + return (HTTPURLResponse(url: Self.url("/auth/static-key"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) + } + defer { TestURLProtocol.unregister(path: "/auth/static-key") } + + do { + _ = try await TestURLProtocol.makeSession().response(with: StaticKeyEndpoint()) + Issue.record("Expected errorResponse to be thrown") + } catch { + guard case .errorResponse(let httpResponse, _) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(httpResponse.statusCode == 401) + } + + let sent = recorder.all() + #expect(sent.count == 1) + #expect(sent.first?.value(forHTTPHeaderField: Header.authorization.name) == "Bearer static-key") + } + + // MARK: - Declaration semantics + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func endpointInheritsServerAuth() async throws { + let successData = try JSONEncoder().encode(AuthTestResponse(value: "ok")) + + let recorder = RequestRecorder() + TestURLProtocol.register(path: "/auth/inherited") { request in + recorder.record(request) + return (HTTPURLResponse(url: Self.url("/auth/inherited"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + } + defer { TestURLProtocol.unregister(path: "/auth/inherited") } + + _ = try await TestURLProtocol.makeSession().response(with: InheritedAuthEndpoint()) + + // The endpoint declares no auth of its own and picks up its server's. + #expect(recorder.all().first?.value(forHTTPHeaderField: Header.authorization.name) == "Bearer server-key") + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func endpointCanOptOutOfServerAuth() async throws { + let successData = try JSONEncoder().encode(AuthTestResponse(value: "ok")) + + let recorder = RequestRecorder() + TestURLProtocol.register(path: "/auth/opted-out") { request in + recorder.record(request) + return (HTTPURLResponse(url: Self.url("/auth/opted-out"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + } + defer { TestURLProtocol.unregister(path: "/auth/opted-out") } + + _ = try await TestURLProtocol.makeSession().response(with: OptedOutEndpoint()) + + // Same server as InheritedAuthEndpoint, but this endpoint overrides with NoAuth. + #expect(recorder.all().first?.value(forHTTPHeaderField: Header.authorization.name) == nil) + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func endpointCanUseADifferentMethodThanItsServer() async throws { + let successData = try JSONEncoder().encode(AuthTestResponse(value: "ok")) + + let recorder = RequestRecorder() + TestURLProtocol.register(path: "/auth/overridden") { request in + recorder.record(request) + return (HTTPURLResponse(url: Self.url("/auth/overridden"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + } + defer { TestURLProtocol.unregister(path: "/auth/overridden") } + + _ = try await TestURLProtocol.makeSession().response(with: OverriddenAuthEndpoint()) + + #expect(recorder.all().first?.value(forHTTPHeaderField: "X-Client-Key") == "client-key") + #expect(recorder.all().first?.value(forHTTPHeaderField: Header.authorization.name) == nil) + } + + @Test + func endpointsOnAServerShareOneAuthInstance() { + // Identity matters: a shared instance is what lets refreshes coalesce across endpoints. + #expect(SharedAuthEndpointA.auth === SharedAuthEndpointB.auth) + #expect(SharedAuthEndpointA.auth === SharedAuthServer.auth) + } +} + +// MARK: - Servers + +/// Unauthenticated, for endpoints that declare their own auth. +struct AuthTestServer: ServerDefinition { + var baseUrls: [Environments: URL] { + [.production: URL(string: "https://api.velosmobile.com")!] + } + + static var defaultEnvironment: Environments { .production } +} + +/// Declares a server-wide authentication method inherited by its endpoints. +struct KeyedServer: ServerDefinition { + static let auth = HeaderKeyAuth(key: "server-key") + + var baseUrls: [Environments: URL] { + [.production: URL(string: "https://api.velosmobile.com")!] + } + + static var defaultEnvironment: Environments { .production } +} + +struct SharedAuthServer: ServerDefinition { + static let auth = JWTAuth(initialTokens: nil) { refreshToken in + JWTAuth.TokenPair(accessToken: "new", refreshToken: refreshToken) + } + + var baseUrls: [Environments: URL] { + [.production: URL(string: "https://api.velosmobile.com")!] + } + + static var defaultEnvironment: Environments { .production } +} + +// MARK: - Endpoints + +struct AuthTestResponse: Codable, Sendable { + let value: String +} + +struct AuthTestErrorResponse: Codable, Sendable, Equatable { + let message: String +} + +struct RetryEndpoint: Endpoint { + typealias Server = AuthTestServer + typealias Response = AuthTestResponse + typealias ErrorResponse = AuthTestErrorResponse + + static let auth = TestAuth() + static let definition: Definition = Definition(method: .get, path: "auth/retry") +} + +struct ExhaustionEndpoint: Endpoint { + typealias Server = AuthTestServer + typealias Response = AuthTestResponse + typealias ErrorResponse = AuthTestErrorResponse + + static let auth = TestAuth() + static let definition: Definition = Definition(method: .get, path: "auth/exhaustion") +} + +struct FailingRefreshEndpoint: Endpoint { + typealias Server = AuthTestServer + typealias Response = AuthTestResponse + typealias ErrorResponse = AuthTestErrorResponse + + static let auth = FailingRefreshAuth() + static let definition: Definition = Definition(method: .get, path: "auth/failing-refresh") +} + +struct JWTEndpoint: Endpoint { + typealias Server = AuthTestServer + typealias Response = AuthTestResponse + typealias ErrorResponse = AuthTestErrorResponse + + static let auth = JWTAuth(initialTokens: nil) { refreshToken in + #expect(refreshToken == "old-refresh") + return JWTAuth.TokenPair(accessToken: "new-access", refreshToken: "new-refresh") + } + + static let definition: Definition = Definition(method: .get, path: "auth/jwt") +} + +struct UnauthenticatedJWTEndpoint: Endpoint { + typealias Server = AuthTestServer + typealias Response = AuthTestResponse + typealias ErrorResponse = AuthTestErrorResponse + + static let auth = JWTAuth(initialTokens: nil) { refreshToken in + JWTAuth.TokenPair(accessToken: "new", refreshToken: refreshToken) + } + + static let definition: Definition = Definition(method: .get, path: "auth/unauthenticated-jwt") +} + +struct RefreshFailureEndpoint: Endpoint { + typealias Server = AuthTestServer + typealias Response = AuthTestResponse + typealias ErrorResponse = AuthTestErrorResponse + + static let auth = JWTAuth( + initialTokens: nil, + refreshHandler: { _ in throw TestRefreshError() }, + onRefreshFailed: { error in await RefreshFailureRecorder.shared.set(error) } + ) + + static let definition: Definition = Definition(method: .get, path: "auth/refresh-failure") +} + +struct ProactiveEndpoint: Endpoint { + typealias Server = AuthTestServer + typealias Response = AuthTestResponse + typealias ErrorResponse = AuthTestErrorResponse + + static let auth = JWTAuth(initialTokens: nil) { refreshToken in + #expect(refreshToken == "old-refresh") + return JWTAuth.TokenPair(accessToken: "new-access", refreshToken: "new-refresh", expiresAt: Date(timeIntervalSinceNow: 3600)) + } + + static let definition: Definition = Definition(method: .get, path: "auth/proactive") +} + +struct StaticKeyEndpoint: Endpoint { + typealias Server = AuthTestServer + typealias Response = AuthTestResponse + typealias ErrorResponse = AuthTestErrorResponse + + static let auth = HeaderKeyAuth(key: "static-key") + static let definition: Definition = Definition(method: .get, path: "auth/static-key") +} + +/// Declares no auth: inherits `KeyedServer.auth`. +struct InheritedAuthEndpoint: Endpoint { + typealias Server = KeyedServer + typealias Response = AuthTestResponse + + static let definition: Definition = Definition(method: .get, path: "auth/inherited") +} + +/// On an authenticated server, but opts out — the shape a login endpoint takes. +struct OptedOutEndpoint: Endpoint { + typealias Server = KeyedServer + typealias Response = AuthTestResponse + + static var auth: NoAuth { NoAuth() } + static let definition: Definition = Definition(method: .get, path: "auth/opted-out") +} + +/// On an authenticated server, but uses a different method entirely. +struct OverriddenAuthEndpoint: Endpoint { + typealias Server = KeyedServer + typealias Response = AuthTestResponse + + static let auth = HeaderKeyAuth(key: "client-key", header: "X-Client-Key", prefix: nil) + static let definition: Definition = Definition(method: .get, path: "auth/overridden") +} + +struct SharedAuthEndpointA: Endpoint { + typealias Server = SharedAuthServer + typealias Response = AuthTestResponse + + static let definition: Definition = Definition(method: .get, path: "auth/shared-a") +} + +struct SharedAuthEndpointB: Endpoint { + typealias Server = SharedAuthServer + typealias Response = AuthTestResponse + + static let definition: Definition = Definition(method: .get, path: "auth/shared-b") +} + +// MARK: - Test authentication methods + +actor TestAuth: AuthenticationMethod { + private var authenticateCount = 0 + private var reauthenticateCount = 0 + + func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { + authenticateCount += 1 + return request + } + + nonisolated func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool { + response?.statusCode == 401 + } + + func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { + reauthenticateCount += 1 + } + + func counts() -> (authenticate: Int, reauthenticate: Int) { + (authenticateCount, reauthenticateCount) + } +} + +struct FailingRefreshAuth: AuthenticationMethod { + func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { + request + } + + func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool { + response?.statusCode == 401 + } + + func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { + throw .refreshFailed(underlying: URLError(.userAuthenticationRequired)) + } +} + +struct TestRefreshError: Error {} + +actor RefreshFailureRecorder { + static let shared = RefreshFailureRecorder() + + private(set) var error: Error? + + func set(_ error: Error) { + self.error = error + } +} +#endif diff --git a/Tests/EndpointsTests/AuthenticatedSessionTests.swift b/Tests/EndpointsTests/AuthenticatedSessionTests.swift deleted file mode 100644 index 0365449..0000000 --- a/Tests/EndpointsTests/AuthenticatedSessionTests.swift +++ /dev/null @@ -1,423 +0,0 @@ -#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) -import Testing -import Foundation - -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -@testable import Endpoints - -@Suite("Authenticated Session", .serialized) -struct AuthenticatedSessionTests { - - private static let url = URL(string: "https://api.velosmobile.com/auth/test")! - - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) - @Test - func retriesAfterReauthentication() async throws { - let errorData = try JSONEncoder().encode(AuthTestEndpoint.ErrorResponse(message: "unauthorized")) - let successData = try JSONEncoder().encode(AuthTestEndpoint.Response(value: "ok")) - - let responses = ResponseQueue([ - (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), - (HTTPURLResponse(url: Self.url, statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) - ]) - - TestURLProtocol.register(path: "/auth/test") { _ in - try responses.next() - } - defer { TestURLProtocol.unregister(path: "/auth/test") } - - let auth = TestAuth() - let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: auth, maxRetryAttempts: 1) - - let response = try await session.response(with: AuthTestEndpoint()) - - #expect(response.value == "ok") - - let counts = await auth.counts() - #expect(counts.authenticate == 2) - #expect(counts.reauthenticate == 1) - } - - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) - @Test - func retryExhaustionSurfacesFinalErrorResponse() async throws { - let errorData = try JSONEncoder().encode(AuthTestEndpoint.ErrorResponse(message: "unauthorized")) - - let responses = ResponseQueue([ - (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), - (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) - ]) - - TestURLProtocol.register(path: "/auth/test") { _ in - try responses.next() - } - defer { TestURLProtocol.unregister(path: "/auth/test") } - - let auth = TestAuth() - let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: auth, maxRetryAttempts: 1) - - do { - _ = try await session.response(with: AuthTestEndpoint()) - Issue.record("Expected errorResponse to be thrown") - } catch { - guard case .errorResponse(let httpResponse, let errorResponse) = error else { - Issue.record("Unexpected error: \(error)") - return - } - #expect(httpResponse.statusCode == 401) - #expect(errorResponse.message == "unauthorized") - } - - let counts = await auth.counts() - #expect(counts.authenticate == 2) - #expect(counts.reauthenticate == 1) - } - - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) - @Test - func refreshFailureSurfacesAsAuthenticationError() async throws { - let errorData = try JSONEncoder().encode(AuthTestEndpoint.ErrorResponse(message: "unauthorized")) - - let responses = ResponseQueue([ - (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) - ]) - - TestURLProtocol.register(path: "/auth/test") { _ in - try responses.next() - } - defer { TestURLProtocol.unregister(path: "/auth/test") } - - let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: FailingRefreshAuth(), maxRetryAttempts: 1) - - do { - _ = try await session.response(with: AuthTestEndpoint()) - Issue.record("Expected authenticationError to be thrown") - } catch { - guard case .authenticationError(.refreshFailed) = error else { - Issue.record("Unexpected error: \(error)") - return - } - } - } - - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) - @Test - func jwtRefreshFlowEndToEnd() async throws { - let errorData = try JSONEncoder().encode(AuthTestEndpoint.ErrorResponse(message: "unauthorized")) - let successData = try JSONEncoder().encode(AuthTestEndpoint.Response(value: "ok")) - - let responses = ResponseQueue([ - (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), - (HTTPURLResponse(url: Self.url, statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) - ]) - - let recorder = RequestRecorder() - TestURLProtocol.register(path: "/auth/test") { request in - recorder.record(request) - return try responses.next() - } - defer { TestURLProtocol.unregister(path: "/auth/test") } - - let auth = JWTAuth( - initialTokens: .init(accessToken: "old-access", refreshToken: "old-refresh"), - refreshHandler: { refreshToken in - #expect(refreshToken == "old-refresh") - return JWTAuth.TokenPair(accessToken: "new-access", refreshToken: "new-refresh") - } - ) - let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: auth, maxRetryAttempts: 1) - - let response = try await session.response(with: AuthTestEndpoint()) - - #expect(response.value == "ok") - - let sentAuthorization = recorder.all().map { $0.value(forHTTPHeaderField: Header.authorization.name) } - #expect(sentAuthorization == ["Bearer old-access", "Bearer new-access"]) - #expect(await auth.tokens == JWTAuth.TokenPair(accessToken: "new-access", refreshToken: "new-refresh")) - } - - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) - @Test - func jwtWithoutTokensFailsWithoutSendingRequest() async throws { - let recorder = RequestRecorder() - TestURLProtocol.register(path: "/auth/test") { request in - recorder.record(request) - throw URLError(.badServerResponse) - } - defer { TestURLProtocol.unregister(path: "/auth/test") } - - let auth = JWTAuth(initialTokens: nil) { _ in - JWTAuth.TokenPair(accessToken: "new", refreshToken: "refresh") - } - let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: auth) - - do { - _ = try await session.response(with: AuthTestEndpoint()) - Issue.record("Expected authenticationError to be thrown") - } catch { - guard case .authenticationError(.notAuthenticated) = error else { - Issue.record("Unexpected error: \(error)") - return - } - } - - #expect(recorder.all().isEmpty) - } - - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) - @Test - func jwtRefreshFailureInvokesFailureHandler() async throws { - let errorData = try JSONEncoder().encode(AuthTestEndpoint.ErrorResponse(message: "unauthorized")) - - let responses = ResponseQueue([ - (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) - ]) - - TestURLProtocol.register(path: "/auth/test") { _ in - try responses.next() - } - defer { TestURLProtocol.unregister(path: "/auth/test") } - - let failureBox = ErrorBox() - let auth = JWTAuth( - initialTokens: .init(accessToken: "old", refreshToken: "refresh"), - refreshHandler: { _ in throw TestRefreshError() }, - onRefreshFailed: { error in await failureBox.set(error) } - ) - let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: auth, maxRetryAttempts: 1) - - do { - _ = try await session.response(with: AuthTestEndpoint()) - Issue.record("Expected authenticationError to be thrown") - } catch { - guard case .authenticationError(.refreshFailed(let underlying)) = error else { - Issue.record("Unexpected error: \(error)") - return - } - #expect(underlying is TestRefreshError) - } - - #expect(await failureBox.error is TestRefreshError) - #expect((await auth.tokens)?.accessToken == "old") - } - - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) - @Test - func jwtProactiveRefreshHappensBeforeRequest() async throws { - let successData = try JSONEncoder().encode(AuthTestEndpoint.Response(value: "ok")) - - let responses = ResponseQueue([ - (HTTPURLResponse(url: Self.url, statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) - ]) - - let recorder = RequestRecorder() - TestURLProtocol.register(path: "/auth/test") { request in - recorder.record(request) - return try responses.next() - } - defer { TestURLProtocol.unregister(path: "/auth/test") } - - let auth = JWTAuth( - initialTokens: .init(accessToken: "expired-access", refreshToken: "old-refresh", expiresAt: Date(timeIntervalSinceNow: -60)), - refreshHandler: { refreshToken in - #expect(refreshToken == "old-refresh") - return JWTAuth.TokenPair(accessToken: "new-access", refreshToken: "new-refresh", expiresAt: Date(timeIntervalSinceNow: 3600)) - } - ) - let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: auth) - - let response = try await session.response(with: AuthTestEndpoint()) - - #expect(response.value == "ok") - - // The expired token never went over the wire: one request, already refreshed. - let sentAuthorization = recorder.all().map { $0.value(forHTTPHeaderField: Header.authorization.name) } - #expect(sentAuthorization == ["Bearer new-access"]) - } - - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) - @Test - func staticAuthDoesNotRetryOn401() async throws { - let errorData = try JSONEncoder().encode(AuthTestEndpoint.ErrorResponse(message: "unauthorized")) - - let responses = ResponseQueue([ - (HTTPURLResponse(url: Self.url, statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) - ]) - - let recorder = RequestRecorder() - TestURLProtocol.register(path: "/auth/test") { request in - recorder.record(request) - return try responses.next() - } - defer { TestURLProtocol.unregister(path: "/auth/test") } - - let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: HeaderKeyAuth(key: "static-key"), maxRetryAttempts: 1) - - do { - _ = try await session.response(with: AuthTestEndpoint()) - Issue.record("Expected errorResponse to be thrown") - } catch { - guard case .errorResponse(let httpResponse, _) = error else { - Issue.record("Unexpected error: \(error)") - return - } - #expect(httpResponse.statusCode == 401) - } - - let sent = recorder.all() - #expect(sent.count == 1) - #expect(sent.first?.value(forHTTPHeaderField: Header.authorization.name) == "Bearer static-key") - } - - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) - @Test - func voidResponse() async throws { - let voidUrl = URL(string: "https://api.velosmobile.com/auth/void")! - let responses = ResponseQueue([ - (HTTPURLResponse(url: voidUrl, statusCode: 204, httpVersion: nil, headerFields: nil)!, Data()) - ]) - - TestURLProtocol.register(path: "/auth/void") { _ in - try responses.next() - } - defer { TestURLProtocol.unregister(path: "/auth/void") } - - let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: NoAuth()) - - try await session.response(with: AuthVoidEndpoint()) - } - - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) - @Test - func dataResponse() async throws { - let dataUrl = URL(string: "https://api.velosmobile.com/auth/data")! - let payload = Data([0xde, 0xad, 0xbe, 0xef]) - let responses = ResponseQueue([ - (HTTPURLResponse(url: dataUrl, statusCode: 200, httpVersion: nil, headerFields: nil)!, payload) - ]) - - TestURLProtocol.register(path: "/auth/data") { _ in - try responses.next() - } - defer { TestURLProtocol.unregister(path: "/auth/data") } - - let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: NoAuth()) - - let response = try await session.response(with: AuthDataEndpoint()) - #expect(response == payload) - } - - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) - @Test - func decodeFailureSurfacesAsResponseParseError() async throws { - let garbage = Data("not json".utf8) - let responses = ResponseQueue([ - (HTTPURLResponse(url: Self.url, statusCode: 200, httpVersion: nil, headerFields: nil)!, garbage) - ]) - - TestURLProtocol.register(path: "/auth/test") { _ in - try responses.next() - } - defer { TestURLProtocol.unregister(path: "/auth/test") } - - let session = AuthenticatedSession(session: TestURLProtocol.makeSession(), auth: NoAuth()) - - do { - _ = try await session.response(with: AuthTestEndpoint()) - Issue.record("Expected responseParseError to be thrown") - } catch { - guard case .responseParseError(let data, _) = error else { - Issue.record("Unexpected error: \(error)") - return - } - #expect(data == garbage) - } - } -} - -struct AuthTestEndpoint: Endpoint { - typealias Server = TestServer - - static let definition: Definition = Definition( - method: .get, - path: "auth/test" - ) - - struct Response: Codable, Sendable { - let value: String - } - - struct ErrorResponse: Codable, Sendable, Equatable { - let message: String - } -} - -struct AuthVoidEndpoint: Endpoint { - typealias Server = TestServer - typealias Response = Void - - static let definition: Definition = Definition( - method: .post, - path: "auth/void" - ) -} - -struct AuthDataEndpoint: Endpoint { - typealias Server = TestServer - typealias Response = Data - - static let definition: Definition = Definition( - method: .get, - path: "auth/data" - ) -} - -actor TestAuth: AuthenticationMethod { - private var authenticateCount = 0 - private var reauthenticateCount = 0 - - func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { - authenticateCount += 1 - return request - } - - nonisolated func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool { - response?.statusCode == 401 - } - - func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { - reauthenticateCount += 1 - } - - func counts() -> (authenticate: Int, reauthenticate: Int) { - (authenticateCount, reauthenticateCount) - } -} - -struct FailingRefreshAuth: AuthenticationMethod { - func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { - request - } - - func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool { - response?.statusCode == 401 - } - - func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { - throw .refreshFailed(underlying: URLError(.userAuthenticationRequired)) - } -} - -struct TestRefreshError: Error {} - -actor ErrorBox { - private(set) var error: Error? - - func set(_ error: Error) { - self.error = error - } -} -#endif From 7a7dd8ea1d2b594c4ce284d70bca45895498f608 Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 27 Jul 2026 10:52:41 -0700 Subject: [PATCH 18/23] Document authentication in the DocC catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add an Authentication topic group to the landing page so the method types are discoverable in generated docs; add MockRegistry alongside the other mocking types - Rewrite the Examples requestProcessor sample, which demonstrated attaching a Bearer token — the case authentication now covers — and point readers at ServerDefinition.auth instead - Remove stale '- Parameter environment:' doc comments left from before servers carried their own environments; they were warning on every docs build --- Sources/Endpoints/Endpoint+URLRequest.swift | 1 - Sources/Endpoints/Endpoints.docc/Endpoints.md | 11 +++++++++++ Sources/Endpoints/Endpoints.docc/Examples.md | 8 ++++++-- Sources/Endpoints/Extensions/URLSession+Async.swift | 4 +--- Sources/Endpoints/Extensions/URLSession+Combine.swift | 3 --- .../Endpoints/Extensions/URLSession+Endpoints.swift | 3 --- 6 files changed, 18 insertions(+), 12 deletions(-) diff --git a/Sources/Endpoints/Endpoint+URLRequest.swift b/Sources/Endpoints/Endpoint+URLRequest.swift index 266acfb..ed65a1c 100644 --- a/Sources/Endpoints/Endpoint+URLRequest.swift +++ b/Sources/Endpoints/Endpoint+URLRequest.swift @@ -15,7 +15,6 @@ import FoundationNetworking extension Endpoint { /// Generates a `URLRequest` given the associated request value. - /// - Parameter environment: The environment in which to create the request /// - Throws: An ``EndpointError`` which describes the error filling in data to the associated ``Definition``. /// - Returns: A `URLRequest` ready for requesting with all values from `self` filled in according to the associated ``Endpoint``. public func urlRequest() throws(EndpointError) -> URLRequest { diff --git a/Sources/Endpoints/Endpoints.docc/Endpoints.md b/Sources/Endpoints/Endpoints.docc/Endpoints.md index 4a5e5cb..1983729 100644 --- a/Sources/Endpoints/Endpoints.docc/Endpoints.md +++ b/Sources/Endpoints/Endpoints.docc/Endpoints.md @@ -21,11 +21,22 @@ The purpose of Endpoints is to, in a type-safe way, define how to create a `URLR - ``GenericServer`` - ``TypicalEnvironments`` +### Authentication + +- ``AuthenticationMethod`` +- ``AuthenticationError`` +- ``NoAuth`` +- ``HeaderKeyAuth`` +- ``BasicAuth`` +- ``CookieAuth`` +- ``JWTAuth`` + ### Testing and Mocking - - ``EndpointsMocking`` - ``withMock(_:_:test:)`` +- ``MockRegistry`` - ``MockContinuation`` - ``MockAction`` diff --git a/Sources/Endpoints/Endpoints.docc/Examples.md b/Sources/Endpoints/Endpoints.docc/Examples.md index fad2e3d..3ed96d5 100644 --- a/Sources/Endpoints/Endpoints.docc/Examples.md +++ b/Sources/Endpoints/Endpoints.docc/Examples.md @@ -59,16 +59,20 @@ struct CustomServer: ServerDefinition { static var defaultEnvironment: Environments { .debug } - var requestProcessor: (URLRequest) -> URLRequest { + var requestProcessor: @Sendable (URLRequest) -> URLRequest { return { request in var mutableRequest = request - mutableRequest.setValue("Bearer token", forHTTPHeaderField: "Authorization") + mutableRequest.setValue(buildNumber, forHTTPHeaderField: "X-Client-Build") return mutableRequest } } } ``` +> Note: `requestProcessor` is for static, synchronous request modification. To attach +> credentials — especially ones that expire and need refreshing — declare an +> ``AuthenticationMethod`` with ``ServerDefinition/auth`` instead. + ### Changing Environments To switch environments at runtime, set the environment on the server type: diff --git a/Sources/Endpoints/Extensions/URLSession+Async.swift b/Sources/Endpoints/Extensions/URLSession+Async.swift index ed1d163..d456ef1 100644 --- a/Sources/Endpoints/Extensions/URLSession+Async.swift +++ b/Sources/Endpoints/Extensions/URLSession+Async.swift @@ -18,9 +18,7 @@ public extension URLSession { /// Perform the request for the endpoint on the given environment. /// /// Use this when the response body is expected to be `Void` or empty as you would have in a 204. - /// - Parameters: - /// - environment: The environment in which to make the request - /// - endpoint: The endpoint instance to be used to make the request + /// - Parameter endpoint: The endpoint instance to be used to make the request func response(with endpoint: T) async throws(T.TaskError) where T.Response == Void { #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) if let mockResponse: T.Response = try await Mocking.shared.handleMock(for: T.self) { diff --git a/Sources/Endpoints/Extensions/URLSession+Combine.swift b/Sources/Endpoints/Extensions/URLSession+Combine.swift index 19376f9..def5db5 100644 --- a/Sources/Endpoints/Extensions/URLSession+Combine.swift +++ b/Sources/Endpoints/Extensions/URLSession+Combine.swift @@ -16,7 +16,6 @@ public extension URLSession { /// Creates a publisher and starts the request for the given ``Definition``. This function does not expect a result value from the endpoint. /// - Parameters: - /// - environment: The environment with which to make the request /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Void, T.Auth == NoAuth { @@ -65,7 +64,6 @@ public extension URLSession { /// Creates a publisher and starts the request for the given ``Definition``. This function expects a result value of `Data`. /// - Parameters: - /// - environment: The environment with which to make the request /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Data, T.Auth == NoAuth { @@ -115,7 +113,6 @@ public extension URLSession { /// Creates a publisher and starts the request for the given ``Definition``. This function expects a result value which is `Decodable`. /// - Parameters: - /// - environment: The environment with which to make the request /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response: Decodable, T.Auth == NoAuth { diff --git a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift index 6faea54..a1ab9ab 100644 --- a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift +++ b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift @@ -59,7 +59,6 @@ public extension URLSession { /// This function does not expect a result value from the endpoint. /// Note: This does not start the request. That must be done with `resume()`. /// - Parameters: - /// - environment: An instance conforming to ``EnvironmentType``, which is used to build the full request. /// - endpoint: The request data to use when filling in the ``Definition`` /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. @@ -99,7 +98,6 @@ public extension URLSession { /// This function expects a result value of `Data`. /// Note: This does not start the request. That must be done with `resume()`. /// - Parameters: - /// - environment: An instance conforming to ``EnvironmentType``, which is used to build the full request. /// - endpoint: The request data to use when filling in the ``Definition`` /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. @@ -137,7 +135,6 @@ public extension URLSession { /// This function expects a result value which is `Decodable`. /// Note: This does not start the request. That must be done with `resume()`. /// - Parameters: - /// - environment: An instance conforming to ``EnvironmentType``, which is used to build the full request. /// - endpoint: The request data to use when filling in the ``Definition`` /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. From 9297d9d42e68a14543d9e7c7c7ac0f8842ff7e85 Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 27 Jul 2026 11:31:25 -0700 Subject: [PATCH 19/23] Apply endpoint authentication in the Combine API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit endpointPublisher now honors the endpoint's Auth, including refresh and retry, rather than being constrained to NoAuth endpoints. An earlier claim that Combine could not await an asynchronous authenticate was wrong: the publishers now wrap the async request path through a small bridge that carries the Future promise across the concurrency boundary under a lock, and cancels the underlying Task when the subscription is cancelled. This reuses the async retry loop rather than reimplementing it in Combine operators, which also makes Mocking.handleMockPublisher dead code — mocks now flow through the async path — so it is removed. The closure-based endpointTask stays constrained to NoAuth: it returns a URLSessionDataTask synchronously and structurally cannot authenticate first without changing what it returns. Behavior notes: the publishers now carry the async path's availability (macOS 12, matching what the README already documents as required) and deliver on the completing task rather than an explicit global queue. Tests cover an authenticated endpoint, JWT refresh-and-retry, an authentication error surfacing through the publisher, and cancellation propagating into the in-flight task — the last verified to fail when the cancellation hook is removed. --- README.md | 4 +- .../Extensions/URLSession+Combine.swift | 239 ++++++++---------- Sources/Endpoints/Mocking/Mocking.swift | 40 --- .../CombineAuthenticationTests.swift | 227 +++++++++++++++++ 4 files changed, 337 insertions(+), 173 deletions(-) create mode 100644 Tests/EndpointsTests/CombineAuthenticationTests.swift diff --git a/README.md b/README.md index 18b67a5..59e5adf 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,9 @@ Declare the method as a `static let` so all endpoints on a server share one inst Servers that declare no `auth` use `NoAuth`, so existing endpoints keep working unchanged. -Authentication requires the async/await API. Because the Combine and closure-based APIs cannot await an asynchronous `authenticate`, they are constrained to unauthenticated endpoints — calling `endpointPublisher` or `endpointTask` with an authenticated endpoint is a compile error rather than a request that silently skips its credentials. +The async/await and Combine APIs both apply authentication, including refresh and retry. Cancelling a Combine subscription cancels the underlying request. + +The closure-based `endpointTask` is the exception: it hands back a `URLSessionDataTask` synchronously, so it cannot await an asynchronous `authenticate` before returning. It is constrained to unauthenticated endpoints — calling it with an authenticated endpoint is a compile error rather than a request that silently skips its credentials. Use `response(with:)` or `endpointPublisher(with:)` for authenticated endpoints. Built-in authentication methods: diff --git a/Sources/Endpoints/Extensions/URLSession+Combine.swift b/Sources/Endpoints/Extensions/URLSession+Combine.swift index def5db5..e075128 100644 --- a/Sources/Endpoints/Extensions/URLSession+Combine.swift +++ b/Sources/Endpoints/Extensions/URLSession+Combine.swift @@ -11,159 +11,134 @@ import Foundation #if canImport(Combine) import Combine -@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +/// Bridges a single-value async operation into Combine. +/// +/// Holds the in-flight `Task` so that cancelling the subscription cancels the work, and +/// carries the `Future` promise across the concurrency boundary under a lock. Combine's +/// promise type is not `Sendable`, which is why this is `@unchecked` rather than a plain +/// value type. +private final class AsyncBridge: @unchecked Sendable { + private let lock = NSLock() + private var promise: ((Result) -> Void)? + private var task: Task? + + /// Starts `work`, delivering its result to `promise` unless the subscription is + /// cancelled first. + func begin( + promise: @escaping (Result) -> Void, + work: @escaping @Sendable () async -> Result + ) { + lock.lock() + self.promise = promise + lock.unlock() + + let task = Task { [self] in + deliver(await work()) + } + + lock.lock() + if self.promise == nil { + // Cancelled between starting and storing the task. + lock.unlock() + task.cancel() + } else { + self.task = task + lock.unlock() + } + } + + private func deliver(_ result: Result) { + lock.lock() + let promise = self.promise + self.promise = nil + self.task = nil + lock.unlock() + + promise?(result) + } + + func cancel() { + lock.lock() + let task = self.task + self.promise = nil + self.task = nil + lock.unlock() + + task?.cancel() + } +} + +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) +private func endpointPublisher( + performing work: @escaping @Sendable () async -> Result +) -> AnyPublisher { + Deferred { () -> AnyPublisher in + let bridge = AsyncBridge() + return Future { promise in + bridge.begin(promise: promise, work: work) + } + .handleEvents(receiveCancel: { bridge.cancel() }) + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() +} + +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) public extension URLSession { /// Creates a publisher and starts the request for the given ``Definition``. This function does not expect a result value from the endpoint. + /// + /// The endpoint's ``Endpoint/Auth`` is applied to the request, and a failed request is + /// retried after reauthentication when the method asks for it. Cancelling the + /// subscription cancels the underlying request. /// - Parameters: /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` - func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Void, T.Auth == NoAuth { - let urlRequest: URLRequest - do { - urlRequest = try createUrlRequest(for: endpoint) - } catch { - return Fail(outputType: T.Response.self, failure: error) - .eraseToAnyPublisher() - } - - let load = dataTaskPublisher(for: urlRequest) - .subscribe(on: DispatchQueue.global()) - .receive(on: DispatchQueue.global()) - .mapError { error -> T.TaskError in - guard case let .failure(responseError) = T.definition.response(data: nil, response: nil, error: error) else { - fatalError("Unhandled error") - } - - return responseError - } - .tryMap { result in - _ = try T.definition.response(data: result.data, response: result.response, error: nil).get() + func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Void { + Endpoints.endpointPublisher { + do throws(T.TaskError) { + try await self.response(with: endpoint) + return .success(()) + } catch { + return .failure(error) } - // swiftlint:disable:next force_cast - .mapError { $0 as! T.TaskError } - - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - return Mocking.shared.handleMockPublisher(for: T.self) - .flatMap { mock in - if let mock { - return Just(mock) - .setFailureType(to: T.TaskError.self) - .eraseToAnyPublisher() - } else { - return load - .eraseToAnyPublisher() - } - } - .eraseToAnyPublisher() - #else - return load - .eraseToAnyPublisher() - #endif + } } /// Creates a publisher and starts the request for the given ``Definition``. This function expects a result value of `Data`. + /// + /// The endpoint's ``Endpoint/Auth`` is applied to the request, and a failed request is + /// retried after reauthentication when the method asks for it. Cancelling the + /// subscription cancels the underlying request. /// - Parameters: /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` - func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Data, T.Auth == NoAuth { - - let urlRequest: URLRequest - do { - urlRequest = try createUrlRequest(for: endpoint) - } catch { - return Fail(outputType: T.Response.self, failure: error) - .eraseToAnyPublisher() - } - - let load = dataTaskPublisher(for: urlRequest) - .subscribe(on: DispatchQueue.global()) - .receive(on: DispatchQueue.global()) - .mapError { error -> T.TaskError in - guard case let .failure(responseError) = T.definition.response(data: nil, response: nil, error: error) else { - fatalError("Unhandled error") - } - - return responseError + func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Data { + Endpoints.endpointPublisher { + do throws(T.TaskError) { + return .success(try await self.response(with: endpoint)) + } catch { + return .failure(error) } - .tryMap { result -> T.Response in - try T.definition.response(data: result.data, response: result.response, error: nil).get() - } - // swiftlint:disable:next force_cast - .mapError { $0 as! T.TaskError } - - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - return Mocking.shared.handleMockPublisher(for: T.self) - .flatMap { mock in - if let mock { - return Just(mock) - .setFailureType(to: T.TaskError.self) - .eraseToAnyPublisher() - } else { - return load - .eraseToAnyPublisher() - } - } - .eraseToAnyPublisher() - #else - return load - .eraseToAnyPublisher() - #endif + } } /// Creates a publisher and starts the request for the given ``Definition``. This function expects a result value which is `Decodable`. + /// + /// The endpoint's ``Endpoint/Auth`` is applied to the request, and a failed request is + /// retried after reauthentication when the method asks for it. Cancelling the + /// subscription cancels the underlying request. /// - Parameters: /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` - func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response: Decodable, T.Auth == NoAuth { - - let urlRequest: URLRequest - do { - urlRequest = try createUrlRequest(for: endpoint) - } catch { - return Fail(outputType: T.Response.self, failure: error) - .eraseToAnyPublisher() - } - - - let load = dataTaskPublisher(for: urlRequest) - .subscribe(on: DispatchQueue.global()) - .receive(on: DispatchQueue.global()) - .mapError { error -> T.TaskError in - guard case let .failure(responseError) = T.definition.response(data: nil, response: nil, error: error) else { - fatalError("Unhandled error") - } - - return responseError - } - .tryMap { result -> T.Response in - let data = try T.definition.response(data: result.data, response: result.response, error: nil).get() - do { - return try T.responseDecoder.decode(T.Response.self, from: data) - } catch { - throw T.TaskError.responseParseError(data: data, error: error) - } + func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response: Decodable { + Endpoints.endpointPublisher { + do throws(T.TaskError) { + return .success(try await self.response(with: endpoint)) + } catch { + return .failure(error) } - // swiftlint:disable:next force_cast - .mapError { $0 as! T.TaskError } - - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - return Mocking.shared.handleMockPublisher(for: T.self) - .flatMap { mock in - if let mock { - return Just(mock) - .setFailureType(to: T.TaskError.self) - .eraseToAnyPublisher() - } else { - return load - .eraseToAnyPublisher() - } - } - .eraseToAnyPublisher() - #else - return load - .eraseToAnyPublisher() - #endif + } } } diff --git a/Sources/Endpoints/Mocking/Mocking.swift b/Sources/Endpoints/Mocking/Mocking.swift index 09f6925..7d41da4 100644 --- a/Sources/Endpoints/Mocking/Mocking.swift +++ b/Sources/Endpoints/Mocking/Mocking.swift @@ -118,44 +118,4 @@ extension Mocking { } } -#if canImport(Combine) -@preconcurrency import Combine - -extension Mocking { - /// Handles a mock request for Combine publishers. - /// - Parameter endpointsOfType: The endpoint type being requested - /// - Returns: A publisher that emits the mock response or error - func handleMockPublisher(for endpointsOfType: T.Type) -> AnyPublisher { - guard shouldHandleMock(for: T.self) else { - return Just(nil) - .setFailureType(to: T.TaskError.self) - .eraseToAnyPublisher() - } - - let subject = CurrentValueSubject(nil) - - Task { - guard let action = await actionForMock(for: T.self) else { - subject.send(nil) - return - } - - switch action { - case .none: - subject.send(nil) - case .return(let value): - subject.send(value) - case .fail(let errorResponse): - subject.send(completion: .failure(T.TaskError.errorResponse(httpResponse: HTTPURLResponse(), response: errorResponse))) - case .throw(let error): - subject.send(completion: .failure(error)) - } - } - - return subject - .eraseToAnyPublisher() - } -} -#endif - #endif diff --git a/Tests/EndpointsTests/CombineAuthenticationTests.swift b/Tests/EndpointsTests/CombineAuthenticationTests.swift new file mode 100644 index 0000000..92492a9 --- /dev/null +++ b/Tests/EndpointsTests/CombineAuthenticationTests.swift @@ -0,0 +1,227 @@ +#if canImport(Combine) && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) +import Testing +import Foundation +import Combine + +@testable import Endpoints + +/// Covers the Combine publishers against authenticated endpoints, plus cancellation +/// propagating into the underlying request. +@Suite("Combine Authentication", .serialized) +struct CombineAuthenticationTests { + + private static func url(_ path: String) -> URL { + URL(string: "https://api.velosmobile.com\(path)")! + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func publisherAppliesEndpointAuthentication() async throws { + let successData = try JSONEncoder().encode(AuthTestResponse(value: "ok")) + + let recorder = RequestRecorder() + TestURLProtocol.register(path: "/combine/keyed") { request in + recorder.record(request) + return (HTTPURLResponse(url: Self.url("/combine/keyed"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + } + defer { TestURLProtocol.unregister(path: "/combine/keyed") } + + let values = TestURLProtocol.makeSession() + .endpointPublisher(with: CombineKeyedEndpoint()) + .values + + var received: AuthTestResponse? + for try await value in values { + received = value + } + + #expect(received?.value == "ok") + #expect(recorder.all().first?.value(forHTTPHeaderField: Header.authorization.name) == "Bearer combine-key") + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func publisherRetriesAfterReauthentication() async throws { + let errorData = try JSONEncoder().encode(AuthTestErrorResponse(message: "unauthorized")) + let successData = try JSONEncoder().encode(AuthTestResponse(value: "ok")) + + let responses = ResponseQueue([ + (HTTPURLResponse(url: Self.url("/combine/jwt"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), + (HTTPURLResponse(url: Self.url("/combine/jwt"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + ]) + + let recorder = RequestRecorder() + TestURLProtocol.register(path: "/combine/jwt") { request in + recorder.record(request) + return try responses.next() + } + defer { TestURLProtocol.unregister(path: "/combine/jwt") } + + await CombineJWTEndpoint.auth.setTokens(.init(accessToken: "old-access", refreshToken: "old-refresh")) + + let values = TestURLProtocol.makeSession() + .endpointPublisher(with: CombineJWTEndpoint()) + .values + + var received: AuthTestResponse? + for try await value in values { + received = value + } + + #expect(received?.value == "ok") + + let sent = recorder.all().map { $0.value(forHTTPHeaderField: Header.authorization.name) } + #expect(sent == ["Bearer old-access", "Bearer new-access"]) + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func publisherSurfacesAuthenticationErrors() async throws { + let recorder = RequestRecorder() + TestURLProtocol.register(path: "/combine/unauthenticated") { request in + recorder.record(request) + throw URLError(.badServerResponse) + } + defer { TestURLProtocol.unregister(path: "/combine/unauthenticated") } + + let values = TestURLProtocol.makeSession() + .endpointPublisher(with: CombineUnauthenticatedJWTEndpoint()) + .values + + do { + for try await _ in values {} + Issue.record("Expected authenticationError to be thrown") + } catch let error as CombineUnauthenticatedJWTEndpoint.TaskError { + guard case .authenticationError(.notAuthenticated) = error else { + Issue.record("Unexpected error: \(error)") + return + } + } catch { + Issue.record("Unexpected error type: \(error)") + } + + #expect(recorder.all().isEmpty) + } + + /// Cancelling the subscription must cancel the in-flight `Task`, not merely stop + /// delivery — Combine gives the latter for free, so this asserts the former by + /// having the endpoint's auth observe its own cancellation. + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func cancellingSubscriptionCancelsInFlightWork() async throws { + let cancellable = TestURLProtocol.makeSession() + .endpointPublisher(with: CombineCancelEndpoint()) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) + + // Wait until the request is genuinely in flight inside authenticate. + await CancellationProbe.shared.waitForStart() + + cancellable.cancel() + + var observed = false + for _ in 0..<40 { + if await CancellationProbe.shared.didObserveCancellation { + observed = true + break + } + try await Task.sleep(nanoseconds: 50_000_000) + } + + #expect(observed, "Cancelling the subscription should cancel the underlying task") + } +} + +// MARK: - Fixtures + +struct CombineTestServer: ServerDefinition { + var baseUrls: [Environments: URL] { + [.production: URL(string: "https://api.velosmobile.com")!] + } + + static var defaultEnvironment: Environments { .production } +} + +struct CombineKeyedEndpoint: Endpoint { + typealias Server = CombineTestServer + typealias Response = AuthTestResponse + typealias ErrorResponse = AuthTestErrorResponse + + static let auth = HeaderKeyAuth(key: "combine-key") + static let definition: Definition = Definition(method: .get, path: "combine/keyed") +} + +struct CombineJWTEndpoint: Endpoint { + typealias Server = CombineTestServer + typealias Response = AuthTestResponse + typealias ErrorResponse = AuthTestErrorResponse + + static let auth = JWTAuth(initialTokens: nil) { refreshToken in + #expect(refreshToken == "old-refresh") + return JWTAuth.TokenPair(accessToken: "new-access", refreshToken: "new-refresh") + } + + static let definition: Definition = Definition(method: .get, path: "combine/jwt") +} + +struct CombineUnauthenticatedJWTEndpoint: Endpoint { + typealias Server = CombineTestServer + typealias Response = AuthTestResponse + typealias ErrorResponse = AuthTestErrorResponse + + static let auth = JWTAuth(initialTokens: nil) { refreshToken in + JWTAuth.TokenPair(accessToken: "new", refreshToken: refreshToken) + } + + static let definition: Definition = Definition(method: .get, path: "combine/unauthenticated") +} + +struct CombineCancelEndpoint: Endpoint { + typealias Server = CombineTestServer + typealias Response = AuthTestResponse + typealias ErrorResponse = AuthTestErrorResponse + + static let auth = ProbeAuth() + static let definition: Definition = Definition(method: .get, path: "combine/cancel") +} + +/// Blocks inside `authenticate` so a test can cancel mid-flight, and records whether +/// the cancellation actually reached the task. +struct ProbeAuth: AuthenticationMethod { + func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { + await CancellationProbe.shared.markStarted() + + do { + try await Task.sleep(nanoseconds: 3_000_000_000) + } catch { + // Task.sleep throws when the enclosing task is cancelled. + await CancellationProbe.shared.markCancelled() + throw .custom(underlying: error) + } + + return request + } +} + +actor CancellationProbe { + static let shared = CancellationProbe() + + private var started = false + private var startWaiters: [CheckedContinuation] = [] + private(set) var didObserveCancellation = false + + func markStarted() { + started = true + startWaiters.forEach { $0.resume() } + startWaiters.removeAll() + } + + func waitForStart() async { + if started { return } + await withCheckedContinuation { startWaiters.append($0) } + } + + func markCancelled() { + didObserveCancellation = true + } +} +#endif From 929ac773eb61d13818a19f89af3429a242659e76 Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 27 Jul 2026 11:45:38 -0700 Subject: [PATCH 20/23] Simplify: dedup request path, mock scoping, and test fixtures Source: - performRequest is typed -> T.Response (R was always T.Response), so the mock short-circuit moves inside it once instead of being pasted into all three response overloads; where mocks sit relative to auth is now one documented decision point - Hoist createUrlRequest out of the retry loop: the endpoint is immutable, so only the credentials applied to it vary per attempt - Combine's bridge takes a throwing closure, collapsing three identical do/catch Result wrappers into one - withMock(_:_:test:) delegates to withMock(registering:test:) so one function owns the merge/shadow semantics both document - Merge the twin query/form parameter loops into a single pass - Clamp retries once via AuthenticationMethod.retryAttempts rather than in the transport, so future transports inherit the bound - BasicAuth/HeaderKeyAuth compose their header value in init; the inputs are immutable, so it was re-encoded on every request - JWTAuth.refresh: join-or-start via ??, single-caller awaitRefresh inlined Tests: - Shared testURL helper, response fixtures, and the Gate latch move to TestTransport.swift; AuthTestServer/CombineTestServer are dropped in favor of the existing TestServer - CancellationProbe is rebuilt on Gate, replacing a hand-rolled continuation latch. Gate gains a bounded wait(upTo:) so an unpropagated cancellation fails in 2s instead of hanging the suite Re-verified that the cancellation test still fails when the Combine cancel hook is removed. --- .../Authentication/AuthenticationMethod.swift | 6 +- .../Implementations/BasicAuth.swift | 7 +- .../Implementations/HeaderKeyAuth.swift | 5 +- .../Implementations/JWTAuth.swift | 26 +++---- Sources/Endpoints/Endpoint+URLRequest.swift | 40 +++++------ .../Extensions/URLSession+Async.swift | 49 ++++++------- .../Extensions/URLSession+Combine.swift | 35 ++++------ Sources/Endpoints/Mocking/Mocking.swift | 6 +- .../AuthenticatedEndpointTests.swift | 64 ++++++----------- .../EndpointsTests/AuthenticationTests.swift | 16 ----- .../CombineAuthenticationTests.swift | 68 +++++-------------- Tests/EndpointsTests/TestTransport.swift | 52 ++++++++++++++ 12 files changed, 163 insertions(+), 211 deletions(-) diff --git a/Sources/Endpoints/Authentication/AuthenticationMethod.swift b/Sources/Endpoints/Authentication/AuthenticationMethod.swift index 043f3c1..22a4420 100644 --- a/Sources/Endpoints/Authentication/AuthenticationMethod.swift +++ b/Sources/Endpoints/Authentication/AuthenticationMethod.swift @@ -36,7 +36,7 @@ public protocol AuthenticationMethod: Sendable { /// How many times a request may be retried after reauthenticating. Defaults to 1. /// /// Bounds the retry loop so that a server which keeps rejecting credentials cannot - /// cause an infinite request/refresh cycle. Negative values are treated as 0. + /// cause an infinite request/refresh cycle. var maxRetryAttempts: Int { get } } @@ -56,4 +56,8 @@ public extension AuthenticationMethod { /// By default, a request is retried once after reauthenticating. var maxRetryAttempts: Int { 1 } + + /// ``maxRetryAttempts`` normalized to a usable count, so that every transport + /// applies the same bound without repeating the clamp. + var retryAttempts: Int { max(0, maxRetryAttempts) } } diff --git a/Sources/Endpoints/Authentication/Implementations/BasicAuth.swift b/Sources/Endpoints/Authentication/Implementations/BasicAuth.swift index 3183f98..3278cd8 100644 --- a/Sources/Endpoints/Authentication/Implementations/BasicAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/BasicAuth.swift @@ -15,15 +15,18 @@ public struct BasicAuth: AuthenticationMethod { /// The password. public let password: String + /// The credentials are immutable, so the header value is encoded once. + private let headerValue: String + public init(username: String, password: String) { self.username = username self.password = password + self.headerValue = "Basic \(Data("\(username):\(password)".utf8).base64EncodedString())" } public func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { var mutableRequest = request - let credentials = Data("\(username):\(password)".utf8).base64EncodedString() - mutableRequest.setValue("Basic \(credentials)", forHTTPHeaderField: Header.authorization.name) + mutableRequest.setValue(headerValue, forHTTPHeaderField: Header.authorization.name) return mutableRequest } } diff --git a/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift b/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift index 77fdef9..d887a74 100644 --- a/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift @@ -16,6 +16,9 @@ public struct HeaderKeyAuth: AuthenticationMethod { /// Set to nil for no prefix. public let prefix: String? + /// The key and prefix are immutable, so the header value is composed once. + private let headerValue: String + /// Creates a header key authentication method. /// /// - Parameters: @@ -30,11 +33,11 @@ public struct HeaderKeyAuth: AuthenticationMethod { self.key = key self.header = header self.prefix = prefix + self.headerValue = prefix.map { "\($0) \(key)" } ?? key } public func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { var mutableRequest = request - let headerValue = prefix.map { "\($0) \(key)" } ?? key mutableRequest.setValue(headerValue, forHTTPHeaderField: header.name) return mutableRequest } diff --git a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift index 0ec7d1b..d5953de 100644 --- a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift @@ -174,12 +174,7 @@ public actor JWTAuth: AuthenticationMethod { /// The existence check and task creation happen in one synchronous stretch of /// actor isolation, so concurrent callers cannot start duplicate refreshes. private func refresh(with refreshToken: String) async throws(AuthenticationError) -> TokenPair { - let refreshTask: Task - if let pendingRefresh { - refreshTask = pendingRefresh - } else { - refreshTask = startRefresh(refreshToken: refreshToken) - } + let refreshTask = pendingRefresh ?? startRefresh(refreshToken: refreshToken) defer { if pendingRefresh == refreshTask { @@ -187,7 +182,13 @@ public actor JWTAuth: AuthenticationMethod { } } - return try await awaitRefresh(refreshTask) + do { + return try await refreshTask.value + } catch let error as AuthenticationError { + throw error + } catch { + throw .refreshFailed(underlying: error) + } } private func startRefresh(refreshToken: String) -> Task { @@ -214,17 +215,6 @@ public actor JWTAuth: AuthenticationMethod { "\(configuration.tokenPrefix) \(accessToken)" } - /// Awaits a refresh task, mapping its untyped failure back to ``AuthenticationError``. - private func awaitRefresh(_ task: Task) async throws(AuthenticationError) -> TokenPair { - do { - return try await task.value - } catch let error as AuthenticationError { - throw error - } catch { - throw .refreshFailed(underlying: error) - } - } - // MARK: - Public Token Management public func setTokens(_ tokens: TokenPair) { diff --git a/Sources/Endpoints/Endpoint+URLRequest.swift b/Sources/Endpoints/Endpoint+URLRequest.swift index ed65a1c..2659c5b 100644 --- a/Sources/Endpoints/Endpoint+URLRequest.swift +++ b/Sources/Endpoints/Endpoint+URLRequest.swift @@ -23,51 +23,43 @@ extension Endpoint { components.path = Self.definition.path.path(with: pathComponents) var urlQueryItems: [URLQueryItem] = [] + var bodyFormItems: [URLQueryItem] = [] + for item in Self.definition.parameters { let value: Any let name: String + let isQuery: Bool switch item { case .query(let queryName, let valuePath): value = parameterComponents[keyPath: valuePath] name = queryName + isQuery = true case .queryValue(let queryName, let queryValue): value = queryValue name = queryName - default: - continue - } - - guard let queryValue = value as? ParameterRepresentable else { - throw EndpointError.invalidQuery(named: name, type: type(of: value)) - } - - if let encodedValue = queryValue.parameterValue { - urlQueryItems.append(URLQueryItem(name: name, value: encodedValue)) - } - } - - var bodyFormItems: [URLQueryItem] = [] - for item in Self.definition.parameters { - - let value: Any - let name: String - switch item { + isQuery = true case .form(let formName, let valuePath): value = parameterComponents[keyPath: valuePath] name = formName + isQuery = false case .formValue(let formName, let formValue): value = formValue name = formName - default: - continue + isQuery = false } - guard let formValue = value as? ParameterRepresentable else { - throw EndpointError.invalidForm(named: name, type: type(of: value)) + guard let parameterValue = value as? ParameterRepresentable else { + throw isQuery + ? EndpointError.invalidQuery(named: name, type: type(of: value)) + : EndpointError.invalidForm(named: name, type: type(of: value)) } - if let encodedValue = formValue.parameterValue { + guard let encodedValue = parameterValue.parameterValue else { continue } + + if isQuery { + urlQueryItems.append(URLQueryItem(name: name, value: encodedValue)) + } else { bodyFormItems.append(URLQueryItem(name: name, value: encodedValue)) } } diff --git a/Sources/Endpoints/Extensions/URLSession+Async.swift b/Sources/Endpoints/Extensions/URLSession+Async.swift index d456ef1..c0c9dab 100644 --- a/Sources/Endpoints/Extensions/URLSession+Async.swift +++ b/Sources/Endpoints/Extensions/URLSession+Async.swift @@ -20,33 +20,15 @@ public extension URLSession { /// Use this when the response body is expected to be `Void` or empty as you would have in a 204. /// - Parameter endpoint: The endpoint instance to be used to make the request func response(with endpoint: T) async throws(T.TaskError) where T.Response == Void { - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - if let mockResponse: T.Response = try await Mocking.shared.handleMock(for: T.self) { - return mockResponse - } - #endif - - _ = try await performRequest(with: endpoint) { (_) throws(T.TaskError) in () } + try await performRequest(with: endpoint) { (_) throws(T.TaskError) in () } } func response(with endpoint: T) async throws(T.TaskError) -> T.Response where T.Response == Data { - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - if let mockResponse = try await Mocking.shared.handleMock(for: T.self) { - return mockResponse - } - #endif - - return try await performRequest(with: endpoint) { (data) throws(T.TaskError) in data } + try await performRequest(with: endpoint) { (data) throws(T.TaskError) in data } } func response(with endpoint: T) async throws(T.TaskError) -> T.Response where T.Response: Decodable { - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - if let mockResponse = try await Mocking.shared.handleMock(for: T.self) { - return mockResponse - } - #endif - - return try await performRequest(with: endpoint) { (data) throws(T.TaskError) in + try await performRequest(with: endpoint) { (data) throws(T.TaskError) in do { return try T.responseDecoder.decode(T.Response.self, from: data) } catch { @@ -61,17 +43,26 @@ public extension URLSession { /// With the default ``NoAuth``, `authenticate` returns the request unchanged and /// `shouldReauthenticate` is always false, so this is a single pass through the /// unauthenticated request path. - private func performRequest( + /// + /// An active mock short-circuits here, ahead of authentication: a mocked request + /// never applies credentials and never enters the retry loop. + private func performRequest( with endpoint: T, - transform: (Data) throws(T.TaskError) -> R - ) async throws(T.TaskError) -> R { + transform: (Data) throws(T.TaskError) -> T.Response + ) async throws(T.TaskError) -> T.Response { + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) + if let mockResponse = try await Mocking.shared.handleMock(for: T.self) { + return mockResponse + } + #endif + let auth = T.auth - let maxRetryAttempts = max(0, auth.maxRetryAttempts) + // The endpoint is immutable, so the unauthenticated request is identical on + // every attempt; only the credentials applied to it change. + let request = try createUrlRequest(for: endpoint) var attempt = 0 while true { - let request = try createUrlRequest(for: endpoint) - let authenticatedRequest: URLRequest do { authenticatedRequest = try await auth.authenticate(request: request) @@ -89,7 +80,7 @@ public extension URLSession { return try transform(data) } catch { - guard attempt < maxRetryAttempts, + guard attempt < auth.retryAttempts, auth.shouldReauthenticate(for: error, response: error.httpResponse) else { throw error } @@ -106,7 +97,7 @@ public extension URLSession { } /// Loads data for the request, mapping `URLSession` failures into the endpoint's ``EndpointTaskError``. - internal func loadData(for urlRequest: URLRequest, endpoint: T.Type) async throws(T.TaskError) -> (data: Data, response: URLResponse) { + private func loadData(for urlRequest: URLRequest, endpoint: T.Type) async throws(T.TaskError) -> (data: Data, response: URLResponse) { do { return try await data(for: urlRequest) } catch { diff --git a/Sources/Endpoints/Extensions/URLSession+Combine.swift b/Sources/Endpoints/Extensions/URLSession+Combine.swift index e075128..3221c51 100644 --- a/Sources/Endpoints/Extensions/URLSession+Combine.swift +++ b/Sources/Endpoints/Extensions/URLSession+Combine.swift @@ -70,12 +70,18 @@ private final class AsyncBridge: @unchecked Se @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) private func endpointPublisher( - performing work: @escaping @Sendable () async -> Result + performing work: @escaping @Sendable () async throws(Failure) -> Output ) -> AnyPublisher { Deferred { () -> AnyPublisher in let bridge = AsyncBridge() return Future { promise in - bridge.begin(promise: promise, work: work) + bridge.begin(promise: promise) { + do throws(Failure) { + return .success(try await work()) + } catch { + return .failure(error) + } + } } .handleEvents(receiveCancel: { bridge.cancel() }) .eraseToAnyPublisher() @@ -95,13 +101,8 @@ public extension URLSession { /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Void { - Endpoints.endpointPublisher { - do throws(T.TaskError) { - try await self.response(with: endpoint) - return .success(()) - } catch { - return .failure(error) - } + Endpoints.endpointPublisher { () throws(T.TaskError) in + try await self.response(with: endpoint) } } @@ -114,12 +115,8 @@ public extension URLSession { /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Data { - Endpoints.endpointPublisher { - do throws(T.TaskError) { - return .success(try await self.response(with: endpoint)) - } catch { - return .failure(error) - } + Endpoints.endpointPublisher { () throws(T.TaskError) in + try await self.response(with: endpoint) } } @@ -132,12 +129,8 @@ public extension URLSession { /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response: Decodable { - Endpoints.endpointPublisher { - do throws(T.TaskError) { - return .success(try await self.response(with: endpoint)) - } catch { - return .failure(error) - } + Endpoints.endpointPublisher { () throws(T.TaskError) in + try await self.response(with: endpoint) } } } diff --git a/Sources/Endpoints/Mocking/Mocking.swift b/Sources/Endpoints/Mocking/Mocking.swift index 7d41da4..477ce49 100644 --- a/Sources/Endpoints/Mocking/Mocking.swift +++ b/Sources/Endpoints/Mocking/Mocking.swift @@ -74,11 +74,7 @@ struct Mocking { /// - body: Closure that configures the mock response /// - test: The test code to execute with mocking enabled func withMock(_ ofType: T.Type, _ body: @Sendable @escaping (MockContinuation) async -> Void, test: @escaping () async throws -> R) async rethrows -> R { - var mocks = Self.current - mocks[ObjectIdentifier(T.self)] = ToReturnWrapper(body) - return try await Self.$current.withValue(mocks) { - try await test() - } + try await withMock(registering: { $0.register(T.self, body) }, test: test) } /// Sets up a mock context for multiple endpoint types and executes the test block within it. diff --git a/Tests/EndpointsTests/AuthenticatedEndpointTests.swift b/Tests/EndpointsTests/AuthenticatedEndpointTests.swift index 5176b07..ba947c1 100644 --- a/Tests/EndpointsTests/AuthenticatedEndpointTests.swift +++ b/Tests/EndpointsTests/AuthenticatedEndpointTests.swift @@ -13,10 +13,6 @@ import FoundationNetworking @Suite("Authenticated Endpoints", .serialized) struct AuthenticatedEndpointTests { - private static func url(_ path: String) -> URL { - URL(string: "https://api.velosmobile.com\(path)")! - } - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) @Test func retriesAfterReauthentication() async throws { @@ -24,8 +20,8 @@ struct AuthenticatedEndpointTests { let successData = try JSONEncoder().encode(AuthTestResponse(value: "ok")) let responses = ResponseQueue([ - (HTTPURLResponse(url: Self.url("/auth/retry"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), - (HTTPURLResponse(url: Self.url("/auth/retry"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + (HTTPURLResponse(url: testURL("/auth/retry"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), + (HTTPURLResponse(url: testURL("/auth/retry"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) ]) TestURLProtocol.register(path: "/auth/retry") { _ in try responses.next() } @@ -46,8 +42,8 @@ struct AuthenticatedEndpointTests { let errorData = try JSONEncoder().encode(AuthTestErrorResponse(message: "unauthorized")) let responses = ResponseQueue([ - (HTTPURLResponse(url: Self.url("/auth/exhaustion"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), - (HTTPURLResponse(url: Self.url("/auth/exhaustion"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) + (HTTPURLResponse(url: testURL("/auth/exhaustion"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), + (HTTPURLResponse(url: testURL("/auth/exhaustion"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) ]) TestURLProtocol.register(path: "/auth/exhaustion") { _ in try responses.next() } @@ -76,7 +72,7 @@ struct AuthenticatedEndpointTests { let errorData = try JSONEncoder().encode(AuthTestErrorResponse(message: "unauthorized")) TestURLProtocol.register(path: "/auth/failing-refresh") { _ in - (HTTPURLResponse(url: Self.url("/auth/failing-refresh"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) + (HTTPURLResponse(url: testURL("/auth/failing-refresh"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) } defer { TestURLProtocol.unregister(path: "/auth/failing-refresh") } @@ -98,8 +94,8 @@ struct AuthenticatedEndpointTests { let successData = try JSONEncoder().encode(AuthTestResponse(value: "ok")) let responses = ResponseQueue([ - (HTTPURLResponse(url: Self.url("/auth/jwt"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), - (HTTPURLResponse(url: Self.url("/auth/jwt"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + (HTTPURLResponse(url: testURL("/auth/jwt"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), + (HTTPURLResponse(url: testURL("/auth/jwt"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) ]) let recorder = RequestRecorder() @@ -149,7 +145,7 @@ struct AuthenticatedEndpointTests { let errorData = try JSONEncoder().encode(AuthTestErrorResponse(message: "unauthorized")) TestURLProtocol.register(path: "/auth/refresh-failure") { _ in - (HTTPURLResponse(url: Self.url("/auth/refresh-failure"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) + (HTTPURLResponse(url: testURL("/auth/refresh-failure"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) } defer { TestURLProtocol.unregister(path: "/auth/refresh-failure") } @@ -178,7 +174,7 @@ struct AuthenticatedEndpointTests { let recorder = RequestRecorder() TestURLProtocol.register(path: "/auth/proactive") { request in recorder.record(request) - return (HTTPURLResponse(url: Self.url("/auth/proactive"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + return (HTTPURLResponse(url: testURL("/auth/proactive"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) } defer { TestURLProtocol.unregister(path: "/auth/proactive") } @@ -203,7 +199,7 @@ struct AuthenticatedEndpointTests { let recorder = RequestRecorder() TestURLProtocol.register(path: "/auth/static-key") { request in recorder.record(request) - return (HTTPURLResponse(url: Self.url("/auth/static-key"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) + return (HTTPURLResponse(url: testURL("/auth/static-key"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) } defer { TestURLProtocol.unregister(path: "/auth/static-key") } @@ -233,7 +229,7 @@ struct AuthenticatedEndpointTests { let recorder = RequestRecorder() TestURLProtocol.register(path: "/auth/inherited") { request in recorder.record(request) - return (HTTPURLResponse(url: Self.url("/auth/inherited"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + return (HTTPURLResponse(url: testURL("/auth/inherited"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) } defer { TestURLProtocol.unregister(path: "/auth/inherited") } @@ -251,7 +247,7 @@ struct AuthenticatedEndpointTests { let recorder = RequestRecorder() TestURLProtocol.register(path: "/auth/opted-out") { request in recorder.record(request) - return (HTTPURLResponse(url: Self.url("/auth/opted-out"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + return (HTTPURLResponse(url: testURL("/auth/opted-out"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) } defer { TestURLProtocol.unregister(path: "/auth/opted-out") } @@ -269,7 +265,7 @@ struct AuthenticatedEndpointTests { let recorder = RequestRecorder() TestURLProtocol.register(path: "/auth/overridden") { request in recorder.record(request) - return (HTTPURLResponse(url: Self.url("/auth/overridden"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + return (HTTPURLResponse(url: testURL("/auth/overridden"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) } defer { TestURLProtocol.unregister(path: "/auth/overridden") } @@ -279,6 +275,7 @@ struct AuthenticatedEndpointTests { #expect(recorder.all().first?.value(forHTTPHeaderField: Header.authorization.name) == nil) } + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) @Test func endpointsOnAServerShareOneAuthInstance() { // Identity matters: a shared instance is what lets refreshes coalesce across endpoints. @@ -289,15 +286,6 @@ struct AuthenticatedEndpointTests { // MARK: - Servers -/// Unauthenticated, for endpoints that declare their own auth. -struct AuthTestServer: ServerDefinition { - var baseUrls: [Environments: URL] { - [.production: URL(string: "https://api.velosmobile.com")!] - } - - static var defaultEnvironment: Environments { .production } -} - /// Declares a server-wide authentication method inherited by its endpoints. struct KeyedServer: ServerDefinition { static let auth = HeaderKeyAuth(key: "server-key") @@ -323,16 +311,8 @@ struct SharedAuthServer: ServerDefinition { // MARK: - Endpoints -struct AuthTestResponse: Codable, Sendable { - let value: String -} - -struct AuthTestErrorResponse: Codable, Sendable, Equatable { - let message: String -} - struct RetryEndpoint: Endpoint { - typealias Server = AuthTestServer + typealias Server = TestServer typealias Response = AuthTestResponse typealias ErrorResponse = AuthTestErrorResponse @@ -341,7 +321,7 @@ struct RetryEndpoint: Endpoint { } struct ExhaustionEndpoint: Endpoint { - typealias Server = AuthTestServer + typealias Server = TestServer typealias Response = AuthTestResponse typealias ErrorResponse = AuthTestErrorResponse @@ -350,7 +330,7 @@ struct ExhaustionEndpoint: Endpoint { } struct FailingRefreshEndpoint: Endpoint { - typealias Server = AuthTestServer + typealias Server = TestServer typealias Response = AuthTestResponse typealias ErrorResponse = AuthTestErrorResponse @@ -359,7 +339,7 @@ struct FailingRefreshEndpoint: Endpoint { } struct JWTEndpoint: Endpoint { - typealias Server = AuthTestServer + typealias Server = TestServer typealias Response = AuthTestResponse typealias ErrorResponse = AuthTestErrorResponse @@ -372,7 +352,7 @@ struct JWTEndpoint: Endpoint { } struct UnauthenticatedJWTEndpoint: Endpoint { - typealias Server = AuthTestServer + typealias Server = TestServer typealias Response = AuthTestResponse typealias ErrorResponse = AuthTestErrorResponse @@ -384,7 +364,7 @@ struct UnauthenticatedJWTEndpoint: Endpoint { } struct RefreshFailureEndpoint: Endpoint { - typealias Server = AuthTestServer + typealias Server = TestServer typealias Response = AuthTestResponse typealias ErrorResponse = AuthTestErrorResponse @@ -398,7 +378,7 @@ struct RefreshFailureEndpoint: Endpoint { } struct ProactiveEndpoint: Endpoint { - typealias Server = AuthTestServer + typealias Server = TestServer typealias Response = AuthTestResponse typealias ErrorResponse = AuthTestErrorResponse @@ -411,7 +391,7 @@ struct ProactiveEndpoint: Endpoint { } struct StaticKeyEndpoint: Endpoint { - typealias Server = AuthTestServer + typealias Server = TestServer typealias Response = AuthTestResponse typealias ErrorResponse = AuthTestErrorResponse diff --git a/Tests/EndpointsTests/AuthenticationTests.swift b/Tests/EndpointsTests/AuthenticationTests.swift index 9117813..2b7e42d 100644 --- a/Tests/EndpointsTests/AuthenticationTests.swift +++ b/Tests/EndpointsTests/AuthenticationTests.swift @@ -326,22 +326,6 @@ struct AuthenticationTests { } } -actor Gate { - private var isOpen = false - private var waiters: [CheckedContinuation] = [] - - func wait() async { - if isOpen { return } - await withCheckedContinuation { waiters.append($0) } - } - - func open() { - isOpen = true - waiters.forEach { $0.resume() } - waiters.removeAll() - } -} - actor RefreshCounter { private var count = 0 diff --git a/Tests/EndpointsTests/CombineAuthenticationTests.swift b/Tests/EndpointsTests/CombineAuthenticationTests.swift index 92492a9..19d0f97 100644 --- a/Tests/EndpointsTests/CombineAuthenticationTests.swift +++ b/Tests/EndpointsTests/CombineAuthenticationTests.swift @@ -10,10 +10,6 @@ import Combine @Suite("Combine Authentication", .serialized) struct CombineAuthenticationTests { - private static func url(_ path: String) -> URL { - URL(string: "https://api.velosmobile.com\(path)")! - } - @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) @Test func publisherAppliesEndpointAuthentication() async throws { @@ -22,7 +18,7 @@ struct CombineAuthenticationTests { let recorder = RequestRecorder() TestURLProtocol.register(path: "/combine/keyed") { request in recorder.record(request) - return (HTTPURLResponse(url: Self.url("/combine/keyed"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + return (HTTPURLResponse(url: testURL("/combine/keyed"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) } defer { TestURLProtocol.unregister(path: "/combine/keyed") } @@ -46,8 +42,8 @@ struct CombineAuthenticationTests { let successData = try JSONEncoder().encode(AuthTestResponse(value: "ok")) let responses = ResponseQueue([ - (HTTPURLResponse(url: Self.url("/combine/jwt"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), - (HTTPURLResponse(url: Self.url("/combine/jwt"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + (HTTPURLResponse(url: testURL("/combine/jwt"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData), + (HTTPURLResponse(url: testURL("/combine/jwt"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) ]) let recorder = RequestRecorder() @@ -114,35 +110,19 @@ struct CombineAuthenticationTests { .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) // Wait until the request is genuinely in flight inside authenticate. - await CancellationProbe.shared.waitForStart() + await CancellationProbe.started.wait() cancellable.cancel() - var observed = false - for _ in 0..<40 { - if await CancellationProbe.shared.didObserveCancellation { - observed = true - break - } - try await Task.sleep(nanoseconds: 50_000_000) - } - + let observed = await CancellationProbe.observedCancellation.wait(upTo: 2_000_000_000) #expect(observed, "Cancelling the subscription should cancel the underlying task") } } // MARK: - Fixtures -struct CombineTestServer: ServerDefinition { - var baseUrls: [Environments: URL] { - [.production: URL(string: "https://api.velosmobile.com")!] - } - - static var defaultEnvironment: Environments { .production } -} - struct CombineKeyedEndpoint: Endpoint { - typealias Server = CombineTestServer + typealias Server = TestServer typealias Response = AuthTestResponse typealias ErrorResponse = AuthTestErrorResponse @@ -151,7 +131,7 @@ struct CombineKeyedEndpoint: Endpoint { } struct CombineJWTEndpoint: Endpoint { - typealias Server = CombineTestServer + typealias Server = TestServer typealias Response = AuthTestResponse typealias ErrorResponse = AuthTestErrorResponse @@ -164,7 +144,7 @@ struct CombineJWTEndpoint: Endpoint { } struct CombineUnauthenticatedJWTEndpoint: Endpoint { - typealias Server = CombineTestServer + typealias Server = TestServer typealias Response = AuthTestResponse typealias ErrorResponse = AuthTestErrorResponse @@ -176,7 +156,7 @@ struct CombineUnauthenticatedJWTEndpoint: Endpoint { } struct CombineCancelEndpoint: Endpoint { - typealias Server = CombineTestServer + typealias Server = TestServer typealias Response = AuthTestResponse typealias ErrorResponse = AuthTestErrorResponse @@ -188,13 +168,13 @@ struct CombineCancelEndpoint: Endpoint { /// the cancellation actually reached the task. struct ProbeAuth: AuthenticationMethod { func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { - await CancellationProbe.shared.markStarted() + await CancellationProbe.started.open() do { try await Task.sleep(nanoseconds: 3_000_000_000) } catch { // Task.sleep throws when the enclosing task is cancelled. - await CancellationProbe.shared.markCancelled() + await CancellationProbe.observedCancellation.open() throw .custom(underlying: error) } @@ -202,26 +182,10 @@ struct ProbeAuth: AuthenticationMethod { } } -actor CancellationProbe { - static let shared = CancellationProbe() - - private var started = false - private var startWaiters: [CheckedContinuation] = [] - private(set) var didObserveCancellation = false - - func markStarted() { - started = true - startWaiters.forEach { $0.resume() } - startWaiters.removeAll() - } - - func waitForStart() async { - if started { return } - await withCheckedContinuation { startWaiters.append($0) } - } - - func markCancelled() { - didObserveCancellation = true - } +enum CancellationProbe { + /// Opens once `authenticate` is suspended and the request is genuinely in flight. + static let started = Gate() + /// Opens only if the in-flight task actually observes its own cancellation. + static let observedCancellation = Gate() } #endif diff --git a/Tests/EndpointsTests/TestTransport.swift b/Tests/EndpointsTests/TestTransport.swift index 7e76398..77b8524 100644 --- a/Tests/EndpointsTests/TestTransport.swift +++ b/Tests/EndpointsTests/TestTransport.swift @@ -87,6 +87,58 @@ final class ResponseQueue: @unchecked Sendable { } } +/// Builds a URL on the shared test server's base for the given path. +func testURL(_ path: String) -> URL { + URL(string: "https://api.velosmobile.com\(path)")! +} + +/// Response shapes shared by the endpoint fixtures across the transport and auth suites. +struct AuthTestResponse: Codable, Sendable { + let value: String +} + +struct AuthTestErrorResponse: Codable, Sendable, Equatable { + let message: String +} + +/// A one-shot latch: `wait()` suspends until some other task calls `open()`. +actor Gate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + var isOpened: Bool { isOpen } + + func wait() async { + if isOpen { return } + await withCheckedContinuation { waiters.append($0) } + } + + func open() { + isOpen = true + waiters.forEach { $0.resume() } + waiters.removeAll() + } +} + +extension Gate { + /// Waits up to `nanoseconds` for the gate to open, returning whether it did. + /// + /// Use this when a test asserts that something *will* happen: an unbounded `wait()` + /// would hang the suite instead of failing when the behavior regresses. + nonisolated func wait(upTo nanoseconds: UInt64) async -> Bool { + let pollInterval: UInt64 = 25_000_000 + var elapsed: UInt64 = 0 + + while elapsed < nanoseconds { + if await isOpened { return true } + try? await Task.sleep(nanoseconds: pollInterval) + elapsed += pollInterval + } + + return await isOpened + } +} + final class RequestRecorder: @unchecked Sendable { private var requests: [URLRequest] = [] private let lock = NSLock() From 1b6ec84869a2ab0188a074acae9776dfb69aa9dd Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 27 Jul 2026 11:48:36 -0700 Subject: [PATCH 21/23] Fix Linux build: move Gate out of the Apple-gated test file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The simplify pass moved the Gate latch into TestTransport.swift, which is wrapped in #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS), but AuthenticationTests.swift is not platform-gated and uses Gate — so Linux failed to compile while macOS passed. Gate now lives in its own ungated file. Verified no other symbol defined in a platform-gated test file is referenced from an ungated one. --- Tests/EndpointsTests/Gate.swift | 42 ++++++++++++++++++++++++ Tests/EndpointsTests/TestTransport.swift | 38 --------------------- 2 files changed, 42 insertions(+), 38 deletions(-) create mode 100644 Tests/EndpointsTests/Gate.swift diff --git a/Tests/EndpointsTests/Gate.swift b/Tests/EndpointsTests/Gate.swift new file mode 100644 index 0000000..729a9fd --- /dev/null +++ b/Tests/EndpointsTests/Gate.swift @@ -0,0 +1,42 @@ +import Foundation + +/// A one-shot latch: `wait()` suspends until some other task calls `open()`. +/// +/// Lives outside the Apple-platform-gated test transport because tests that run on +/// every platform use it too. +actor Gate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + var isOpened: Bool { isOpen } + + func wait() async { + if isOpen { return } + await withCheckedContinuation { waiters.append($0) } + } + + func open() { + isOpen = true + waiters.forEach { $0.resume() } + waiters.removeAll() + } +} + +extension Gate { + /// Waits up to `nanoseconds` for the gate to open, returning whether it did. + /// + /// Use this when a test asserts that something *will* happen: an unbounded `wait()` + /// would hang the suite instead of failing when the behavior regresses. + nonisolated func wait(upTo nanoseconds: UInt64) async -> Bool { + let pollInterval: UInt64 = 25_000_000 + var elapsed: UInt64 = 0 + + while elapsed < nanoseconds { + if await isOpened { return true } + try? await Task.sleep(nanoseconds: pollInterval) + elapsed += pollInterval + } + + return await isOpened + } +} diff --git a/Tests/EndpointsTests/TestTransport.swift b/Tests/EndpointsTests/TestTransport.swift index 77b8524..b6690bd 100644 --- a/Tests/EndpointsTests/TestTransport.swift +++ b/Tests/EndpointsTests/TestTransport.swift @@ -101,44 +101,6 @@ struct AuthTestErrorResponse: Codable, Sendable, Equatable { let message: String } -/// A one-shot latch: `wait()` suspends until some other task calls `open()`. -actor Gate { - private var isOpen = false - private var waiters: [CheckedContinuation] = [] - - var isOpened: Bool { isOpen } - - func wait() async { - if isOpen { return } - await withCheckedContinuation { waiters.append($0) } - } - - func open() { - isOpen = true - waiters.forEach { $0.resume() } - waiters.removeAll() - } -} - -extension Gate { - /// Waits up to `nanoseconds` for the gate to open, returning whether it did. - /// - /// Use this when a test asserts that something *will* happen: an unbounded `wait()` - /// would hang the suite instead of failing when the behavior regresses. - nonisolated func wait(upTo nanoseconds: UInt64) async -> Bool { - let pollInterval: UInt64 = 25_000_000 - var elapsed: UInt64 = 0 - - while elapsed < nanoseconds { - if await isOpened { return true } - try? await Task.sleep(nanoseconds: pollInterval) - elapsed += pollInterval - } - - return await isOpened - } -} - final class RequestRecorder: @unchecked Sendable { private var requests: [URLRequest] = [] private let lock = NSLock() From 90c7a34c6362c8a1ed7a534d4ff0f57c1780bd6e Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 27 Jul 2026 11:59:27 -0700 Subject: [PATCH 22/23] Turn two documented auth footguns into diagnosable failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were previously prevented only by prose in the README, and both fail silently or by hanging — the two hardest failure modes to diagnose. Shared-instance requirement: JWTAuth keeps tokens and its in-flight refresh on the instance, so declaring auth as a computed static var instead of a static let hands out a fresh actor per request, losing tokens and turning coalescing into a refresh storm with no error to point at. Debug builds now assert once per endpoint type, naming the type and the fix. Value-type methods are skipped (a fresh copy is harmless) and the check compiles out of release builds. Refresh reentrancy: a refreshHandler that requests an endpoint authenticated by the same JWTAuth deadlocks — the request waits on the refresh that waits on the handler. A task-local marker set around the handler lets authenticate detect the reentrant call and throw RefreshReentrancyError, whose message states the fix. Also reconciles the README's stated requirements with Package.swift: the package builds against iOS 13 / macOS 10.15, while the async and Combine request APIs (and therefore authentication) require macOS 12. Verified on macOS (86 tests) and in the Linux swift:6.0 container (40). --- README.md | 7 ++- .../Authentication/AuthenticationError.swift | 15 +++++ .../AuthenticationStability.swift | 48 ++++++++++++++ .../Implementations/JWTAuth.swift | 24 ++++++- .../Extensions/URLSession+Async.swift | 4 ++ .../AuthenticatedEndpointTests.swift | 63 +++++++++++++++++++ 6 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 Sources/Endpoints/Authentication/AuthenticationStability.swift diff --git a/README.md b/README.md index 59e5adf..35e1efc 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ struct MetricsEndpoint: Endpoint { } ``` -Declare the method as a `static let` so all endpoints on a server share one instance. That shared instance is what allows a stateful method like `JWTAuth` to coalesce concurrent token refreshes across every endpoint. +Declare the method as a `static let` so all endpoints on a server share one instance. That shared instance is what allows a stateful method like `JWTAuth` to coalesce concurrent token refreshes across every endpoint — a computed `static var` would hand out a fresh instance per request, losing tokens. Debug builds assert if that happens, so the mistake surfaces in development rather than as mysterious re-authentication in production. Servers that declare no `auth` use `NoAuth`, so existing endpoints keep working unchanged. @@ -179,7 +179,7 @@ struct ApiServer: ServerDefinition { } ``` -> **Important:** the refresh endpoint must not be authenticated by the same `JWTAuth` — the request would wait on the very refresh that is waiting on it, deadlocking the task. Give it `static var auth: NoAuth { NoAuth() }`; it authenticates with the refresh token, not the access token. +> **Important:** the refresh endpoint must not be authenticated by the same `JWTAuth` — the request would wait on the very refresh that is waiting on it. Give it `static var auth: NoAuth { NoAuth() }`; it authenticates with the refresh token, not the access token. If you do hit this, the request fails with a `RefreshReentrancyError` explaining the fix rather than hanging. After a login or logout, update the tokens with `await ApiServer.auth.setTokens(_:)` or `await ApiServer.auth.clearTokens()`. @@ -270,7 +270,8 @@ To browse more complex examples, make sure to check out the [Examples](https://g ## Requirements - Swift 6.0+ -- iOS 15.0+ / macOS 12.0+ / tvOS 15.0+ / watchOS 8.0+ +- iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 6.0+ to build endpoints and create `URLRequest`s +- macOS 12.0+ for the async/await and Combine request APIs (`response(with:)`, `endpointPublisher(with:)`), and therefore for authentication ## Installation diff --git a/Sources/Endpoints/Authentication/AuthenticationError.swift b/Sources/Endpoints/Authentication/AuthenticationError.swift index 397bedb..bef7af2 100644 --- a/Sources/Endpoints/Authentication/AuthenticationError.swift +++ b/Sources/Endpoints/Authentication/AuthenticationError.swift @@ -21,6 +21,21 @@ public enum AuthenticationError: Error, Sendable { case custom(underlying: Error) } +/// Thrown when a request authenticated by a ``JWTAuth`` is made from inside that same +/// instance's refresh handler, which would otherwise deadlock. +public struct RefreshReentrancyError: Error, CustomStringConvertible { + let authType: Any.Type + + public var description: String { + """ + A request authenticated by this \(authType) was made from inside its own \ + refreshHandler, which would deadlock: the request waits for the refresh that is \ + waiting for the handler. Give the refresh endpoint `static var auth: NoAuth \ + { NoAuth() }`, or perform the refresh with a plain URLSession. + """ + } +} + extension AuthenticationError: CustomNSError { public var errorUserInfo: [String: Any] { switch self { diff --git a/Sources/Endpoints/Authentication/AuthenticationStability.swift b/Sources/Endpoints/Authentication/AuthenticationStability.swift new file mode 100644 index 0000000..75896dd --- /dev/null +++ b/Sources/Endpoints/Authentication/AuthenticationStability.swift @@ -0,0 +1,48 @@ +import Foundation + +#if DEBUG + +/// Debug-only check that an endpoint's ``Endpoint/auth`` yields one shared instance. +/// +/// Stateful methods such as ``JWTAuth`` keep their tokens and in-flight refresh on the +/// instance, so they only work when every request sees the *same* one. Declaring +/// `static let auth = JWTAuth(...)` does that; a computed `static var auth: JWTAuth +/// { JWTAuth(...) }` silently hands out a fresh actor per access, which loses tokens +/// and turns refresh coalescing into a refresh storm — with no error to point at. +/// +/// This catches that during development. It runs once per endpoint type and compiles +/// out of release builds entirely. +enum AuthenticationStability { + private static let lock = NSLock() + nonisolated(unsafe) private static var verified: Set = [] + + static func verifySharedInstance(for endpointType: T.Type) { + // Value-type methods are stateless in practice, so a fresh copy is harmless. + guard T.Auth.self is AnyObject.Type else { return } + + let key = ObjectIdentifier(T.self) + + lock.lock() + let alreadyVerified = verified.contains(key) + if !alreadyVerified { + verified.insert(key) + } + lock.unlock() + + guard !alreadyVerified else { return } + + let first = T.auth as AnyObject + let second = T.auth as AnyObject + + assert( + first === second, + """ + \(T.self).auth returns a new \(T.Auth.self) on each access. Declare it as a \ + `static let` so every request shares one instance — a stateful authentication \ + method cannot retain credentials or coalesce refreshes otherwise. + """ + ) + } +} + +#endif diff --git a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift index d5953de..5a43573 100644 --- a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift @@ -96,6 +96,13 @@ public actor JWTAuth: AuthenticationMethod { private var currentTokens: TokenPair? private var pendingRefresh: Task? + /// Instances whose `refreshHandler` is running in the current task tree. + /// + /// Task-local, so it propagates into any request the handler makes and lets + /// ``authenticate(request:)`` recognize a reentrant call. + @TaskLocal + private static var refreshingInstances: Set = [] + // MARK: - Configuration & Handlers private nonisolated let configuration: Configuration @@ -122,6 +129,13 @@ public actor JWTAuth: AuthenticationMethod { // MARK: - AuthenticationMethod public func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { + // A request made from inside this instance's own refreshHandler would wait on + // the very refresh that is waiting on it. Fail with a diagnosable error instead + // of deadlocking the task. + if Self.refreshingInstances.contains(ObjectIdentifier(self)) { + throw .custom(underlying: RefreshReentrancyError(authType: Self.self)) + } + if let tokens = currentTokens, pendingRefresh != nil || tokens.isExpiring(within: configuration.expiryLeeway) { do { @@ -195,10 +209,18 @@ public actor JWTAuth: AuthenticationMethod { let refreshHandler = self.refreshHandler let onTokensUpdated = self.onTokensUpdated let onRefreshFailed = self.onRefreshFailed + // Captured as a value so the refresh task does not retain the actor. + let identity = ObjectIdentifier(self) let refreshTask = Task { do { - let newTokens = try await refreshHandler(refreshToken) + // Marks this instance as refreshing for the duration of the handler, so a + // request that reenters authenticate from inside it can be detected. + let newTokens = try await Self.$refreshingInstances.withValue( + Self.refreshingInstances.union([identity]) + ) { + try await refreshHandler(refreshToken) + } await onTokensUpdated?(newTokens) return newTokens } catch { diff --git a/Sources/Endpoints/Extensions/URLSession+Async.swift b/Sources/Endpoints/Extensions/URLSession+Async.swift index c0c9dab..8eb339b 100644 --- a/Sources/Endpoints/Extensions/URLSession+Async.swift +++ b/Sources/Endpoints/Extensions/URLSession+Async.swift @@ -56,6 +56,10 @@ public extension URLSession { } #endif + #if DEBUG + AuthenticationStability.verifySharedInstance(for: T.self) + #endif + let auth = T.auth // The endpoint is immutable, so the unauthenticated request is identical on // every attempt; only the credentials applied to it change. diff --git a/Tests/EndpointsTests/AuthenticatedEndpointTests.swift b/Tests/EndpointsTests/AuthenticatedEndpointTests.swift index ba947c1..b8bb8cd 100644 --- a/Tests/EndpointsTests/AuthenticatedEndpointTests.swift +++ b/Tests/EndpointsTests/AuthenticatedEndpointTests.swift @@ -191,6 +191,43 @@ struct AuthenticatedEndpointTests { #expect(sentAuthorization == ["Bearer new-access"]) } + /// A refresh handler that calls back through an endpoint authenticated by the same + /// JWTAuth would deadlock; it must surface as an error instead of hanging. + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func refreshHandlerReenteringItsOwnAuthFailsInsteadOfDeadlocking() async throws { + let errorData = try JSONEncoder().encode(AuthTestErrorResponse(message: "unauthorized")) + let successData = try JSONEncoder().encode(AuthTestResponse(value: "ok")) + + TestURLProtocol.register(path: "/auth/reentrant") { _ in + (HTTPURLResponse(url: testURL("/auth/reentrant"), statusCode: 401, httpVersion: nil, headerFields: nil)!, errorData) + } + TestURLProtocol.register(path: "/auth/reentrant-refresh") { _ in + (HTTPURLResponse(url: testURL("/auth/reentrant-refresh"), statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + } + defer { + TestURLProtocol.unregister(path: "/auth/reentrant") + TestURLProtocol.unregister(path: "/auth/reentrant-refresh") + } + + await ReentrantEndpoint.auth.setTokens(.init(accessToken: "old", refreshToken: "refresh")) + + do { + _ = try await TestURLProtocol.makeSession().response(with: ReentrantEndpoint()) + Issue.record("Expected the reentrant refresh to fail") + } catch { + // The refresh handler's own request failed with the reentrancy error, and + // that failure is what the refresh reports back. + guard case .authenticationError(.refreshFailed(let underlying)) = error, + let handlerError = underlying as? ReentrantRefreshEndpoint.TaskError, + case .authenticationError(.custom(let reentrancy)) = handlerError, + reentrancy is RefreshReentrancyError else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) @Test func staticAuthDoesNotRetryOn401() async throws { @@ -390,6 +427,32 @@ struct ProactiveEndpoint: Endpoint { static let definition: Definition = Definition(method: .get, path: "auth/proactive") } +/// Its refresh handler requests `ReentrantRefreshEndpoint`, which is authenticated by +/// the very same JWTAuth — the deadlock shape the reentrancy guard detects. +struct ReentrantEndpoint: Endpoint { + typealias Server = TestServer + typealias Response = AuthTestResponse + typealias ErrorResponse = AuthTestErrorResponse + + static let auth = JWTAuth(initialTokens: nil) { _ in + _ = try await TestURLProtocol.makeSession().response(with: ReentrantRefreshEndpoint()) + return JWTAuth.TokenPair(accessToken: "new", refreshToken: "new-refresh") + } + + static let definition: Definition = Definition(method: .get, path: "auth/reentrant") +} + +/// Deliberately shares `ReentrantEndpoint`'s auth instead of opting out with NoAuth. +struct ReentrantRefreshEndpoint: Endpoint { + typealias Server = TestServer + typealias Response = AuthTestResponse + typealias ErrorResponse = AuthTestErrorResponse + + static var auth: JWTAuth { ReentrantEndpoint.auth } + + static let definition: Definition = Definition(method: .get, path: "auth/reentrant-refresh") +} + struct StaticKeyEndpoint: Endpoint { typealias Server = TestServer typealias Response = AuthTestResponse From d9a450f2548c2a9ccb0b41f6ae8282b47d8d0699 Mon Sep 17 00:00:00 2001 From: Zac White Date: Mon, 27 Jul 2026 16:50:26 -0700 Subject: [PATCH 23/23] Supply environment and credentials per request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration surfaced the limitation this design always had: environment and authentication were resolved from static/global state, so two client objects targeting different deployments with different credentials could not coexist. The environment was process-global per server type, and the auth instance came from the endpoint's static declaration. Both are now per-request parameters that default to those declarations: response(with: endpoint, environment: T.Server.Environments = T.Server.defaultEnvironment, auth: any AuthenticationMethod = T.auth) Because the defaults come from what endpoints already declare, this is purely additive — existing endpoint declarations and call sites are unchanged, and the single-client case still passes nothing. Clients that need their own context pass it, and two of them can issue requests concurrently without interfering. Everything the declarative model bought is retained, since the static declaration is now the default rather than the only source: per-endpoint opt-out for login and refresh endpoints, the endpointTask NoAuth gate, the debug shared-instance assertion, and JWT refresh coalescing (a client holds one JWTAuth and passes it, so sharing follows the account). Removed: ServerDefinition.environment and its EnvironmentStorage backing — the mutable global that made two environments impossible. defaultEnvironment remains as the parameter's default. urlRequest() gains an `in:` parameter, also defaulted. The same parameters are on endpointPublisher. endpointTask takes environment but stays NoAuth-gated: it returns a URLSessionDataTask synchronously and cannot await authentication. Verified on macOS (88 tests) and in the Linux swift:6.0 container (40), including a test that interleaves two clients with different environments and credentials and asserts every request pairs the right host with the right key. --- README.md | 33 ++++++- Sources/Endpoints/Endpoint+URLRequest.swift | 8 +- Sources/Endpoints/Endpoints.docc/Examples.md | 11 +-- Sources/Endpoints/Endpoints.docc/Mocking.md | 17 ++-- .../Extensions/URLSession+Async.swift | 52 ++++++++--- .../Extensions/URLSession+Combine.swift | 24 +++-- .../Extensions/URLSession+Endpoints.swift | 19 ++-- Sources/Endpoints/Server.swift | 49 ++-------- .../AuthenticatedEndpointTests.swift | 90 +++++++++++++++++++ Tests/EndpointsTests/EndpointsTests.swift | 58 ++++++------ 10 files changed, 243 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 35e1efc..2a1ebb0 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,34 @@ URLSession.shared.endpointPublisher(with: MyEndpoint()) .store(in: &cancellables) ``` -Notice that the API no longer requires passing an environment - it's handled automatically by the server definition. +Notice that the common case requires no environment or credentials at the call site — both default to what the server and endpoint declare. + +### Runtime context: environment and credentials + +When a request needs different context than the declarations provide — most often because your app talks to more than one deployment or account at once — pass it per request: + +```swift +let response = try await URLSession.shared.response( + with: ProfileEndpoint(), + environment: client.environment, + auth: client.auth +) +``` + +Both parameters default to the endpoint's declarations, so this is opt-in and existing call sites are unaffected. Because they are per-request values rather than global state, two clients with different environments *and* different credentials can issue requests concurrently without interfering: + +```swift +struct WavelikeClient { + let environment: ApiServer.Environments + let auth: JWTAuth // one instance per client, so its refreshes coalesce + + func profile() async throws -> ProfileEndpoint.Response { + try await URLSession.shared.response(with: ProfileEndpoint(), environment: environment, auth: auth) + } +} +``` + +The same parameters are available on `endpointPublisher(with:)`. `endpointTask(with:)` accepts `environment:` but remains restricted to unauthenticated endpoints, since it returns a `URLSessionDataTask` synchronously and cannot await authentication. ### Async/Await @@ -307,8 +334,8 @@ Full documentation is available in Xcode (Product > Build Documentation) and inc If you're upgrading from version 0.4.0 or earlier, the main changes are: 1. **ServerDefinition replaces EnvironmentType** - Define your environments in a `ServerDefinition` conforming type -2. **No more environment parameter** - Remove `in: .production` from all API calls -3. **Add Server typealias** - Add `typealias Server = YourServer` to your endpoints +2. **Add Server typealias** - Add `typealias Server = YourServer` to your endpoints +3. **Environment is per request, not global** - `ApiServer.environment = .staging` is gone. Pass `environment:` to the request methods, or rely on the server's `defaultEnvironment`. This is what allows two clients to use different environments at the same time. 4. **Swift 6.0 required** - Update your Swift toolchain See the [Migration Guide](https://github.com/velos/Endpoints/wiki/Migration) for detailed instructions. diff --git a/Sources/Endpoints/Endpoint+URLRequest.swift b/Sources/Endpoints/Endpoint+URLRequest.swift index 2659c5b..c719f0d 100644 --- a/Sources/Endpoints/Endpoint+URLRequest.swift +++ b/Sources/Endpoints/Endpoint+URLRequest.swift @@ -15,9 +15,13 @@ import FoundationNetworking extension Endpoint { /// Generates a `URLRequest` given the associated request value. + /// - Parameter environment: The environment whose base URL the request is built against. + /// Defaults to the server's ``ServerDefinition/defaultEnvironment``. /// - Throws: An ``EndpointError`` which describes the error filling in data to the associated ``Definition``. /// - Returns: A `URLRequest` ready for requesting with all values from `self` filled in according to the associated ``Endpoint``. - public func urlRequest() throws(EndpointError) -> URLRequest { + public func urlRequest( + in environment: Server.Environments = Server.defaultEnvironment + ) throws(EndpointError) -> URLRequest { var components = URLComponents() components.path = Self.definition.path.path(with: pathComponents) @@ -82,7 +86,7 @@ extension Endpoint { } let server = Self.definition.server - let baseUrl = server.baseUrls[type(of: server).environment] + let baseUrl = server.baseUrls[environment] guard let baseUrl else { throw EndpointError.misconfiguredServer(server: Self.definition.server) diff --git a/Sources/Endpoints/Endpoints.docc/Examples.md b/Sources/Endpoints/Endpoints.docc/Examples.md index 3ed96d5..7e60528 100644 --- a/Sources/Endpoints/Endpoints.docc/Examples.md +++ b/Sources/Endpoints/Endpoints.docc/Examples.md @@ -75,15 +75,16 @@ struct CustomServer: ServerDefinition { ### Changing Environments -To switch environments at runtime, set the environment on the server type: +Select the environment when performing a request: ```swift -// Switch to staging environment -ApiServer.environment = .staging - -// All subsequent requests will use the staging URL +let response = try await URLSession.shared.response(with: MyEndpoint(), environment: .staging) ``` +Because the environment is a per-request value rather than global state, different parts +of an app — or two clients talking to different deployments — can use different +environments at the same time. + --- ## Endpoint Examples diff --git a/Sources/Endpoints/Endpoints.docc/Mocking.md b/Sources/Endpoints/Endpoints.docc/Mocking.md index 57a7cdd..8363d0d 100644 --- a/Sources/Endpoints/Endpoints.docc/Mocking.md +++ b/Sources/Endpoints/Endpoints.docc/Mocking.md @@ -339,19 +339,18 @@ struct UserEndpointTests { } ``` -### 4. Reset Environment After Tests +### 4. Select the Environment Per Request -If your tests change the server environment, reset it afterward: +The environment is a per-request value, so tests never need to save and restore global +state — pass the one you want and nothing leaks into other tests: ```swift @Test func testStagingEnvironment() async throws { - let originalEnvironment = ApiServer.environment - ApiServer.environment = .staging - - defer { - ApiServer.environment = originalEnvironment - } - + let response = try await URLSession.shared.response( + with: MyEndpoint(), + environment: .staging + ) + // Test code... } ``` diff --git a/Sources/Endpoints/Extensions/URLSession+Async.swift b/Sources/Endpoints/Extensions/URLSession+Async.swift index 8eb339b..88b1ab3 100644 --- a/Sources/Endpoints/Extensions/URLSession+Async.swift +++ b/Sources/Endpoints/Extensions/URLSession+Async.swift @@ -15,20 +15,47 @@ import FoundationNetworking @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) public extension URLSession { - /// Perform the request for the endpoint on the given environment. + /// Perform the request for the endpoint. /// /// Use this when the response body is expected to be `Void` or empty as you would have in a 204. - /// - Parameter endpoint: The endpoint instance to be used to make the request - func response(with endpoint: T) async throws(T.TaskError) where T.Response == Void { - try await performRequest(with: endpoint) { (_) throws(T.TaskError) in () } + /// - Parameters: + /// - endpoint: The endpoint instance to be used to make the request + /// - environment: The environment to resolve the base URL against. Defaults to the + /// server's ``ServerDefinition/defaultEnvironment``. + /// - auth: The credentials to authenticate with. Defaults to the endpoint's + /// declared ``Endpoint/auth``. + func response( + with endpoint: T, + environment: T.Server.Environments = T.Server.defaultEnvironment, + auth: any AuthenticationMethod = T.auth + ) async throws(T.TaskError) where T.Response == Void { + try await performRequest(with: endpoint, environment: environment, auth: auth) { (_) throws(T.TaskError) in () } } - func response(with endpoint: T) async throws(T.TaskError) -> T.Response where T.Response == Data { - try await performRequest(with: endpoint) { (data) throws(T.TaskError) in data } + /// Perform the request for the endpoint, returning the raw response body. + /// - Parameters: + /// - endpoint: The endpoint instance to be used to make the request + /// - environment: The environment to resolve the base URL against. + /// - auth: The credentials to authenticate with. + func response( + with endpoint: T, + environment: T.Server.Environments = T.Server.defaultEnvironment, + auth: any AuthenticationMethod = T.auth + ) async throws(T.TaskError) -> T.Response where T.Response == Data { + try await performRequest(with: endpoint, environment: environment, auth: auth) { (data) throws(T.TaskError) in data } } - func response(with endpoint: T) async throws(T.TaskError) -> T.Response where T.Response: Decodable { - try await performRequest(with: endpoint) { (data) throws(T.TaskError) in + /// Perform the request for the endpoint, decoding the response body. + /// - Parameters: + /// - endpoint: The endpoint instance to be used to make the request + /// - environment: The environment to resolve the base URL against. + /// - auth: The credentials to authenticate with. + func response( + with endpoint: T, + environment: T.Server.Environments = T.Server.defaultEnvironment, + auth: any AuthenticationMethod = T.auth + ) async throws(T.TaskError) -> T.Response where T.Response: Decodable { + try await performRequest(with: endpoint, environment: environment, auth: auth) { (data) throws(T.TaskError) in do { return try T.responseDecoder.decode(T.Response.self, from: data) } catch { @@ -38,9 +65,9 @@ public extension URLSession { } /// Authenticates and performs the request, retrying after reauthentication when the - /// endpoint's ``Endpoint/Auth`` asks for it. + /// supplied ``AuthenticationMethod`` asks for it. /// - /// With the default ``NoAuth``, `authenticate` returns the request unchanged and + /// With ``NoAuth``, `authenticate` returns the request unchanged and /// `shouldReauthenticate` is always false, so this is a single pass through the /// unauthenticated request path. /// @@ -48,6 +75,8 @@ public extension URLSession { /// never applies credentials and never enters the retry loop. private func performRequest( with endpoint: T, + environment: T.Server.Environments, + auth: any AuthenticationMethod, transform: (Data) throws(T.TaskError) -> T.Response ) async throws(T.TaskError) -> T.Response { #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) @@ -60,10 +89,9 @@ public extension URLSession { AuthenticationStability.verifySharedInstance(for: T.self) #endif - let auth = T.auth // The endpoint is immutable, so the unauthenticated request is identical on // every attempt; only the credentials applied to it change. - let request = try createUrlRequest(for: endpoint) + let request = try createUrlRequest(for: endpoint, in: environment) var attempt = 0 while true { diff --git a/Sources/Endpoints/Extensions/URLSession+Combine.swift b/Sources/Endpoints/Extensions/URLSession+Combine.swift index 3221c51..10626af 100644 --- a/Sources/Endpoints/Extensions/URLSession+Combine.swift +++ b/Sources/Endpoints/Extensions/URLSession+Combine.swift @@ -100,9 +100,13 @@ public extension URLSession { /// - Parameters: /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` - func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Void { + func endpointPublisher( + with endpoint: T, + environment: T.Server.Environments = T.Server.defaultEnvironment, + auth: any AuthenticationMethod = T.auth + ) -> AnyPublisher where T.Response == Void { Endpoints.endpointPublisher { () throws(T.TaskError) in - try await self.response(with: endpoint) + try await self.response(with: endpoint, environment: environment, auth: auth) } } @@ -114,9 +118,13 @@ public extension URLSession { /// - Parameters: /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` - func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Data { + func endpointPublisher( + with endpoint: T, + environment: T.Server.Environments = T.Server.defaultEnvironment, + auth: any AuthenticationMethod = T.auth + ) -> AnyPublisher where T.Response == Data { Endpoints.endpointPublisher { () throws(T.TaskError) in - try await self.response(with: endpoint) + try await self.response(with: endpoint, environment: environment, auth: auth) } } @@ -128,9 +136,13 @@ public extension URLSession { /// - Parameters: /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` - func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response: Decodable { + func endpointPublisher( + with endpoint: T, + environment: T.Server.Environments = T.Server.defaultEnvironment, + auth: any AuthenticationMethod = T.auth + ) -> AnyPublisher where T.Response: Decodable { Endpoints.endpointPublisher { () throws(T.TaskError) in - try await self.response(with: endpoint) + try await self.response(with: endpoint, environment: environment, auth: auth) } } } diff --git a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift index a1ab9ab..ddcb144 100644 --- a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift +++ b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift @@ -63,9 +63,9 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws(T.TaskError) -> URLSessionDataTask where T.Response == Void, T.Auth == NoAuth { + func endpointTask(with endpoint: T, environment: T.Server.Environments = T.Server.defaultEnvironment, completion: @escaping @Sendable (Result) -> Void) throws(T.TaskError) -> URLSessionDataTask where T.Response == Void, T.Auth == NoAuth { - let urlRequest = try createUrlRequest(for: endpoint) + let urlRequest = try createUrlRequest(for: endpoint, in: environment) let task = dataTask(with: urlRequest) { (data, response, error) in completion(T.definition.response(data: data, response: response, error: error).map { _ in }) @@ -102,9 +102,9 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws(T.TaskError) -> URLSessionDataTask where T.Response == Data, T.Auth == NoAuth { + func endpointTask(with endpoint: T, environment: T.Server.Environments = T.Server.defaultEnvironment, completion: @escaping @Sendable (Result) -> Void) throws(T.TaskError) -> URLSessionDataTask where T.Response == Data, T.Auth == NoAuth { - let urlRequest = try createUrlRequest(for: endpoint) + let urlRequest = try createUrlRequest(for: endpoint, in: environment) let task = dataTask(with: urlRequest) { (data, response, error) in completion(T.definition.response(data: data, response: response, error: error)) @@ -139,9 +139,9 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws(T.TaskError) -> URLSessionDataTask where T.Response: Decodable, T.Auth == NoAuth { + func endpointTask(with endpoint: T, environment: T.Server.Environments = T.Server.defaultEnvironment, completion: @escaping @Sendable (Result) -> Void) throws(T.TaskError) -> URLSessionDataTask where T.Response: Decodable, T.Auth == NoAuth { - let urlRequest = try createUrlRequest(for: endpoint) + let urlRequest = try createUrlRequest(for: endpoint, in: environment) let task = dataTask(with: urlRequest) { (data, response, error) in let response = T.definition.response(data: data, response: response, error: error) @@ -181,9 +181,12 @@ public extension URLSession { return task } - func createUrlRequest(for endpoint: T) throws(T.TaskError) -> URLRequest { + func createUrlRequest( + for endpoint: T, + in environment: T.Server.Environments = T.Server.defaultEnvironment + ) throws(T.TaskError) -> URLRequest { do { - return try endpoint.urlRequest() + return try endpoint.urlRequest(in: environment) } catch { throw T.TaskError.endpointError(error) } diff --git a/Sources/Endpoints/Server.swift b/Sources/Endpoints/Server.swift index 3c29a6a..6aba352 100644 --- a/Sources/Endpoints/Server.swift +++ b/Sources/Endpoints/Server.swift @@ -7,46 +7,9 @@ import Foundation -/// Thread-safe storage for server environments. -/// Maps server types to their current environment values, allowing runtime switching. -/// -/// Keyed by the server type — not its `Environments` type — so servers that share an -/// environment type (e.g. the default ``TypicalEnvironments``) switch independently. -enum EnvironmentStorage { - private static let lock = NSLock() - nonisolated(unsafe) private static var environments: [ObjectIdentifier: Any] = [:] - - static func getEnvironment(for server: S.Type) -> S.Environments? { - lock.lock() - defer { lock.unlock() } - let serverKey = ObjectIdentifier(server) - return environments[serverKey] as? S.Environments - } - - static func setEnvironment(_ environment: S.Environments, for server: S.Type) { - lock.lock() - defer { lock.unlock() } - let serverKey = ObjectIdentifier(server) - environments[serverKey] = environment - } -} - -extension ServerDefinition { - /// The current environment for this server type. - /// - /// Use this property to switch environments at runtime. The value persists across - /// all endpoints using this server type. - /// - /// ```swift - /// // Switch to staging for all subsequent requests - /// ApiServer.environment = .staging - /// ``` - public static var environment: Self.Environments { - get { - EnvironmentStorage.getEnvironment(for: Self.self) ?? Self.defaultEnvironment - } - set { - EnvironmentStorage.setEnvironment(newValue, for: Self.self) - } - } -} +// The environment a request is built against is supplied per request — see +// ``Endpoint/urlRequest(in:)`` and the `environment:` parameter on the `URLSession` +// request methods — rather than stored in process-wide mutable state. That is what +// lets two clients talk to different environments at the same time. +// +// ``ServerDefinition/defaultEnvironment`` provides the default for those parameters. diff --git a/Tests/EndpointsTests/AuthenticatedEndpointTests.swift b/Tests/EndpointsTests/AuthenticatedEndpointTests.swift index b8bb8cd..e893f06 100644 --- a/Tests/EndpointsTests/AuthenticatedEndpointTests.swift +++ b/Tests/EndpointsTests/AuthenticatedEndpointTests.swift @@ -256,6 +256,87 @@ struct AuthenticatedEndpointTests { #expect(sent.first?.value(forHTTPHeaderField: Header.authorization.name) == "Bearer static-key") } + // MARK: - Per-request runtime context + + /// The motivating case: two client objects, each with its own environment and + /// credentials, issuing requests for the same endpoint at the same time. + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func concurrentClientsUseTheirOwnEnvironmentAndCredentials() async throws { + let successData = try JSONEncoder().encode(AuthTestResponse(value: "ok")) + + let recorder = RequestRecorder() + // Registered per path, so both environments' hosts route here. + TestURLProtocol.register(path: "/multi/tenant") { request in + recorder.record(request) + return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + } + defer { TestURLProtocol.unregister(path: "/multi/tenant") } + + let clientA = (environment: TypicalEnvironments.staging, auth: HeaderKeyAuth(key: "tenant-a")) + let clientB = (environment: TypicalEnvironments.production, auth: HeaderKeyAuth(key: "tenant-b")) + + let session = TestURLProtocol.makeSession() + + // Interleave both clients so a shared-state regression would cross the wires. + await withTaskGroup(of: Void.self) { group in + for _ in 0..<4 { + group.addTask { + _ = try? await session.response( + with: MultiTenantEndpoint(), + environment: clientA.environment, + auth: clientA.auth + ) + } + group.addTask { + _ = try? await session.response( + with: MultiTenantEndpoint(), + environment: clientB.environment, + auth: clientB.auth + ) + } + } + } + + let sent = recorder.all() + #expect(sent.count == 8) + + // Every request pairs the right host with the right credentials. + for request in sent { + let host = request.url?.host + let key = request.value(forHTTPHeaderField: Header.authorization.name) + switch key { + case "Bearer tenant-a": #expect(host == "staging-api.velosmobile.com") + case "Bearer tenant-b": #expect(host == "api.velosmobile.com") + default: Issue.record("Unexpected credentials: \(key ?? "none")") + } + } + + #expect(sent.filter { $0.value(forHTTPHeaderField: Header.authorization.name) == "Bearer tenant-a" }.count == 4) + #expect(sent.filter { $0.value(forHTTPHeaderField: Header.authorization.name) == "Bearer tenant-b" }.count == 4) + } + + /// Omitting both parameters falls back to what the endpoint declares, so existing + /// call sites are unaffected. + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) + @Test + func omittingRuntimeContextUsesTheDeclaredDefaults() async throws { + let successData = try JSONEncoder().encode(AuthTestResponse(value: "ok")) + + let recorder = RequestRecorder() + TestURLProtocol.register(path: "/auth/inherited") { request in + recorder.record(request) + return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, successData) + } + defer { TestURLProtocol.unregister(path: "/auth/inherited") } + + _ = try await TestURLProtocol.makeSession().response(with: InheritedAuthEndpoint()) + + let request = recorder.all().first + #expect(request?.value(forHTTPHeaderField: Header.authorization.name) == "Bearer server-key") + #expect(request?.url?.host == "api.velosmobile.com") + } + // MARK: - Declaration semantics @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) @@ -453,6 +534,15 @@ struct ReentrantRefreshEndpoint: Endpoint { static let definition: Definition = Definition(method: .get, path: "auth/reentrant-refresh") } +/// Declares no auth of its own: both credentials and environment come from the caller. +struct MultiTenantEndpoint: Endpoint { + typealias Server = TestServer + typealias Response = AuthTestResponse + typealias ErrorResponse = AuthTestErrorResponse + + static let definition: Definition = Definition(method: .get, path: "multi/tenant") +} + struct StaticKeyEndpoint: Endpoint { typealias Server = TestServer typealias Response = AuthTestResponse diff --git a/Tests/EndpointsTests/EndpointsTests.swift b/Tests/EndpointsTests/EndpointsTests.swift index 1c8344c..3c58e6b 100644 --- a/Tests/EndpointsTests/EndpointsTests.swift +++ b/Tests/EndpointsTests/EndpointsTests.swift @@ -242,48 +242,46 @@ struct EndpointsTests { @Test @available(iOS 16.0, *) - func environmentsChange() throws { - let existing = TestServer.environment - + func environmentSelectsBaseUrl() throws { let endpoint = SimpleEndpoint( pathComponents: .init(name: "zac", id: "42") ) - TestServer.environment = .local - #expect(try endpoint.urlRequest().url?.host() == "local-api.velosmobile.com") - - TestServer.environment = .staging - #expect(try endpoint.urlRequest().url?.host() == "staging-api.velosmobile.com") - - TestServer.environment = .production - #expect(try endpoint.urlRequest().url?.host() == "api.velosmobile.com") - - TestServer.environment = existing + #expect(try endpoint.urlRequest(in: .local).url?.host() == "local-api.velosmobile.com") + #expect(try endpoint.urlRequest(in: .staging).url?.host() == "staging-api.velosmobile.com") + #expect(try endpoint.urlRequest(in: .production).url?.host() == "api.velosmobile.com") } @Test @available(iOS 16.0, *) - func defaultEnvironment() throws { + func environmentDefaultsToTheServersDefault() throws { #expect(TestServer.defaultEnvironment == .production) + + let endpoint = SimpleEndpoint(pathComponents: .init(name: "zac", id: "42")) + #expect(try endpoint.urlRequest().url?.host() == "api.velosmobile.com") } + /// The environment is a per-request value, so requests built for different + /// environments do not interfere — including from concurrent tasks. @Test - func environmentSwitchingIsPerServer() { - // Both servers use TypicalEnvironments; switching one must not affect - // the other, which stays on its default. - EnvironmentIsolationServerA.environment = .staging + @available(iOS 16.0, *) + func environmentsDoNotLeakAcrossConcurrentRequests() async throws { + let hosts = await withTaskGroup(of: String?.self) { group in + for environment in [TypicalEnvironments.local, .staging, .production, .local, .staging] { + group.addTask { + let endpoint = SimpleEndpoint(pathComponents: .init(name: "zac", id: "42")) + return try? endpoint.urlRequest(in: environment).url?.host() + } + } + return await group.reduce(into: [String?]()) { $0.append($1) } + } - #expect(EnvironmentIsolationServerA.environment == .staging) - #expect(EnvironmentIsolationServerB.environment == .production) + #expect(hosts.compactMap { $0 }.sorted() == [ + "api.velosmobile.com", + "local-api.velosmobile.com", + "local-api.velosmobile.com", + "staging-api.velosmobile.com", + "staging-api.velosmobile.com" + ]) } } - -struct EnvironmentIsolationServerA: ServerDefinition { - var baseUrls: [Environments: URL] { [.production: URL(string: "https://a.example.com")!] } - static var defaultEnvironment: Environments { .production } -} - -struct EnvironmentIsolationServerB: ServerDefinition { - var baseUrls: [Environments: URL] { [.production: URL(string: "https://b.example.com")!] } - static var defaultEnvironment: Environments { .production } -}