diff --git a/README.md b/README.md index ae6e5d6..2a1ebb0 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** - 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 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 @@ -79,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 @@ -92,6 +120,134 @@ do { } ``` +## Authentication + +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 +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 — 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. + +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: + +- `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. + +### Token refresh with JWTAuth + +`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. + +```swift +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() + } + ) + + var baseUrls: [Environments: URL] { ... } + static var defaultEnvironment: Environments { .production } +} +``` + +> **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()`. + +### 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 + } +} +``` + +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 — including authentication failures — surface as the endpoint's typed `EndpointTaskError`, so a single `catch` covers everything: + +```swift +do { + let response = try await URLSession.shared.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: @@ -109,12 +265,28 @@ 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 - Throwing network errors - Dynamic response generation - Combine publisher mocking +- Authenticated and unauthenticated endpoints alike + +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. @@ -125,7 +297,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 @@ -135,7 +308,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") ] ``` @@ -161,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/Authentication/AuthenticationError.swift b/Sources/Endpoints/Authentication/AuthenticationError.swift new file mode 100644 index 0000000..bef7af2 --- /dev/null +++ b/Sources/Endpoints/Authentication/AuthenticationError.swift @@ -0,0 +1,48 @@ +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) + + /// 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) +} + +/// 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 { + case .refreshFailed(let underlying), .custom(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..22a4420 --- /dev/null +++ b/Sources/Endpoints/Authentication/AuthenticationMethod.swift @@ -0,0 +1,63 @@ +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(AuthenticationError) -> 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). + /// + /// - 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(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. + var maxRetryAttempts: Int { get } +} + +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 + } + + /// 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/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/BasicAuth.swift b/Sources/Endpoints/Authentication/Implementations/BasicAuth.swift new file mode 100644 index 0000000..3278cd8 --- /dev/null +++ b/Sources/Endpoints/Authentication/Implementations/BasicAuth.swift @@ -0,0 +1,32 @@ +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 + + /// 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 + mutableRequest.setValue(headerValue, forHTTPHeaderField: Header.authorization.name) + return mutableRequest + } +} diff --git a/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift b/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift new file mode 100644 index 0000000..4662b9e --- /dev/null +++ b/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift @@ -0,0 +1,48 @@ +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 + + /// 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, + appendToExisting: Bool = true + ) { + self.name = name + self.value = value + self.appendToExisting = appendToExisting + } + + public func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { + var mutableRequest = request + let cookiePair = "\(name)=\(value)" + + if appendToExisting, + let existing = request.value(forHTTPHeaderField: Header.cookie.name), + !existing.isEmpty { + 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.cookie.name) + } + + return mutableRequest + } +} diff --git a/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift b/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift new file mode 100644 index 0000000..d887a74 --- /dev/null +++ b/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift @@ -0,0 +1,44 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// 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`. + public let header: Header + + /// Optional prefix before the key (e.g., "Bearer", "ApiKey"). + /// 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: + /// - key: The 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 + self.headerValue = prefix.map { "\($0) \(key)" } ?? key + } + + public func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest { + var mutableRequest = request + mutableRequest.setValue(headerValue, forHTTPHeaderField: header.name) + return mutableRequest + } +} diff --git a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift new file mode 100644 index 0000000..5a43573 --- /dev/null +++ b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift @@ -0,0 +1,262 @@ +import Foundation + +#if canImport(FoundationNetworking) +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 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 + + /// A pair of access and refresh tokens. + public struct TokenPair: Sendable, Equatable { + public let accessToken: String + public let 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 + } + } + + /// 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 + + /// 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], + expiryLeeway: TimeInterval = 30 + ) { + self.header = header + self.tokenPrefix = tokenPrefix + self.refreshTriggerStatusCodes = refreshTriggerStatusCodes + self.expiryLeeway = expiryLeeway + } + + public static let `default` = Configuration() + } + + /// 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). + 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? + + /// 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 + 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(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 { + // 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 refresh failed. Keep the existing (possibly expired) tokens and + // send the request anyway; if it is rejected, the failure surfaces + // through shouldReauthenticate/reauthenticate. + } + } + + guard let accessToken = currentTokens?.accessToken else { + throw AuthenticationError.notAuthenticated + } + + var mutableRequest = request + mutableRequest.setValue(headerValue(for: accessToken), 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(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 + } + + 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 = pendingRefresh ?? startRefresh(refreshToken: refreshToken) + + defer { + if pendingRefresh == refreshTask { + pendingRefresh = nil + } + } + + do { + return try await refreshTask.value + } catch let error as AuthenticationError { + throw error + } catch { + throw .refreshFailed(underlying: error) + } + } + + private func startRefresh(refreshToken: String) -> Task { + 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 { + // 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 { + await onRefreshFailed?(error) + throw AuthenticationError.refreshFailed(underlying: error) + } + } + + pendingRefresh = refreshTask + return refreshTask + } + + private nonisolated func headerValue(for accessToken: String) -> String { + "\(configuration.tokenPrefix) \(accessToken)" + } + + // MARK: - Public Token Management + + public func setTokens(_ tokens: TokenPair) { + currentTokens = tokens + pendingRefresh?.cancel() + pendingRefresh = nil + } + + public func clearTokens() { + currentTokens = nil + pendingRefresh?.cancel() + pendingRefresh = nil + } + + /// The current token pair, if any. + public var tokens: 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..aeae4b7 --- /dev/null +++ b/Sources/Endpoints/Authentication/Implementations/NoAuth.swift @@ -0,0 +1,14 @@ +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(AuthenticationError) -> URLRequest { + request + } +} diff --git a/Sources/Endpoints/Endpoint+URLRequest.swift b/Sources/Endpoints/Endpoint+URLRequest.swift index 4e39d69..c719f0d 100644 --- a/Sources/Endpoints/Endpoint+URLRequest.swift +++ b/Sources/Endpoints/Endpoint+URLRequest.swift @@ -15,64 +15,57 @@ import FoundationNetworking extension Endpoint { /// Generates a `URLRequest` given the associated request value. - /// - Parameter environment: The environment in which to create the request + /// - 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 -> URLRequest { + public func urlRequest( + in environment: Server.Environments = Server.defaultEnvironment + ) 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] = [] + 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: - return nil - } - - guard let queryValue = value as? ParameterRepresentable else { - throw EndpointError.invalidQuery(named: name, type: type(of: value)) - } - - if let encodedValue = queryValue.parameterValue { - return URLQueryItem(name: name, value: encodedValue) - } - - return nil - } - - let bodyFormItems: [URLQueryItem] = try Self.definition.parameters.compactMap { item in - - 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: - return nil + 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 { - return URLQueryItem(name: name, value: encodedValue) - } + guard let encodedValue = parameterValue.parameterValue else { continue } - return nil + if isQuery { + urlQueryItems.append(URLQueryItem(name: name, value: encodedValue)) + } else { + bodyFormItems.append(URLQueryItem(name: name, value: encodedValue)) + } } if !urlQueryItems.isEmpty { @@ -93,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) @@ -106,7 +99,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 +115,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/Endpoint.swift b/Sources/Endpoints/Endpoint.swift index c7e6883..360b9e3 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 { @@ -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/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..7e60528 100644 --- a/Sources/Endpoints/Endpoints.docc/Examples.md +++ b/Sources/Endpoints/Endpoints.docc/Examples.md @@ -59,27 +59,32 @@ 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: +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/EnvironmentType.swift b/Sources/Endpoints/EnvironmentType.swift index ca49fea..73bc6d1 100644 --- a/Sources/Endpoints/EnvironmentType.swift +++ b/Sources/Endpoints/EnvironmentType.swift @@ -41,7 +41,13 @@ 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 + + /// The ``AuthenticationMethod`` applied to endpoints on this server. Defaults to ``NoAuth``. + associatedtype Auth: AuthenticationMethod = NoAuth /// Required initializer for creating server instances. init() @@ -50,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 } } @@ -62,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 40f3afb..88b1ab3 100644 --- a/Sources/Endpoints/Extensions/URLSession+Async.swift +++ b/Sources/Endpoints/Extensions/URLSession+Async.swift @@ -15,70 +15,123 @@ 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. /// - 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 { - let urlRequest = try createUrlRequest(for: endpoint) + /// - 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 () } + } - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { - return mockResponse - } - #endif + /// 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 } + } - 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) + /// 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 { + throw T.TaskError.responseParseError(data: data, error: error) } } - - _ = 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 { - let urlRequest = try createUrlRequest(for: endpoint) - + /// Authenticates and performs the request, retrying after reauthentication when the + /// supplied ``AuthenticationMethod`` asks for it. + /// + /// With ``NoAuth``, `authenticate` returns the request unchanged and + /// `shouldReauthenticate` is always false, so this is a single pass through the + /// unauthenticated request path. + /// + /// 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, + 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)) - 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) - } - } - - return try T.definition.response(data: result.data, response: result.response, error: nil).get() - } + #if DEBUG + AuthenticationStability.verifySharedInstance(for: T.self) + #endif - func response(with endpoint: T) async throws -> T.Response where T.Response: Decodable { - let urlRequest = try createUrlRequest(for: endpoint) + // 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, in: environment) + + var attempt = 0 + while true { + let authenticatedRequest: URLRequest + do { + authenticatedRequest = try await auth.authenticate(request: request) + } catch { + throw T.TaskError.authenticationError(error) + } - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { - return mockResponse + 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 < auth.retryAttempts, + 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 + } } - #endif + } - let result: (data: Data, response: URLResponse) + /// Loads data for the request, mapping `URLSession` failures into the endpoint's ``EndpointTaskError``. + private 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 +139,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..10626af 100644 --- a/Sources/Endpoints/Extensions/URLSession+Combine.swift +++ b/Sources/Endpoints/Extensions/URLSession+Combine.swift @@ -11,162 +11,139 @@ 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 throws(Failure) -> Output +) -> AnyPublisher { + Deferred { () -> AnyPublisher in + let bridge = AsyncBridge() + return Future { promise in + bridge.begin(promise: promise) { + do throws(Failure) { + return .success(try await work()) + } catch { + return .failure(error) + } + } + } + .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: - /// - 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 { - let urlRequest: URLRequest - do { - urlRequest = try createUrlRequest(for: endpoint) - } catch { - return Fail(outputType: T.Response.self, failure: error as! T.TaskError) - .eraseToAnyPublisher() + 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, environment: environment, auth: auth) } - - 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() - } - // swiftlint:disable:next force_cast - .mapError { $0 as! T.TaskError } - - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - return Mocking.shared.handleMock(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: - /// - 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 { - - let urlRequest: URLRequest - do { - urlRequest = try createUrlRequest(for: endpoint) - } catch { - return Fail(outputType: T.Response.self, failure: error as! T.TaskError) - .eraseToAnyPublisher() + 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, environment: environment, auth: auth) } - - 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 - 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.handleMock(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: - /// - 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 { - - let urlRequest: URLRequest - do { - urlRequest = try createUrlRequest(for: endpoint) - } catch { - return Fail(outputType: T.Response.self, failure: error as! T.TaskError) - .eraseToAnyPublisher() + 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, environment: environment, auth: auth) } - - - 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) - } - } - // swiftlint:disable:next force_cast - .mapError { $0 as! T.TaskError } - - #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) - return Mocking.shared.handleMock(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/Extensions/URLSession+Endpoints.swift b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift index 7e2b2af..ddcb144 100644 --- a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift +++ b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift @@ -24,6 +24,28 @@ public enum EndpointTaskError: Error, Sendable { case urlLoadError(Error) case internetConnectionOffline + + /// 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 { @@ -37,14 +59,13 @@ 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. /// - 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, 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 }) @@ -77,14 +98,13 @@ 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. /// - 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, 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)) @@ -115,14 +135,13 @@ 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. /// - 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, 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) @@ -162,19 +181,14 @@ public extension URLSession { return task } - func createUrlRequest(for endpoint: T) throws -> URLRequest { - let urlRequest: URLRequest - + func createUrlRequest( + for endpoint: T, + in environment: T.Server.Environments = T.Server.defaultEnvironment + ) throws(T.TaskError) -> URLRequest { do { - urlRequest = try endpoint.urlRequest() + return try endpoint.urlRequest(in: environment) } 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/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 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 f2c1548..477ce49 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 @@ -44,7 +48,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 } @@ -62,12 +66,29 @@ 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)) { + 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. + /// + /// 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,61 +97,21 @@ 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 } } -#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 handleMock(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/Sources/Endpoints/Server.swift b/Sources/Endpoints/Server.swift index 3f6f7d8..6aba352 100644 --- a/Sources/Endpoints/Server.swift +++ b/Sources/Endpoints/Server.swift @@ -7,43 +7,9 @@ import Foundation -/// Thread-safe storage for server environments. -/// Maps environment types to their current values, allowing runtime switching. -enum EnvironmentStorage { - private static let lock = NSLock() - nonisolated(unsafe) private static var environments: [ObjectIdentifier: Any] = [:] - - 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) { - lock.lock() - defer { lock.unlock() } - let typeKey = ObjectIdentifier(type) - environments[typeKey] = 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.Environments.self) ?? Self.defaultEnvironment - } - set { - EnvironmentStorage.setEnvironment(newValue, for: Self.Environments.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/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/AuthenticatedEndpointMockingTests.swift b/Tests/EndpointsMockingTests/AuthenticatedEndpointMockingTests.swift new file mode 100644 index 0000000..1d60b2e --- /dev/null +++ b/Tests/EndpointsMockingTests/AuthenticatedEndpointMockingTests.swift @@ -0,0 +1,138 @@ +import Testing +import Foundation +import Endpoints +@testable import EndpointsMocking + +/// 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 { + try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "mocked"))) { + let endpoint = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + 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 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") + } + } + + @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(MockAuthenticatedEndpoint.self, action: .return(.init(value: "authed"))) + } test: { + let simple = try await URLSession.shared.response(with: MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b"))) + #expect(simple.response1 == "profile") + + let authed = try await URLSession.shared.response(with: MockAuthenticatedEndpoint()) + #expect(authed.value == "authed") + } + } + + /// 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 { + await withMock(MockSimpleEndpoint.self, action: .throw(.authenticationError(.notAuthenticated))) { + do throws(MockSimpleEndpoint.TaskError) { + _ = 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 { + Issue.record("Unexpected error: \(error)") + return + } + } + } + } +} diff --git a/Tests/EndpointsMockingTests/MockRegistryTests.swift b/Tests/EndpointsMockingTests/MockRegistryTests.swift new file mode 100644 index 0000000..09f763a --- /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 { + 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) + } + } +} diff --git a/Tests/EndpointsTests/AuthenticatedEndpointTests.swift b/Tests/EndpointsTests/AuthenticatedEndpointTests.swift new file mode 100644 index 0000000..e893f06 --- /dev/null +++ b/Tests/EndpointsTests/AuthenticatedEndpointTests.swift @@ -0,0 +1,644 @@ +#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 { + + @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: 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() } + 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: 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() } + 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: testURL("/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: 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() + 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: testURL("/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: testURL("/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"]) + } + + /// 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 { + 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: testURL("/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: - 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, *) + @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: testURL("/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: testURL("/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: testURL("/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) + } + + @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. + #expect(SharedAuthEndpointA.auth === SharedAuthEndpointB.auth) + #expect(SharedAuthEndpointA.auth === SharedAuthServer.auth) + } +} + +// MARK: - Servers + +/// 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 RetryEndpoint: Endpoint { + typealias Server = TestServer + 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 = TestServer + 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 = TestServer + 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 = TestServer + 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 = TestServer + 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 = TestServer + 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 = TestServer + 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") +} + +/// 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") +} + +/// 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 + 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/AuthenticationTests.swift b/Tests/EndpointsTests/AuthenticationTests.swift new file mode 100644 index 0000000..2b7e42d --- /dev/null +++ b/Tests/EndpointsTests/AuthenticationTests.swift @@ -0,0 +1,339 @@ +import Testing +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +@testable import Endpoints + +@Suite("Authentication") +struct AuthenticationTests { + @Test + func apiKeyAuthAddsAuthorizationHeader() async throws { + let auth = HeaderKeyAuth(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 = 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) + + #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) + } + ) + + 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(after: failedRequest) + } + } + } + + #expect(await counter.value() == 1) + #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") + 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") + } + + @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") + } + + @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() + 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 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) + let bridged = AuthenticationError.refreshFailed(underlying: underlying) as NSError + + #expect((bridged.userInfo[NSUnderlyingErrorKey] as? URLError) == underlying) + #expect((AuthenticationError.notAuthenticated as NSError).userInfo[NSUnderlyingErrorKey] == nil) + } +} + +actor RefreshCounter { + private var count = 0 + + func increment() { + count += 1 + } + + func value() -> Int { + count + } +} diff --git a/Tests/EndpointsTests/CombineAuthenticationTests.swift b/Tests/EndpointsTests/CombineAuthenticationTests.swift new file mode 100644 index 0000000..19d0f97 --- /dev/null +++ b/Tests/EndpointsTests/CombineAuthenticationTests.swift @@ -0,0 +1,191 @@ +#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 { + + @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: testURL("/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: 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() + 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.started.wait() + + cancellable.cancel() + + let observed = await CancellationProbe.observedCancellation.wait(upTo: 2_000_000_000) + #expect(observed, "Cancelling the subscription should cancel the underlying task") + } +} + +// MARK: - Fixtures + +struct CombineKeyedEndpoint: Endpoint { + typealias Server = TestServer + 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 = TestServer + 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 = TestServer + 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 = TestServer + 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.started.open() + + do { + try await Task.sleep(nanoseconds: 3_000_000_000) + } catch { + // Task.sleep throws when the enclosing task is cancelled. + await CancellationProbe.observedCancellation.open() + throw .custom(underlying: error) + } + + return request + } +} + +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/EndpointsTests.swift b/Tests/EndpointsTests/EndpointsTests.swift index 19df9f3..3c58e6b 100644 --- a/Tests/EndpointsTests/EndpointsTests.swift +++ b/Tests/EndpointsTests/EndpointsTests.swift @@ -242,28 +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") + #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") + } - TestServer.environment = .staging - #expect(try endpoint.urlRequest().url?.host() == "staging-api.velosmobile.com") + @Test + @available(iOS 16.0, *) + func environmentDefaultsToTheServersDefault() throws { + #expect(TestServer.defaultEnvironment == .production) - TestServer.environment = .production + let endpoint = SimpleEndpoint(pathComponents: .init(name: "zac", id: "42")) #expect(try endpoint.urlRequest().url?.host() == "api.velosmobile.com") - - TestServer.environment = existing } + /// The environment is a per-request value, so requests built for different + /// environments do not interfere — including from concurrent tasks. @Test @available(iOS 16.0, *) - func defaultEnvironment() throws { - #expect(TestServer.defaultEnvironment == .production) + 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(hosts.compactMap { $0 }.sorted() == [ + "api.velosmobile.com", + "local-api.velosmobile.com", + "local-api.velosmobile.com", + "staging-api.velosmobile.com", + "staging-api.velosmobile.com" + ]) } } 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 new file mode 100644 index 0000000..b6690bd --- /dev/null +++ b/Tests/EndpointsTests/TestTransport.swift @@ -0,0 +1,120 @@ +#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() + } +} + +/// 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 +} + +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 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