From 4f26abea9e43d2f434120a91e7644b8298920a53 Mon Sep 17 00:00:00 2001 From: Zac White Date: Tue, 6 Oct 2026 08:22:44 -0700 Subject: [PATCH 1/5] Keep logout from being undone by an in-flight JWT refresh; tighten docs JWTAuth committed a refresh's tokens even when setTokens(_:) or clearTokens() had replaced them while the refresh was in flight, and persisted them through onTokensUpdated. A logout during a refresh could silently sign the user back in. With several requests sharing one refresh, a later waiter could also restore tokens after a logout that landed between waiters resuming. A refresh now commits only while it is still the pending refresh, so the first waiter commits once and a superseded refresh never does. A superseded refresh does not call onTokensUpdated or onRefreshFailed. Requests waiting on it continue with the current tokens, or fail with notAuthenticated after a logout. A persistence callback that has already started cannot be revoked, and the docs now say so. Also: - An empty JWTAuth tokenPrefix sends the bare token, with no leading space. - Fold EnvironmentType.swift into Server.swift and remove the internal sample ApiServer/TestEndpoint types that shipped in the library. - Document that credentials are bound to an environment, and how to pair per-request environment and auth. - Fix the Mocking guide: remove the nonexistent continuation.endpoint, fix non-Sendable examples, and describe the resume() swizzling accurately. - Document the environment/auth parameters on every request method, and fix DocC warnings. --- README.md | 6 +- .../Implementations/JWTAuth.swift | 76 +++++-- Sources/Endpoints/Endpoint.swift | 2 +- Sources/Endpoints/Endpoints.docc/Endpoints.md | 28 ++- Sources/Endpoints/Endpoints.docc/Examples.md | 64 ++++++ Sources/Endpoints/Endpoints.docc/Mocking.md | 153 ++++++++----- Sources/Endpoints/EnvironmentType.swift | 170 --------------- .../Extensions/URLSession+Async.swift | 12 +- .../Extensions/URLSession+Combine.swift | 12 ++ .../Extensions/URLSession+Endpoints.swift | 6 + Sources/Endpoints/MultipartFormEncoder.swift | 4 +- Sources/Endpoints/Server.swift | 159 +++++++++++++- .../JWTAuthSupersededRefreshTests.swift | 202 ++++++++++++++++++ 13 files changed, 630 insertions(+), 264 deletions(-) delete mode 100644 Sources/Endpoints/EnvironmentType.swift create mode 100644 Tests/EndpointsTests/JWTAuthSupersededRefreshTests.swift diff --git a/README.md b/README.md index 2a1ebb0..8703e45 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ struct WavelikeClient { 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. +Credentials are usually bound to one environment: a token issued by staging is not valid against production. A server-wide `static let auth` therefore suits apps that talk to a single environment at a time. If your app switches environments per request, pass the matching credentials alongside — keep one `JWTAuth` per environment or per account, as in the client above — rather than sharing one instance across environments. A server whose credentials always arrive per request need not declare `auth` at all; it defaults to `NoAuth`, and the `auth:` parameter accepts any `AuthenticationMethod`. + ### Async/Await ```swift @@ -208,7 +210,9 @@ struct ApiServer: ServerDefinition { > **Important:** the refresh endpoint must not be authenticated by the same `JWTAuth` — the request would wait on the very refresh that is waiting on it. 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()`. +After a login or logout, update the tokens with `await ApiServer.auth.setTokens(_:)` or `await ApiServer.auth.clearTokens()`. Either call supersedes a refresh that is still in flight: its result is discarded rather than committed, so a refresh that started just before a logout cannot sign the user back in, and requests that were waiting on it proceed with the new tokens (or fail with `.notAuthenticated` after a logout). `onTokensUpdated` is not called for a superseded refresh, but a call that has already started cannot be revoked; if your store must never hold tokens after a logout, have it reject writes from a session that has since been signed out. + +Use `JWTAuth.Configuration` to send the token in a different header or without a prefix, for example `.init(header: "X-Access-Token", tokenPrefix: "")`. ### Custom authentication methods diff --git a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift index 5a43573..d41f5df 100644 --- a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift @@ -53,6 +53,7 @@ public actor JWTAuth: AuthenticationMethod { public let header: Header /// Prefix before the token (e.g., "Bearer"). Defaults to "Bearer". + /// An empty string sends the bare token, for headers such as `X-Access-Token`. public let tokenPrefix: String /// HTTP status codes that should trigger a token refresh. Defaults to [401]. @@ -86,6 +87,13 @@ public actor JWTAuth: AuthenticationMethod { public typealias RefreshHandler = @Sendable (String) async throws -> TokenPair /// Closure type for handling token updates (e.g., persisting to Keychain). + /// + /// Called once per successful refresh, before the new tokens are used. A refresh + /// superseded by ``JWTAuth/setTokens(_:)`` or ``JWTAuth/clearTokens()`` before its + /// handler returns never calls this. A call that has already started cannot be + /// revoked, though: if a logout lands while it is still persisting, the write can + /// complete after the logout. If that matters, have the store reject writes from a + /// session that has since been signed out. public typealias TokenUpdateHandler = @Sendable (TokenPair) async -> Void /// Closure type for handling refresh failures (e.g., logout). @@ -138,15 +146,12 @@ public actor JWTAuth: AuthenticationMethod { 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. - } + // Join an in-flight refresh, or proactively refresh an expiring token + // rather than sending a request that is likely to be rejected. If the + // refresh fails, keep the existing (possibly expired) tokens and send the + // request anyway; a rejection then surfaces through + // shouldReauthenticate/reauthenticate. + try? await refresh(with: tokens.refreshToken) } guard let accessToken = currentTokens?.accessToken else { @@ -178,16 +183,22 @@ public actor JWTAuth: AuthenticationMethod { throw AuthenticationError.noRefreshToken } - currentTokens = try await refresh(with: refreshToken) + try await refresh(with: refreshToken) } // MARK: - Refresh - /// Joins the in-flight refresh if one exists, otherwise starts a new one. + /// Joins the in-flight refresh if one exists, otherwise starts a new one, and + /// commits the resulting tokens. /// /// 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 { + /// + /// A refresh superseded by ``setTokens(_:)`` or ``clearTokens()`` is discarded + /// rather than committed: the caller's tokens are the newer intent, and a logout + /// must not be undone by a refresh that was already underway. Superseded refreshes + /// return normally so the request proceeds with whatever tokens are current. + private func refresh(with refreshToken: String) async throws(AuthenticationError) { let refreshTask = pendingRefresh ?? startRefresh(refreshToken: refreshToken) defer { @@ -196,13 +207,25 @@ public actor JWTAuth: AuthenticationMethod { } } + let newTokens: TokenPair do { - return try await refreshTask.value + newTokens = try await refreshTask.value + } catch is CancellationError { + return } catch let error as AuthenticationError { throw error } catch { throw .refreshFailed(underlying: error) } + + // Commit only while this refresh is still the pending one. Every caller that + // joined it resumes here, one at a time; the first commits and (via the defer) + // clears `pendingRefresh`, so later callers leave the tokens alone. That also + // covers supersession: setTokens/clearTokens clear `pendingRefresh`, so a + // refresh they replaced is never committed — even by a caller that resumes + // after a logout which landed between two callers resuming. + guard pendingRefresh == refreshTask else { return } + currentTokens = newTokens } private func startRefresh(refreshToken: String) -> Task { @@ -213,20 +236,28 @@ public actor JWTAuth: AuthenticationMethod { let identity = ObjectIdentifier(self) let refreshTask = Task { + let newTokens: TokenPair 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( + newTokens = try await Self.$refreshingInstances.withValue( Self.refreshingInstances.union([identity]) ) { try await refreshHandler(refreshToken) } - await onTokensUpdated?(newTokens) - return newTokens } catch { + // A superseded refresh is not a failure: the tokens were replaced or + // cleared deliberately, so don't report it (and don't trigger a logout). + try Task.checkCancellation() await onRefreshFailed?(error) throw AuthenticationError.refreshFailed(underlying: error) } + + // Don't persist tokens that setTokens/clearTokens already replaced while + // the handler was running. + try Task.checkCancellation() + await onTokensUpdated?(newTokens) + return newTokens } pendingRefresh = refreshTask @@ -234,17 +265,28 @@ public actor JWTAuth: AuthenticationMethod { } private nonisolated func headerValue(for accessToken: String) -> String { - "\(configuration.tokenPrefix) \(accessToken)" + configuration.tokenPrefix.isEmpty ? accessToken : "\(configuration.tokenPrefix) \(accessToken)" } // MARK: - Public Token Management + /// Replaces the current tokens, for example after a login. + /// + /// Any refresh in flight is superseded: its result is discarded rather than + /// committed, and requests waiting on it proceed with these tokens. public func setTokens(_ tokens: TokenPair) { currentTokens = tokens pendingRefresh?.cancel() pendingRefresh = nil } + /// Removes the current tokens, for example after a logout. + /// + /// Any refresh in flight is superseded: its result is discarded rather than + /// committed, so a refresh that was already underway cannot silently sign the + /// user back in. Requests waiting on it fail with + /// ``AuthenticationError/notAuthenticated``. See ``TokenUpdateHandler`` for the + /// one case where a persistence callback can still finish after this returns. public func clearTokens() { currentTokens = nil pendingRefresh?.cancel() diff --git a/Sources/Endpoints/Endpoint.swift b/Sources/Endpoints/Endpoint.swift index 360b9e3..7bb0271 100644 --- a/Sources/Endpoints/Endpoint.swift +++ b/Sources/Endpoints/Endpoint.swift @@ -1,5 +1,5 @@ // -// Definition.swift +// Endpoint.swift // Endpoints // // Created by Zac White on 1/26/19. diff --git a/Sources/Endpoints/Endpoints.docc/Endpoints.md b/Sources/Endpoints/Endpoints.docc/Endpoints.md index 1983729..80a4cba 100644 --- a/Sources/Endpoints/Endpoints.docc/Endpoints.md +++ b/Sources/Endpoints/Endpoints.docc/Endpoints.md @@ -11,8 +11,8 @@ The purpose of Endpoints is to, in a type-safe way, define how to create a `URLR ### Essentials - ``Endpoint`` -- ``ServerDefinition`` - ``Definition`` +- ``ServerDefinition`` - ### Server Configuration @@ -21,28 +21,36 @@ The purpose of Endpoints is to, in a type-safe way, define how to create a `URLR - ``GenericServer`` - ``TypicalEnvironments`` +### Request Components + +- ``Method`` +- ``PathTemplate`` +- ``Parameter`` +- ``Header`` +- ``HeaderField`` +- ``ParameterRepresentable`` +- ``MultipartFormEncoder`` + ### Authentication - ``AuthenticationMethod`` - ``AuthenticationError`` +- ``RefreshReentrancyError`` - ``NoAuth`` - ``HeaderKeyAuth`` - ``BasicAuth`` - ``CookieAuth`` - ``JWTAuth`` +### Making Requests + +- ``Foundation/URLSession`` +- ``EndpointTaskError`` +- ``EndpointError`` + ### Testing and Mocking - -- ``EndpointsMocking`` -- ``withMock(_:_:test:)`` - ``MockRegistry`` - ``MockContinuation`` - ``MockAction`` - -### Making Requests - -#### Combine - -- ``Foundation/URLSession`` -- ``Endpoint/Response`` diff --git a/Sources/Endpoints/Endpoints.docc/Examples.md b/Sources/Endpoints/Endpoints.docc/Examples.md index 7e60528..334bfdf 100644 --- a/Sources/Endpoints/Endpoints.docc/Examples.md +++ b/Sources/Endpoints/Endpoints.docc/Examples.md @@ -85,6 +85,70 @@ Because the environment is a per-request value rather than global state, differe of an app — or two clients talking to different deployments — can use different environments at the same time. +### Authentication + +A server names the ``AuthenticationMethod`` its endpoints share, and every endpoint on it +inherits that method. Declare it as a `static let` so all endpoints use one instance — +for a stateful method such as ``JWTAuth`` that shared instance is what lets concurrent +token refreshes coalesce: + +```swift +struct ApiServer: ServerDefinition { + static let auth = JWTAuth( + initialTokens: loadTokensFromKeychain(), + refreshHandler: { refreshToken in + 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: { _ in await logOut() } + ) + + var baseUrls: [Environments: URL] { ... } + static var defaultEnvironment: Environments { .production } +} +``` + +An individual endpoint can override its server's method. The refresh endpoint above must +do so: it authenticates with the refresh token, and a request authenticated by the same +`JWTAuth` would wait on the refresh that is waiting on it. + +```swift +struct RefreshEndpoint: Endpoint { + typealias Server = ApiServer + static var auth: NoAuth { NoAuth() } + ... +} +``` + +Requests then go through the ordinary `URLSession` API; credentials are applied, refreshed +and retried automatically: + +```swift +let profile = try await URLSession.shared.response(with: ProfileEndpoint()) +``` + +### Environment and Credentials Per Request + +Credentials are usually bound to one environment — a token issued by staging is not valid +against production — so an app that talks to several environments or accounts at once +should pass both per request rather than share one server-wide instance: + +```swift +struct Client { + 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) + } +} +``` + +Both parameters default to what the endpoint declares, so the common single-environment +case needs neither. A server whose credentials always arrive this way need not declare +``ServerDefinition/auth`` at all. + --- ## Endpoint Examples diff --git a/Sources/Endpoints/Endpoints.docc/Mocking.md b/Sources/Endpoints/Endpoints.docc/Mocking.md index 8363d0d..5d55ba3 100644 --- a/Sources/Endpoints/Endpoints.docc/Mocking.md +++ b/Sources/Endpoints/Endpoints.docc/Mocking.md @@ -116,26 +116,33 @@ withMock(MyEndpoint.self, action: .none) ## Advanced Mocking -### Dynamic Responses Based on Request +### Dynamic Responses -The continuation closure receives the endpoint instance, allowing dynamic responses: +The mock closure runs once per request, so it can vary its response over time. The +continuation does not receive the endpoint instance, so decide based on state you +control rather than on the request's components. Because the closure is `@Sendable`, +keep that state in an actor: ```swift +actor ResponseSequence { + private var names = ["Administrator", "Regular User"] + + func next() -> String { + names.isEmpty ? "Guest" : names.removeFirst() + } +} + @Test func testDynamicResponse() async throws { + let sequence = ResponseSequence() + try await withMock(MyEndpoint.self) { continuation in - // Access the endpoint being requested - let endpoint = continuation.endpoint - - // Return different responses based on the request - if endpoint.pathComponents.userId == "admin" { - continuation.resume(returning: .init(userId: "admin", name: "Administrator")) - } else { - continuation.resume(returning: .init(userId: "user", name: "Regular User")) - } + continuation.resume(returning: .init(userId: "1", name: await sequence.next())) } test: { - let adminEndpoint = MyEndpoint(pathComponents: .init(userId: "admin")) - let adminResponse = try await URLSession.shared.response(with: adminEndpoint) - #expect(adminResponse.name == "Administrator") + let first = try await URLSession.shared.response(with: MyEndpoint(pathComponents: .init(userId: "1"))) + let second = try await URLSession.shared.response(with: MyEndpoint(pathComponents: .init(userId: "2"))) + + #expect(first.name == "Administrator") + #expect(second.name == "Regular User") } } ``` @@ -173,23 +180,48 @@ The mock applies to all requests of the specified endpoint type within the test ```swift @Test func testMultipleRequests() async throws { - var callCount = 0 - + let calls = CallCounter() + try await withMock(MyEndpoint.self) { continuation in - callCount += 1 - continuation.resume(returning: .init(userId: "\(callCount)", name: "User \(callCount)")) + let count = await calls.increment() + continuation.resume(returning: .init(userId: "\(count)", name: "User \(count)")) } test: { - let endpoint1 = MyEndpoint(pathComponents: .init(userId: "1")) - let response1 = try await URLSession.shared.response(with: endpoint1) - - let endpoint2 = MyEndpoint(pathComponents: .init(userId: "2")) - let response2 = try await URLSession.shared.response(with: endpoint2) - - #expect(callCount == 2) + let response1 = try await URLSession.shared.response(with: MyEndpoint(pathComponents: .init(userId: "1"))) + let response2 = try await URLSession.shared.response(with: MyEndpoint(pathComponents: .init(userId: "2"))) + + #expect(await calls.value == 2) #expect(response1.name == "User 1") #expect(response2.name == "User 2") } } + +actor CallCounter { + private(set) var value = 0 + + func increment() -> Int { + value += 1 + return value + } +} +``` + +### Mocking Several Endpoints at Once + +When a flow touches more than one endpoint, register them together instead of nesting +`withMock` calls. Endpoint types without a registered mock pass through to the real +transport, nested scopes merge, and an inner mock for the same endpoint type shadows +the outer one for the duration of its scope: + +```swift +@Test func testProfileAfterRefresh() async throws { + 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 URLSession.shared.response(with: ProfileEndpoint()) + #expect(profile.name == "Zac") + } +} ``` ## Combine Support @@ -203,6 +235,7 @@ import EndpointsMocking @preconcurrency import Combine @Suite("Combine Mocking") +@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) struct CombineMockingTests { @Test func testCombinePublisher() async throws { @@ -218,13 +251,15 @@ struct CombineMockingTests { } } -// Helper to await publisher values -@available(iOS 15.0, *) -extension AnyPublisher where Output: Sendable { - var awaitFirst: Output { - get async throws { - try await self.first().asyncThrowing() +// Helper to await the first value of a publisher. +// `Publisher.values` needs iOS 15 / macOS 12, so annotate it for older deployment targets. +@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) +extension Publisher where Output: Sendable { + func awaitFirst() async throws -> Output { + for try await value in values { + return value } + throw CancellationError() } } ``` @@ -288,17 +323,12 @@ extension AnyPublisher where Output: Sendable { ## Best Practices -### 1. Use Type-Specific Mocks +### 1. Mock at the Endpoint Type -Always specify the endpoint type explicitly to ensure type safety: - -```swift -// Good -withMock(MySpecificEndpoint.self) { ... } - -// Avoid (if possible) -withMock(endpoint) { ... } -``` +Mocks are keyed by endpoint type, so one `withMock(MyEndpoint.self)` covers every +request of that type inside the block regardless of the instance's path, query, or body +values. Keep endpoint types focused so a mock does not have to answer for unrelated +requests. ### 2. Organize Mock Data @@ -355,19 +385,38 @@ state — pass the one you want and nothing leaks into other tests: } ``` -## Limitations +## Authentication and Mocks -- Mocking only works in DEBUG builds (disabled in release builds) -- Mocking applies to all instances of an endpoint type within the test block -- You cannot selectively mock some requests and not others within the same block +A mocked request short-circuits before authentication: the endpoint's +``AuthenticationMethod`` is never invoked, no credentials are applied, and a mock error +does not enter the refresh/retry loop. To simulate an authentication failure, throw one +directly: -## Migration from Old Mocking +```swift +try await withMock(ProfileEndpoint.self, action: .throw(.authenticationError(.notAuthenticated))) { + await #expect(throws: ProfileEndpoint.TaskError.self) { + try await URLSession.shared.response(with: ProfileEndpoint()) + } +} +``` -If you were previously using a different mocking approach, the new `withMock` API offers several advantages: +To exercise the refresh flow itself — credentials applied, a 401, a refresh, a retry — +use a `URLProtocol`-based fake transport on a dedicated `URLSession` instead of a mock. -1. **No URLSession swizzling needed** - Clean, Swift-native approach -2. **Type-safe** - Mock responses are checked at compile time -3. **Async-native** - Built for Swift's async/await -4. **Combine support** - Works with both async and Combine APIs +## Limitations -Replace manual URLProtocol mocking or stubbing with `withMock` for cleaner, more maintainable tests. +- Mocking only works in DEBUG builds and on Apple platforms; the `EndpointsMocking` + module is not built on Linux. +- A mock applies to every request of its endpoint type within the block. To vary the + response between requests, use a dynamic mock as shown above. +- Mocks bypass authentication entirely (see above). + +## How It Works + +`withMock` stores the registered mocks in task-local state, so they are visible to +every request made from within the `test` closure — including from child tasks — and +invisible to anything running concurrently outside it. The async/await and Combine +request methods consult that state before building a request. The closure-based +`endpointTask` cannot, because it returns a `URLSessionDataTask` synchronously; for that +path the library swizzles `URLSessionTask.resume()` in DEBUG builds so the task delivers +the mock instead of hitting the network. diff --git a/Sources/Endpoints/EnvironmentType.swift b/Sources/Endpoints/EnvironmentType.swift deleted file mode 100644 index 73bc6d1..0000000 --- a/Sources/Endpoints/EnvironmentType.swift +++ /dev/null @@ -1,170 +0,0 @@ -// -// EnvironmentType.swift -// Endpoints -// -// Created by Zac White on 1/26/19. -// Copyright © 2019 Velos Mobile LLC. All rights reserved. -// - -import Foundation - -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -/// Standard environment types used by most servers. -/// -/// Use these as a starting point, or define your own environment enum. -public enum TypicalEnvironments: String, CaseIterable, Sendable { - case local - case development - case staging - case production -} - -/// Defines the server configuration for endpoints. -/// -/// Conform to this protocol to create a server definition that specifies -/// base URLs for different environments and request processing behavior. -/// -/// ```swift -/// struct ApiServer: ServerDefinition { -/// var baseUrls: [Environments: URL] { -/// return [ -/// .staging: URL(string: "https://staging-api.example.com")!, -/// .production: URL(string: "https://api.example.com")! -/// ] -/// } -/// -/// static var defaultEnvironment: Environments { .production } -/// } -/// ``` -public protocol ServerDefinition: Sendable { - /// The environment type for this server. Defaults to ``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() - - /// Maps environments to their base URLs. - var baseUrls: [Environments: URL] { get } - - /// Optional request processor to modify requests before sending. - /// - /// 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 } -} - -public extension ServerDefinition { - /// Default passthrough request processor that returns the request unchanged. - 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 [ - .local: URL(string: "https://local-api.velos.com")!, - .staging: URL(string: "https://staging-api.velos.com")!, - .production: URL(string: "https://api.velos.com")! - ] - } - - static var defaultEnvironment: Environments { - #if DEBUG - .staging - #else - .production - #endif - } - - static let api = Self() -} - -struct TestEndpoint: Endpoint { - typealias Server = ApiServer - typealias Response = Void - static let definition: Definition = Definition(server: ApiServer.api, method: .get, path: "/") -} - -/// A generic server implementation that can be used as a default for simple endpoints. -/// Supports multiple environments (development, staging, production) with configurable base URLs. -public struct GenericServer: ServerDefinition { - public let baseUrls: [Environments: URL] - public let requestProcessor: @Sendable (URLRequest) -> URLRequest - - /// Creates a GenericServer with the given base URLs for different environments. - /// - Parameters: - /// - local: URL for local development (optional) - /// - development: URL for development environment (optional) - /// - staging: URL for staging environment (optional) - /// - production: URL for production environment (optional) - /// - requestProcessor: Optional request processor for modifying requests (default: passthrough) - public init( - local: URL? = nil, - development: URL? = nil, - staging: URL? = nil, - production: URL? = nil, - requestProcessor: @Sendable @escaping (URLRequest) -> URLRequest = { $0 } - ) { - var urls: [Environments: URL] = [:] - if let local { urls[.local] = local } - if let development { urls[.development] = development } - if let staging { urls[.staging] = staging } - if let production { urls[.production] = production } - self.baseUrls = urls - self.requestProcessor = requestProcessor - } - - /// Creates a GenericServer with a single base URL used for all environments. - /// - Parameters: - /// - baseUrl: The base URL to use for all environments - /// - requestProcessor: Optional request processor for modifying requests (default: passthrough) - public init(baseUrl: URL, requestProcessor: @Sendable @escaping (URLRequest) -> URLRequest = { $0 }) { - self.baseUrls = [ - .local: baseUrl, - .development: baseUrl, - .staging: baseUrl, - .production: baseUrl - ] - self.requestProcessor = requestProcessor - } - - /// Required parameterless initializer for ServerDefinition conformance. - /// Creates a GenericServer with no base URLs configured. - /// Note: You must set base URLs using the `baseUrls` property or use a different initializer. - public init() { - self.baseUrls = [:] - self.requestProcessor = { @Sendable in $0 } - } - - public static var defaultEnvironment: Environments { .production } -} diff --git a/Sources/Endpoints/Extensions/URLSession+Async.swift b/Sources/Endpoints/Extensions/URLSession+Async.swift index 88b1ab3..349f6a3 100644 --- a/Sources/Endpoints/Extensions/URLSession+Async.swift +++ b/Sources/Endpoints/Extensions/URLSession+Async.swift @@ -35,8 +35,10 @@ public extension URLSession { /// 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. + /// - 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, @@ -48,8 +50,10 @@ public extension URLSession { /// 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. + /// - 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, diff --git a/Sources/Endpoints/Extensions/URLSession+Combine.swift b/Sources/Endpoints/Extensions/URLSession+Combine.swift index 10626af..2c98d6e 100644 --- a/Sources/Endpoints/Extensions/URLSession+Combine.swift +++ b/Sources/Endpoints/Extensions/URLSession+Combine.swift @@ -99,6 +99,10 @@ public extension URLSession { /// subscription cancels the underlying request. /// - Parameters: /// - endpoint: The request data to insert into the ``Definition`` + /// - 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``. /// - 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, @@ -117,6 +121,10 @@ public extension URLSession { /// subscription cancels the underlying request. /// - Parameters: /// - endpoint: The request data to insert into the ``Definition`` + /// - 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``. /// - 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, @@ -135,6 +143,10 @@ public extension URLSession { /// subscription cancels the underlying request. /// - Parameters: /// - endpoint: The request data to insert into the ``Definition`` + /// - 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``. /// - 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, diff --git a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift index ddcb144..b36da8e 100644 --- a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift +++ b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift @@ -60,6 +60,8 @@ public extension URLSession { /// Note: This does not start the request. That must be done with `resume()`. /// - Parameters: /// - endpoint: The request data to use when filling in the ``Definition`` + /// - environment: The environment to resolve the base URL against. Defaults to the + /// server's ``ServerDefinition/defaultEnvironment``. /// - 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. @@ -99,6 +101,8 @@ public extension URLSession { /// Note: This does not start the request. That must be done with `resume()`. /// - Parameters: /// - endpoint: The request data to use when filling in the ``Definition`` + /// - environment: The environment to resolve the base URL against. Defaults to the + /// server's ``ServerDefinition/defaultEnvironment``. /// - 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. @@ -136,6 +140,8 @@ public extension URLSession { /// Note: This does not start the request. That must be done with `resume()`. /// - Parameters: /// - endpoint: The request data to use when filling in the ``Definition`` + /// - environment: The environment to resolve the base URL against. Defaults to the + /// server's ``ServerDefinition/defaultEnvironment``. /// - 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. diff --git a/Sources/Endpoints/MultipartFormEncoder.swift b/Sources/Endpoints/MultipartFormEncoder.swift index 3c7dfe6..32d934a 100644 --- a/Sources/Endpoints/MultipartFormEncoder.swift +++ b/Sources/Endpoints/MultipartFormEncoder.swift @@ -1,6 +1,6 @@ import Foundation -/// An encoder that transforms an ``Encodable`` body into `multipart/form-data`. +/// An encoder that transforms an `Encodable` body into `multipart/form-data`. /// /// Use this encoder by overriding ``Endpoint/bodyEncoder`` for endpoints that require multipart payloads. public final class MultipartFormEncoder: EncoderType { @@ -122,7 +122,7 @@ fileprivate protocol MultipartFormJSONProtocol { func _encodeJSON(to encoder: _MultipartFormDataEncoder, path: [CodingKey]) throws } -/// Wraps an ``Encodable`` value so it is embedded as a JSON part within a multipart payload. +/// Wraps an `Encodable` value so it is embedded as a JSON part within a multipart payload. public struct MultipartFormJSON: Encodable, Sendable { public let value: Value fileprivate let jsonEncoder: JSONEncoder diff --git a/Sources/Endpoints/Server.swift b/Sources/Endpoints/Server.swift index 6aba352..64f4376 100644 --- a/Sources/Endpoints/Server.swift +++ b/Sources/Endpoints/Server.swift @@ -2,14 +2,159 @@ // Server.swift // Endpoints // -// Created by Zac White on 11/27/24. +// Created by Zac White on 1/26/19. +// Copyright © 2019 Velos Mobile LLC. All rights reserved. // import Foundation -// 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. +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Standard environment types used by most servers. +/// +/// Use these as a starting point, or define your own environment enum. +public enum TypicalEnvironments: String, CaseIterable, Sendable { + case local + case development + case staging + case production +} + +/// Defines the server configuration for endpoints. +/// +/// Conform to this protocol to create a server definition that specifies +/// base URLs for different environments, the authentication method its endpoints +/// share, and request processing behavior. +/// +/// 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, which is what +/// lets two clients talk to different environments at the same time. +/// ``defaultEnvironment`` provides the default for those parameters. +/// +/// Credentials are usually bound to one environment: a token issued by staging is not +/// valid against production. A server-wide ``auth`` therefore suits apps that talk to +/// a single environment at a time. An app that switches environments per request +/// should pass matching credentials with the `auth:` parameter, and may leave the +/// server's ``auth`` undeclared. +/// +/// ```swift +/// struct ApiServer: ServerDefinition { +/// var baseUrls: [Environments: URL] { +/// return [ +/// .staging: URL(string: "https://staging-api.example.com")!, +/// .production: URL(string: "https://api.example.com")! +/// ] +/// } +/// +/// static var defaultEnvironment: Environments { .production } +/// } +/// ``` +public protocol ServerDefinition: Sendable { + /// The environment type for this server. Defaults to ``TypicalEnvironments``. + /// + /// Must be `Sendable`: environments are passed per request and may cross task + /// boundaries. + 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() + + /// Maps environments to their base URLs. + var baseUrls: [Environments: URL] { get } + + /// Optional request processor to modify requests before sending. + /// + /// 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 } +} + +public extension ServerDefinition { + /// Default passthrough request processor that returns the request unchanged. + 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() } +} + +/// A generic server implementation that can be used as a default for simple endpoints. +/// Supports multiple environments (development, staging, production) with configurable base URLs. +public struct GenericServer: ServerDefinition { + public let baseUrls: [Environments: URL] + public let requestProcessor: @Sendable (URLRequest) -> URLRequest + + /// Creates a GenericServer with the given base URLs for different environments. + /// - Parameters: + /// - local: URL for local development (optional) + /// - development: URL for development environment (optional) + /// - staging: URL for staging environment (optional) + /// - production: URL for production environment (optional) + /// - requestProcessor: Optional request processor for modifying requests (default: passthrough) + public init( + local: URL? = nil, + development: URL? = nil, + staging: URL? = nil, + production: URL? = nil, + requestProcessor: @Sendable @escaping (URLRequest) -> URLRequest = { $0 } + ) { + var urls: [Environments: URL] = [:] + if let local { urls[.local] = local } + if let development { urls[.development] = development } + if let staging { urls[.staging] = staging } + if let production { urls[.production] = production } + self.baseUrls = urls + self.requestProcessor = requestProcessor + } + + /// Creates a GenericServer with a single base URL used for all environments. + /// - Parameters: + /// - baseUrl: The base URL to use for all environments + /// - requestProcessor: Optional request processor for modifying requests (default: passthrough) + public init(baseUrl: URL, requestProcessor: @Sendable @escaping (URLRequest) -> URLRequest = { $0 }) { + self.baseUrls = [ + .local: baseUrl, + .development: baseUrl, + .staging: baseUrl, + .production: baseUrl + ] + self.requestProcessor = requestProcessor + } + + /// Required parameterless initializer for ServerDefinition conformance. + /// + /// Creates a GenericServer with no base URLs configured, so every request built + /// against it fails with ``EndpointError/misconfiguredServer(server:)``. Pass a + /// configured instance to ``Definition/init(server:method:path:parameters:headers:)`` + /// instead. + public init() { + self.baseUrls = [:] + self.requestProcessor = { @Sendable in $0 } + } + + public static var defaultEnvironment: Environments { .production } +} diff --git a/Tests/EndpointsTests/JWTAuthSupersededRefreshTests.swift b/Tests/EndpointsTests/JWTAuthSupersededRefreshTests.swift new file mode 100644 index 0000000..bae5482 --- /dev/null +++ b/Tests/EndpointsTests/JWTAuthSupersededRefreshTests.swift @@ -0,0 +1,202 @@ +import Testing +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +@testable import Endpoints + +/// `setTokens`/`clearTokens` are the caller's newer intent: a refresh that was already +/// in flight when they were called must not overwrite (or persist over) that intent. +@Suite("JWTAuth superseded refresh") +struct JWTAuthSupersededRefreshTests { + + private static func staleRequest() -> URLRequest { + var request = URLRequest(url: URL(string: "https://example.com")!) + request.setValue("Bearer old", forHTTPHeaderField: Header.authorization.name) + return request + } + + @Test + func clearingTokensDuringRefreshDoesNotResurrectThem() async throws { + let refreshStarted = Gate() + let releaseRefresh = Gate() + let persisted = PersistedTokens() + let failures = RefreshCounter() + + let auth = JWTAuth( + initialTokens: .init(accessToken: "old", refreshToken: "refresh"), + refreshHandler: { refreshToken in + await refreshStarted.open() + await releaseRefresh.wait() + return JWTAuth.TokenPair(accessToken: "refreshed", refreshToken: refreshToken) + }, + onTokensUpdated: { await persisted.set($0) }, + onRefreshFailed: { _ in await failures.increment() } + ) + + let refreshTask = Task { try await auth.reauthenticate(after: Self.staleRequest()) } + await refreshStarted.wait() + + // The user logs out while the refresh is in flight. + await auth.clearTokens() + await releaseRefresh.open() + try await refreshTask.value + + #expect(await auth.tokens == nil) + #expect(await persisted.value == nil) + // Being superseded is not a refresh failure, so no logout handler fires. + #expect(await failures.value() == 0) + } + + @Test + func settingTokensDuringRefreshWins() async throws { + let refreshStarted = Gate() + let releaseRefresh = Gate() + let persisted = PersistedTokens() + + let auth = JWTAuth( + initialTokens: .init(accessToken: "old", refreshToken: "refresh"), + refreshHandler: { refreshToken in + await refreshStarted.open() + await releaseRefresh.wait() + return JWTAuth.TokenPair(accessToken: "refreshed", refreshToken: refreshToken) + }, + onTokensUpdated: { await persisted.set($0) } + ) + + let refreshTask = Task { try await auth.reauthenticate(after: Self.staleRequest()) } + await refreshStarted.wait() + + // A fresh login lands while the refresh is in flight. + let loginTokens = JWTAuth.TokenPair(accessToken: "login", refreshToken: "login-refresh") + await auth.setTokens(loginTokens) + await releaseRefresh.open() + try await refreshTask.value + + #expect(await auth.tokens == loginTokens) + #expect(await persisted.value == nil) + } + + /// An expired token makes `authenticate` start the refresh itself, so once the + /// handler is running the request is known to be suspended on it. + private static func expiringAuth(refreshStarted: Gate, releaseRefresh: Gate) -> JWTAuth { + JWTAuth( + initialTokens: .init(accessToken: "old", refreshToken: "refresh", expiresAt: Date(timeIntervalSinceNow: -60)), + refreshHandler: { refreshToken in + await refreshStarted.open() + await releaseRefresh.wait() + return JWTAuth.TokenPair(accessToken: "refreshed", refreshToken: refreshToken) + } + ) + } + + @Test + func requestWaitingOnSupersededRefreshUsesTheNewTokens() async throws { + let refreshStarted = Gate() + let releaseRefresh = Gate() + let auth = Self.expiringAuth(refreshStarted: refreshStarted, releaseRefresh: releaseRefresh) + + let authenticated = Task { + try await auth.authenticate(request: URLRequest(url: URL(string: "https://example.com")!)) + } + await refreshStarted.wait() + + // A login lands while the request is waiting on the refresh. + let loginTokens = JWTAuth.TokenPair(accessToken: "login", refreshToken: "login-refresh") + await auth.setTokens(loginTokens) + await releaseRefresh.open() + + let request = try await authenticated.value + #expect(request.value(forHTTPHeaderField: Header.authorization.name) == "Bearer login") + #expect(await auth.tokens == loginTokens) + } + + @Test + func requestWaitingOnRefreshFailsAfterLogout() async throws { + let refreshStarted = Gate() + let releaseRefresh = Gate() + let auth = Self.expiringAuth(refreshStarted: refreshStarted, releaseRefresh: releaseRefresh) + + let authenticated = Task { + try await auth.authenticate(request: URLRequest(url: URL(string: "https://example.com")!)) + } + await refreshStarted.wait() + + // The user logs out while the request is waiting on the refresh. + await auth.clearTokens() + await releaseRefresh.open() + + do { + _ = try await authenticated.value + Issue.record("Expected notAuthenticated error") + } catch { + guard case AuthenticationError.notAuthenticated = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + #expect(await auth.tokens == nil) + } + + /// Several requests share one refresh. Only one of them may commit its result: + /// otherwise a logout that lands after the first waiter resumes is undone when a + /// later waiter resumes and commits the same tokens again. + /// + /// The interleaving can't be forced from outside the actor, so the later waiter + /// runs at background priority to make it resume last, and the scenario repeats. + @Test + func laterWaiterDoesNotUndoLogoutAfterSharedRefresh() async throws { + for _ in 0..<100 { + 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: "refreshed", refreshToken: refreshToken) + } + ) + + let first = Task(priority: .high) { try await auth.reauthenticate(after: Self.staleRequest()) } + await refreshStarted.wait() + let later = Task(priority: .background) { try? await auth.reauthenticate(after: Self.staleRequest()) } + // Let the later waiter reach the in-flight refresh. + try await Task.sleep(nanoseconds: 1_000_000) + + await releaseRefresh.open() + try await first.value + await auth.clearTokens() + await later.value + + #expect(await auth.tokens == nil) + } + } + + @Test + func emptyTokenPrefixSendsBareToken() async throws { + let auth = JWTAuth( + initialTokens: .init(accessToken: "token", refreshToken: "refresh"), + configuration: .init(header: "X-Access-Token", tokenPrefix: ""), + refreshHandler: { refreshToken in + JWTAuth.TokenPair(accessToken: "new", refreshToken: refreshToken) + } + ) + + let authenticated = try await auth.authenticate(request: URLRequest(url: URL(string: "https://example.com")!)) + #expect(authenticated.value(forHTTPHeaderField: "X-Access-Token") == "token") + + // The staleness check must agree with what was sent, so a bare-token 401 + // still refreshes. + try await auth.reauthenticate(after: authenticated) + #expect(await auth.tokens?.accessToken == "new") + } +} + +actor PersistedTokens { + private(set) var value: JWTAuth.TokenPair? + func set(_ tokens: JWTAuth.TokenPair) { value = tokens } +} From 87e9713fca25aa59a1c562402b78e2e950d4fce9 Mon Sep 17 00:00:00 2001 From: Zac White Date: Tue, 6 Oct 2026 10:02:59 -0700 Subject: [PATCH 2/5] Rewrite docs for accuracy and plain wording - README: shorter, with installation up front, a single getting-started path, accurate requirements and migration notes from 0.4, and working links. The wiki it linked to doesn't exist. - DocC: new Authentication article. Examples now compile: they use ParameterComponents/HeaderComponents, the real multipart types, and the correct errorResponse pattern. Mocking documents the actual behavior of .fail and .none, typed-error matching inside withMock, a URLProtocol example for testing refresh, and building requests without sending them. - Doc comments: document every public enum case, fix comments that described the wrong encoder or decoder, and use American spelling. Drop filler and em-dashes. The README and guide examples were compiled and run against the library before committing. The only non-comment change is the wording of a debug assertion message. --- README.md | 325 ++++----- .../Authentication/AuthenticationError.swift | 21 +- .../Authentication/AuthenticationMethod.swift | 50 +- .../AuthenticationStability.swift | 16 +- .../Implementations/BasicAuth.swift | 4 +- .../Implementations/CookieAuth.swift | 8 +- .../Implementations/HeaderKeyAuth.swift | 17 +- .../Implementations/JWTAuth.swift | 114 ++- .../Implementations/NoAuth.swift | 2 +- .../Endpoints/Definition+URLResponse.swift | 13 +- Sources/Endpoints/Endpoint.swift | 128 ++-- .../Endpoints.docc/Authentication.md | 207 ++++++ Sources/Endpoints/Endpoints.docc/Endpoints.md | 54 +- Sources/Endpoints/Endpoints.docc/Examples.md | 666 ++++++------------ Sources/Endpoints/Endpoints.docc/Mocking.md | 460 +++++------- .../Extensions/URLSession+Async.swift | 25 +- .../Extensions/URLSession+Combine.swift | 42 +- .../Extensions/URLSession+Endpoints.swift | 68 +- Sources/Endpoints/Header.swift | 33 +- Sources/Endpoints/Method.swift | 3 +- .../Endpoints/Mocking/MockContinuation.swift | 40 +- Sources/Endpoints/Mocking/MockRegistry.swift | 22 +- .../Endpoints/ParameterRepresentable.swift | 16 +- Sources/Endpoints/PathTemplate.swift | 17 +- Sources/Endpoints/Server.swift | 86 ++- .../EndpointsMocking/EndpointsMocking.swift | 55 +- 26 files changed, 1195 insertions(+), 1297 deletions(-) create mode 100644 Sources/Endpoints/Endpoints.docc/Authentication.md diff --git a/README.md b/README.md index 8703e45..c6ea2b9 100644 --- a/README.md +++ b/README.md @@ -2,28 +2,42 @@ ![CI](https://github.com/velos/Endpoints/workflows/CI/badge.svg) ![Documentation](https://github.com/velos/Endpoints/workflows/Documentation/badge.svg) -Endpoints is a small library for creating statically and strongly-typed definitions of endpoints with paths, methods, inputs and outputs. +Endpoints describes HTTP endpoints as Swift types: the path, method, parameters, headers, body, and response. From that description it builds a `URLRequest` and decodes the response, using plain `URLSession`. It doesn't replace the URL loading system the way Alamofire does, and the requests it builds work with Alamofire if you prefer that. -## Purpose +- **Typed endpoints.** Paths, query and form parameters, and headers are checked at compile time. +- **Servers and environments.** Each server lists a base URL per environment, and every request can pick its environment. +- **Authentication.** Declare an authentication method per server or per endpoint. Credentials are applied to each request, and refreshable tokens are refreshed and the request retried when needed. +- **Mocking.** The `EndpointsMocking` module replaces responses per endpoint type in tests. +- **Swift 6.** Built for strict concurrency, with `Sendable` types and typed throws. +- **async/await and Combine.** Plus a completion-handler API for unauthenticated endpoints. -The purpose of Endpoints is to, in a type-safe way, define how to create a `URLRequest` from typed properties and, additionally, define how a response for the request should be handled. The library not only includes the ability to create these requests in a type-safe way, but also includes helpers to perform the requests using `URLSession`. Endpoints does not try to wrap the URL loading system to provide features on top of it like Alamofire. Instead, Endpoints focuses on defining endpoints and associated data to produce a request as a `URLRequest` object to be plugged into vanilla `URLSession`s. However, this library could be used in conjunction with Alamofire if desired. +## Installation -## Features +Add the package to your `Package.swift`: -- **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, Sendable support and typed throws -- **Combine and async/await support** - Use either reactive or async patterns +```swift +dependencies: [ + .package(url: "https://github.com/velos/Endpoints.git", from: "0.5.0") +] +``` -## Getting Started +Add `Endpoints` to your app target, and `EndpointsMocking` to your test target: -The basic process for defining an Endpoint starts with defining a value conforming to `Endpoint`. With the `Endpoint` protocol, you are encapsulating the definition of the endpoint, all the properties that are plugged into the definition and the types for parsing the response. Within the `Endpoint`, the `definition` static var serves as an immutable definition of the server's endpoint and how the variable pieces of the `Endpoint` should fit together when making the full request. +```swift +.testTarget( + name: "YourAppTests", + dependencies: [ + .product(name: "Endpoints", package: "Endpoints"), + .product(name: "EndpointsMocking", package: "Endpoints") + ] +) +``` -### Defining a Server +## Getting Started -First, define a server that conforms to `ServerDefinition`. This encapsulates your base URLs for different environments: +### Define a server + +A server lists its base URL for each environment and names the environment requests use by default: ```swift import Endpoints @@ -31,7 +45,7 @@ import Foundation struct ApiServer: ServerDefinition { var baseUrls: [Environments: URL] { - return [ + [ .local: URL(string: "https://local-api.example.com")!, .staging: URL(string: "https://staging-api.example.com")!, .production: URL(string: "https://api.example.com")! @@ -42,89 +56,88 @@ 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`. +`Environments` defaults to `TypicalEnvironments` (`local`, `development`, `staging`, `production`). To use your own cases, declare `typealias Environments = MyEnvironments` with any `Hashable & Sendable` type. -`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. +### Define an endpoint -### Basic Endpoint Example +An endpoint names its server, its `Definition`, and its `Response` type: -```Swift -struct MyEndpoint: Endpoint { +```swift +struct ProfileEndpoint: Endpoint { typealias Server = ApiServer - - static let definition: Definition = Definition( + + static let definition: Definition = Definition( method: .get, - path: "path/to/resource" + path: "users/\(path: \.userId)/profile", + parameters: [ + .query("fields", path: \.fields) + ] ) struct Response: Decodable { - let resourceId: String - let resourceName: String + let name: String + let email: String } -} -``` - -This includes a `Response` associated type (can be typealiased to a more complex existing type) which defines how the response will come back from the request. The server is specified via `typealias Server = ApiServer`. -Then usage can employ the `URLSession` extensions: + struct PathComponents { + let userId: String + } -#### Usage -```Swift -URLSession.shared.endpointPublisher(with: MyEndpoint()) - .sink { completion in - guard case .failure(let error) = completion else { return } - // handle error - } receiveValue: { (response: MyEndpoint.Response) in - // handle MyEndpoint.Response + struct ParameterComponents { + let fields: String? } - .store(in: &cancellables) -``` -Notice that the common case requires no environment or credentials at the call site — both default to what the server and endpoint declare. + let pathComponents: PathComponents + let parameterComponents: ParameterComponents +} +``` -### Runtime context: environment and credentials +A `Decodable` response is decoded with `JSONDecoder` by default. Use `Data` to receive the raw body, or `Void` to ignore it. `nil` query and form values are left out of the request. See the [Examples](Sources/Endpoints/Endpoints.docc/Examples.md) guide for headers, request bodies, form parameters, multipart uploads, custom encoders and decoders, and typed error responses. -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: +### Make a request ```swift -let response = try await URLSession.shared.response( - with: ProfileEndpoint(), - environment: client.environment, - auth: client.auth +let endpoint = ProfileEndpoint( + pathComponents: .init(userId: "42"), + parameterComponents: .init(fields: nil) ) + +let profile = try await URLSession.shared.response(with: endpoint) ``` -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: +With Combine: ```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) +URLSession.shared.endpointPublisher(with: endpoint) + .sink { completion in + if case .failure(let error) = completion { + // handle ProfileEndpoint.TaskError + } + } receiveValue: { profile in + // handle ProfileEndpoint.Response } -} + .store(in: &cancellables) ``` -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. +Canceling the subscription cancels the request. For unauthenticated endpoints, `endpointTask(with:completion:)` returns a `URLSessionDataTask` that you start with `resume()`. + +To build the request without sending it, call `endpoint.urlRequest()`. -Credentials are usually bound to one environment: a token issued by staging is not valid against production. A server-wide `static let auth` therefore suits apps that talk to a single environment at a time. If your app switches environments per request, pass the matching credentials alongside — keep one `JWTAuth` per environment or per account, as in the client above — rather than sharing one instance across environments. A server whose credentials always arrive per request need not declare `auth` at all; it defaults to `NoAuth`, and the `auth:` parameter accepts any `AuthenticationMethod`. +## Environments -### Async/Await +Every request method takes an `environment:` argument, which defaults to the server's `defaultEnvironment`: ```swift -do { - let response = try await URLSession.shared.response(with: MyEndpoint()) - // handle response -} catch { - // handle error -} +let profile = try await URLSession.shared.response(with: endpoint, environment: .staging) ``` +The environment is a per-request value, not global state, so different parts of an app can use different environments at the same time. + +A server can also provide a `requestProcessor`, a synchronous hook that runs on every request after it is built. Use it for static changes such as adding a build-number header. Use authentication for credentials. + ## 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: +A server declares the authentication method its endpoints use, and every endpoint on that server inherits it: ```swift struct ApiServer: ServerDefinition { @@ -133,27 +146,16 @@ struct ApiServer: ServerDefinition { 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: +Requests then use the same `URLSession` methods, and credentials are applied automatically. A server that declares no `auth` uses `NoAuth`. -```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: +An endpoint can override its server's method, either to opt out or to use a different scheme: ```swift struct LoginEndpoint: Endpoint { typealias Server = ApiServer - static var auth: NoAuth { NoAuth() } // unauthenticated + static var auth: NoAuth { NoAuth() } ... } @@ -164,34 +166,31 @@ struct MetricsEndpoint: Endpoint { } ``` -Declare the method as a `static let` so all endpoints on a server share one instance. That shared instance is what allows a stateful method like `JWTAuth` to coalesce concurrent token refreshes across every endpoint — 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. +Declare authentication methods with `static let`, so every request shares one instance. A stateful method like `JWTAuth` keeps its tokens on that instance and relies on it to combine concurrent refreshes. A computed `static var` creates a new instance on every request and loses the tokens. Debug builds assert when this happens. -The async/await and Combine APIs both apply authentication, including refresh and retry. Cancelling a Combine subscription cancels the underlying request. +The async/await and Combine APIs apply authentication, including refresh and retry. `endpointTask(with:completion:)` returns its task synchronously, so it can't wait for authentication. It only accepts endpoints whose `auth` is `NoAuth`, and using it with an authenticated endpoint is a compile error. -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 methods -Built-in authentication methods: +| Method | Sends | +|---|---| +| `HeaderKeyAuth` | A static key in a header. Defaults to `Authorization: Bearer `. Pass `header:` and `prefix: nil` for headers like `X-API-Key: `. | +| `BasicAuth` | HTTP Basic credentials ([RFC 7617](https://www.rfc-editor.org/rfc/rfc7617)), UTF-8 encoded. | +| `CookieAuth` | A static cookie, merged with any cookies already on the request. | +| `JWTAuth` | An access token, refreshed with a refresh token when it expires or is rejected. | +| `NoAuth` | Nothing. The request passes through unchanged. | -- `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. +### Refreshing tokens with JWTAuth -### Token refresh with JWTAuth +`JWTAuth` holds an access token and a refresh token. When a response has a status code in `refreshTriggerStatusCodes` (401 by default), it calls your `refreshHandler` and retries the request with the new tokens. Concurrent requests that need a refresh share one call to the handler. A request rejected with tokens that have already been replaced doesn't trigger another refresh, which matters when your backend issues single-use refresh tokens. -`JWTAuth` holds an access/refresh token pair. When a request fails with a status code in `refreshTriggerStatusCodes` (401 by default), your `refreshHandler` is called and the request is retried with the new tokens. Concurrent refreshes are coalesced into a single operation, and a request that fails with already-replaced tokens will not trigger a redundant refresh — important when your backend rotates single-use refresh tokens. - -If you know when the access token expires, set `TokenPair.expiresAt`: tokens within `expiryLeeway` (30 seconds by default) of expiring are then refreshed *before* the request is sent, skipping the round trip that would have been rejected. Without `expiresAt`, refresh is purely reactive. +If you know when the access token expires, set `TokenPair.expiresAt`. A token within `expiryLeeway` (30 seconds by default) of expiring is then refreshed before the request is sent, which saves a rejected round trip. ```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) }, @@ -208,15 +207,36 @@ struct ApiServer: ServerDefinition { } ``` -> **Important:** the refresh endpoint must not be authenticated by the same `JWTAuth` — the request would wait on the very refresh that is waiting on it. 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. +> **Important:** Give the refresh endpoint `static var auth: NoAuth { NoAuth() }`. It authenticates with the refresh token, and a request authenticated by the same `JWTAuth` would wait for the refresh that is waiting for it. If that happens, the request fails with a `RefreshReentrancyError` instead of hanging. + +After a login or logout, call `await ApiServer.auth.setTokens(_:)` or `await ApiServer.auth.clearTokens()`. Either call replaces a refresh that is still in progress. That refresh's tokens are discarded, so a refresh that started just before a logout can't sign the user back in. Requests that were waiting for it use the new tokens, or fail with `.notAuthenticated` after a logout. + +`onTokensUpdated` isn't called for a discarded refresh, but a call that has already started can't be stopped. If your token store must never hold tokens after a logout, have it reject writes from a session that has since been signed out. + +To send the token in a different header or without a prefix, pass a configuration such as `JWTAuth.Configuration(header: "X-Access-Token", tokenPrefix: "")`. + +### Credentials and environments -After a login or logout, update the tokens with `await ApiServer.auth.setTokens(_:)` or `await ApiServer.auth.clearTokens()`. Either call supersedes a refresh that is still in flight: its result is discarded rather than committed, so a refresh that started just before a logout cannot sign the user back in, and requests that were waiting on it proceed with the new tokens (or fail with `.notAuthenticated` after a logout). `onTokensUpdated` is not called for a superseded refresh, but a call that has already started cannot be revoked; if your store must never hold tokens after a logout, have it reject writes from a session that has since been signed out. +Credentials usually belong to one environment: a token issued by staging isn't valid against production. A server-wide `static let auth` fits an app that talks to one environment at a time. -Use `JWTAuth.Configuration` to send the token in a different header or without a prefix, for example `.init(header: "X-Access-Token", tokenPrefix: "")`. +An app that talks to several environments or accounts at once should pass the environment and credentials together on each request: -### Custom authentication methods +```swift +struct ApiClient { + let environment: ApiServer.Environments + let auth: JWTAuth // one instance per client, so its refreshes are shared + + func profile(_ endpoint: ProfileEndpoint) async throws -> ProfileEndpoint.Response { + try await URLSession.shared.response(with: endpoint, environment: environment, auth: auth) + } +} +``` + +`auth:` accepts any `AuthenticationMethod` and defaults to the endpoint's `auth`. Two clients like this can make requests concurrently without affecting each other. A server whose credentials always come from the call site doesn't need to declare `auth`. + +### Custom methods -Conform to `AuthenticationMethod` to implement your own scheme. Only `authenticate(request:)` is required; refreshable credentials also implement `shouldReauthenticate(for:response:)` and `reauthenticate(after:)`: +Conform to `AuthenticationMethod`. Only `authenticate(request:)` is required: ```swift struct SignatureAuth: AuthenticationMethod { @@ -230,120 +250,79 @@ struct SignatureAuth: AuthenticationMethod { } ``` -For failures that don't fit the built-in `AuthenticationError` cases (credential storage errors, signing failures), wrap them in `AuthenticationError.custom(underlying:)`. +Credentials that can be refreshed also implement `shouldReauthenticate(for:response:)` and `reauthenticate(after:)`, and can set `maxRetryAttempts`. Wrap failures that don't match a built-in `AuthenticationError` case, such as a keychain error, in `AuthenticationError.custom(underlying:)`. -### Error handling +## Error Handling -All failures — including authentication failures — surface as the endpoint's typed `EndpointTaskError`, so a single `catch` covers everything: +Every failure, including authentication failures, is thrown as the endpoint's `TaskError`, so a `catch` needs no casting: ```swift do { - let response = try await URLSession.shared.response(with: MyEndpoint()) + let profile = try await URLSession.shared.response(with: endpoint) } catch { - // error is MyEndpoint.TaskError — no casting needed switch error { + case .errorResponse(let httpResponse, let errorResponse): + // The server returned an error, decoded as the endpoint's ErrorResponse type. case .authenticationError(.refreshFailed(let underlying)): - // token refresh failed; underlying holds the refresh error - case .errorResponse(_, let errorResponse): - // typed server error response + // The token refresh failed. + case .internetConnectionOffline: + // The device is offline. default: break } } ``` -## Testing with EndpointsMocking +## Testing -Endpoints includes a comprehensive mocking system through the `EndpointsMocking` module: +`withMock` from `EndpointsMocking` replaces the response for an endpoint type inside a test: ```swift import Testing import Endpoints import EndpointsMocking -@Test func testMyEndpoint() async throws { - try await withMock(MyEndpoint.self, action: .return(.init(resourceId: "123", resourceName: "Test"))) { - let response = try await URLSession.shared.response(with: MyEndpoint()) - #expect(response.resourceId == "123") +@Test func loadsProfile() async throws { + try await withMock(ProfileEndpoint.self, action: .return(.init(name: "Zac", email: "zac@example.com"))) { + let profile = try await URLSession.shared.response(with: endpoint) + #expect(profile.name == "Zac") } } ``` -When a flow touches several endpoints, register them together with a `MockRegistry` instead of nesting `withMock` calls: +To mock several endpoint types at once, register them together: ```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"))) + mocks.register(ProfileEndpoint.self, action: .return(.init(name: "Zac", email: "zac@example.com"))) } test: { - let profile = try await session.response(with: ProfileEndpoint()) + let profile = try await URLSession.shared.response(with: endpoint) } ``` -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. - -## Examples - -To browse more complex examples, make sure to check out the [Examples](https://github.com/velos/Endpoints/wiki/Examples) wiki page or the documentation in Xcode. +Endpoint types without a mock make real requests. Mocked requests skip authentication. See the [Mocking](Sources/Endpoints/Endpoints.docc/Mocking.md) guide for errors, dynamic responses, and testing the refresh flow. ## Requirements -- Swift 6.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 - -### Swift Package Manager - -Add the following to your `Package.swift`: - -```swift -dependencies: [ - .package(url: "https://github.com/velos/Endpoints.git", from: "0.5.0") -] -``` - -For testing, also add: - -```swift -testTarget( - name: "YourTests", - dependencies: ["Endpoints", "EndpointsMocking"] -) -``` +- Swift 6.0 or later +- iOS 13, macOS 10.15, tvOS 13, or watchOS 6 to define endpoints and build requests +- macOS 12 for the async/await and Combine request methods, and so for authentication. Other platforms have no additional requirement. +- `EndpointsMocking` is available on Apple platforms only. Linux builds include `Endpoints`. ## Documentation -Full documentation is available in Xcode (Product > Build Documentation) and includes: -- API reference for all types -- Comprehensive examples -- Mocking guide -- Best practices - -## Migration from 0.4.0 - -If you're upgrading from version 0.4.0 or earlier, the main changes are: +The [API documentation](https://velosmobile.com/Endpoints/documentation/endpoints/) is published with each release. You can also build it in Xcode with Product > Build Documentation. The [Examples](Sources/Endpoints/Endpoints.docc/Examples.md) and [Mocking](Sources/Endpoints/Endpoints.docc/Mocking.md) guides are readable on GitHub. -1. **ServerDefinition replaces EnvironmentType** - Define your environments in a `ServerDefinition` conforming type -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 +## Migrating from 0.4 -See the [Migration Guide](https://github.com/velos/Endpoints/wiki/Migration) for detailed instructions. +- **Replace `EnvironmentType` with a `ServerDefinition`.** List each environment's base URL in `baseUrls`, and move any `requestProcessor` onto the server. +- **Name the server on each endpoint** with `typealias Server = ApiServer`. +- **Drop the `in:` argument.** `response(in: environment, with: endpoint)` becomes `response(with: endpoint)`, which uses the server's default environment, or `response(with: endpoint, environment: .staging)`. The same applies to `endpointPublisher` and `endpointTask`. `urlRequest(in:)` now takes one of the server's environments. +- **Move credentials to `auth`.** Credentials set in a `requestProcessor` keep working, but declaring them with `static let auth` adds refresh and retry. +- **Remove error casts.** Request methods use typed throws, so the error in a `catch` is already the endpoint's `TaskError`. +- **Update to Swift 6.0.** ## License -Endpoints is released under the MIT license. See LICENSE for details. +Endpoints is released under the MIT license. See [LICENSE](LICENSE) for details. diff --git a/Sources/Endpoints/Authentication/AuthenticationError.swift b/Sources/Endpoints/Authentication/AuthenticationError.swift index bef7af2..a01b002 100644 --- a/Sources/Endpoints/Authentication/AuthenticationError.swift +++ b/Sources/Endpoints/Authentication/AuthenticationError.swift @@ -2,27 +2,28 @@ import Foundation /// Errors that can occur during authentication operations. public enum AuthenticationError: Error, Sendable { - /// No valid credentials are available to authenticate the request. + /// There are no credentials to authenticate the request with. case notAuthenticated - /// No refresh token is available to perform token refresh. + /// There is no refresh token to refresh with. case noRefreshToken - /// The token refresh operation failed. + /// The refresh failed with the underlying error. case refreshFailed(underlying: Error) - /// The authentication method does not support refresh. + /// The authentication method can't refresh its credentials. 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). + /// A failure in a custom ``AuthenticationMethod`` that doesn't match another case, + /// such as a keychain or signing error. 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. +/// The underlying error when a ``JWTAuth`` refresh handler makes a request through +/// that same `JWTAuth`. +/// +/// The request would wait for the refresh, which is waiting for the request, so it +/// fails instead. Give the refresh endpoint ``NoAuth``. public struct RefreshReentrancyError: Error, CustomStringConvertible { let authType: Any.Type diff --git a/Sources/Endpoints/Authentication/AuthenticationMethod.swift b/Sources/Endpoints/Authentication/AuthenticationMethod.swift index 22a4420..4161d84 100644 --- a/Sources/Endpoints/Authentication/AuthenticationMethod.swift +++ b/Sources/Endpoints/Authentication/AuthenticationMethod.swift @@ -4,60 +4,58 @@ import Foundation import FoundationNetworking #endif -/// A protocol that defines how to authenticate requests and handle token refresh. +/// Adds credentials to requests, and optionally refreshes them after a rejected response. public protocol AuthenticationMethod: Sendable { - /// Applies authentication credentials to a request. + /// Returns the request with credentials added. /// - /// - Parameter request: The URLRequest to authenticate. - /// - Returns: The authenticated URLRequest. - /// - Throws: ``AuthenticationError/notAuthenticated`` if no valid credentials are available. + /// Called before every attempt, including retries. + /// - Parameter request: The request to authenticate. + /// - Returns: The authenticated request. + /// - Throws: ``AuthenticationError/notAuthenticated`` if there are no credentials. func authenticate(request: URLRequest) async throws(AuthenticationError) -> URLRequest - /// Determines whether a failed request should trigger reauthentication. + /// Returns whether a failed request should be retried after ``reauthenticate(after:)``. /// + /// Defaults to `false`. /// - Parameters: - /// - error: The error that occurred. - /// - response: The HTTP response, if available. - /// - Returns: `true` if reauthentication should be attempted. + /// - error: The error the request failed with. + /// - response: The HTTP response, if the server returned one. + /// - Returns: `true` to refresh the credentials and retry. func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool - /// Performs reauthentication (e.g., token refresh). + /// Refreshes the credentials after a failed request. /// - /// - 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. + /// Combine concurrent calls into one refresh. If the failed request's credentials no + /// longer match the current ones, another request already refreshed them, so return + /// without refreshing again. Refreshing twice can waste a single-use refresh token. /// - /// Implementations should coalesce concurrent calls into a single refresh operation. + /// Throws ``AuthenticationError/refreshNotSupported`` by default. + /// - Parameter failedRequest: The failed request, as returned by ``authenticate(request:)``. func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) - /// How many times a request may be retried after reauthenticating. Defaults to 1. + /// How many times a request can 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. + /// This limit stops a server that keeps rejecting credentials from causing an + /// endless cycle of requests and refreshes. var maxRetryAttempts: Int { get } } public extension AuthenticationMethod { - /// By default, failed requests never trigger reauthentication. - /// Override for credentials that can be refreshed. + /// Returns `false`, so failed requests are never retried. func shouldReauthenticate(for error: any Error, response: HTTPURLResponse?) -> Bool { false } - /// By default, refresh is unsupported. - /// Override for credentials that can be refreshed. + /// Throws ``AuthenticationError/refreshNotSupported``. func reauthenticate(after failedRequest: URLRequest) async throws(AuthenticationError) { throw AuthenticationError.refreshNotSupported } - /// By default, a request is retried once after reauthenticating. + /// Retries a request once. var maxRetryAttempts: Int { 1 } - /// ``maxRetryAttempts`` normalized to a usable count, so that every transport - /// applies the same bound without repeating the clamp. + /// ``maxRetryAttempts``, with negative values treated as 0. var retryAttempts: Int { max(0, maxRetryAttempts) } } diff --git a/Sources/Endpoints/Authentication/AuthenticationStability.swift b/Sources/Endpoints/Authentication/AuthenticationStability.swift index 75896dd..c33cd37 100644 --- a/Sources/Endpoints/Authentication/AuthenticationStability.swift +++ b/Sources/Endpoints/Authentication/AuthenticationStability.swift @@ -5,13 +5,13 @@ import Foundation /// 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. +/// instance, so every request has to see the same one. `static let auth = JWTAuth(...)` +/// does that. A computed `static var auth: JWTAuth { JWTAuth(...) }` creates a new +/// actor on every access, which loses tokens and starts a separate refresh per request, +/// without any error. /// -/// This catches that during development. It runs once per endpoint type and compiles -/// out of release builds entirely. +/// This check catches that during development. It runs once per endpoint type and +/// isn't compiled into release builds. enum AuthenticationStability { private static let lock = NSLock() nonisolated(unsafe) private static var verified: Set = [] @@ -38,8 +38,8 @@ enum AuthenticationStability { 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. + `static let` so every request shares one instance. A stateful authentication \ + method can't keep credentials or combine refreshes otherwise. """ ) } diff --git a/Sources/Endpoints/Authentication/Implementations/BasicAuth.swift b/Sources/Endpoints/Authentication/Implementations/BasicAuth.swift index 3278cd8..7db02ea 100644 --- a/Sources/Endpoints/Authentication/Implementations/BasicAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/BasicAuth.swift @@ -4,9 +4,9 @@ import Foundation import FoundationNetworking #endif -/// Authentication using HTTP Basic credentials ([RFC 7617](https://www.rfc-editor.org/rfc/rfc7617)). +/// Sends HTTP Basic credentials ([RFC 7617](https://www.rfc-editor.org/rfc/rfc7617)). /// -/// Sends `Authorization: Basic ` with the credentials +/// 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). diff --git a/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift b/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift index 4662b9e..6377836 100644 --- a/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/CookieAuth.swift @@ -4,7 +4,7 @@ import Foundation import FoundationNetworking #endif -/// Authentication using a static HTTP cookie. +/// Sends a static cookie. public struct CookieAuth: AuthenticationMethod { /// The cookie name. public let name: String @@ -12,8 +12,10 @@ public struct CookieAuth: AuthenticationMethod { /// 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. + /// Whether to keep cookies already on the request. Defaults to `true`. + /// + /// When `true`, an existing cookie with the same name is replaced and the others are + /// kept. When `false`, the cookie replaces all existing cookies. public let appendToExisting: Bool public init( diff --git a/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift b/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift index d887a74..8940da1 100644 --- a/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/HeaderKeyAuth.swift @@ -4,27 +4,26 @@ import Foundation import FoundationNetworking #endif -/// Authentication using a static header key. +/// Sends a static key in a header, such as an API key. public struct HeaderKeyAuth: AuthenticationMethod { - /// The key value. + /// The key. public let key: String - /// The HTTP header to use. Defaults to `.authorization`. + /// The header the key is sent in. public let header: Header - /// Optional prefix before the key (e.g., "Bearer", "ApiKey"). - /// Set to nil for no prefix. + /// The text before the key, such as `Bearer`. When `nil`, the key is sent alone. 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. + /// Creates a method that sends `key` in `header`. /// /// - Parameters: - /// - key: The key value. - /// - header: The HTTP header to use. Defaults to `.authorization`. - /// - prefix: Optional prefix (e.g., "Bearer"). Defaults to "Bearer". + /// - key: The key. + /// - header: The header to send it in. Defaults to `Authorization`. + /// - prefix: The text before the key. Defaults to `Bearer`. Pass `nil` to send the key alone. public init( key: String, header: Header = .authorization, diff --git a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift index d41f5df..1880ccb 100644 --- a/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/JWTAuth.swift @@ -4,33 +4,33 @@ import Foundation import FoundationNetworking #endif -/// JWT-based authentication with automatic token refresh. +/// Access-token authentication that refreshes the token when needed. /// -/// 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. +/// The token is refreshed after a response with a status code in +/// ``Configuration/refreshTriggerStatusCodes``, and the request is retried. When +/// ``TokenPair/expiresAt`` is set, a token within ``Configuration/expiryLeeway`` of +/// expiring is refreshed before the request is sent. Concurrent requests share one +/// refresh. /// -/// > 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. +/// > Important: The ``RefreshHandler`` must not make its request through an endpoint +/// > that uses this same `JWTAuth`. That request would wait for the refresh, which is +/// > waiting for the handler. Give the refresh endpoint +/// > `static var auth: NoAuth { NoAuth() }`, since it authenticates with the refresh +/// > token. A request that does this fails with ``RefreshReentrancyError``. public actor JWTAuth: AuthenticationMethod { // MARK: - Types - /// A pair of access and refresh tokens. + /// An access token and the refresh token that renews it. 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. + /// When set, a request authenticated within ``Configuration/expiryLeeway`` of this + /// date refreshes the token before it is sent. When `nil`, the token is refreshed + /// only after a rejected response. public let expiresAt: Date? public init(accessToken: String, refreshToken: String, expiresAt: Date? = nil) { @@ -39,28 +39,28 @@ public actor JWTAuth: AuthenticationMethod { self.expiresAt = expiresAt } - /// Whether the access token is expired or will expire within the given leeway. - /// Always false when ``expiresAt`` is nil. + /// Whether the access token expires within `leeway` seconds, or already has. + /// 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. + /// Where the token is sent and when it is refreshed. public struct Configuration: Sendable { - /// The HTTP header for the access token. Defaults to `.authorization`. + /// The header the access token is sent in. Defaults to `Authorization`. public let header: Header - /// Prefix before the token (e.g., "Bearer"). Defaults to "Bearer". - /// An empty string sends the bare token, for headers such as `X-Access-Token`. + /// The text before the token, such as `Bearer`. Defaults to `Bearer`. + /// An empty string sends the token alone. public let tokenPrefix: String - /// HTTP status codes that should trigger a token refresh. Defaults to [401]. + /// The status codes that trigger a refresh and retry. 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. + /// How many seconds before ``TokenPair/expiresAt`` a token is refreshed before + /// use. Defaults to 30. public let expiryLeeway: TimeInterval public init( @@ -78,25 +78,25 @@ public actor JWTAuth: AuthenticationMethod { public static let `default` = Configuration() } - /// Closure type for performing token refresh. + /// Exchanges a refresh token for new tokens. /// - /// 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``. + /// > Important: Make the refresh request through an endpoint that doesn't use this + /// > `JWTAuth`. See ``JWTAuth``. public typealias RefreshHandler = @Sendable (String) async throws -> TokenPair - /// Closure type for handling token updates (e.g., persisting to Keychain). + /// Receives new tokens after a refresh, for example to save them to the keychain. /// - /// Called once per successful refresh, before the new tokens are used. A refresh - /// superseded by ``JWTAuth/setTokens(_:)`` or ``JWTAuth/clearTokens()`` before its - /// handler returns never calls this. A call that has already started cannot be - /// revoked, though: if a logout lands while it is still persisting, the write can - /// complete after the logout. If that matters, have the store reject writes from a - /// session that has since been signed out. + /// Called once per successful refresh, before the new tokens are used. It isn't + /// called for a refresh replaced by ``JWTAuth/setTokens(_:)`` or + /// ``JWTAuth/clearTokens()`` before its handler returned. A call that has already + /// started can't be stopped, so a save can finish after a logout. If your store must + /// not keep tokens after a logout, have it reject writes from a signed-out session. public typealias TokenUpdateHandler = @Sendable (TokenPair) async -> Void - /// Closure type for handling refresh failures (e.g., logout). + /// Receives the error when a refresh fails, for example to log the user out. + /// + /// It isn't called for a refresh replaced by ``JWTAuth/setTokens(_:)`` or + /// ``JWTAuth/clearTokens()``. public typealias RefreshFailureHandler = @Sendable (Error) async -> Void // MARK: - State @@ -106,8 +106,8 @@ public actor JWTAuth: AuthenticationMethod { /// 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. + /// Task-local, so requests the handler makes inherit it, which lets + /// ``authenticate(request:)`` detect a reentrant call. @TaskLocal private static var refreshingInstances: Set = [] @@ -194,10 +194,10 @@ public actor JWTAuth: AuthenticationMethod { /// The existence check and task creation happen in one synchronous stretch of /// actor isolation, so concurrent callers cannot start duplicate refreshes. /// - /// A refresh superseded by ``setTokens(_:)`` or ``clearTokens()`` is discarded - /// rather than committed: the caller's tokens are the newer intent, and a logout - /// must not be undone by a refresh that was already underway. Superseded refreshes - /// return normally so the request proceeds with whatever tokens are current. + /// A refresh replaced by ``setTokens(_:)`` or ``clearTokens()`` is discarded, not + /// committed. Those tokens are newer, and a logout must not be undone by a refresh + /// that was already running. A replaced refresh returns normally, so the request + /// continues with the current tokens. private func refresh(with refreshToken: String) async throws(AuthenticationError) { let refreshTask = pendingRefresh ?? startRefresh(refreshToken: refreshToken) @@ -219,11 +219,11 @@ public actor JWTAuth: AuthenticationMethod { } // Commit only while this refresh is still the pending one. Every caller that - // joined it resumes here, one at a time; the first commits and (via the defer) - // clears `pendingRefresh`, so later callers leave the tokens alone. That also - // covers supersession: setTokens/clearTokens clear `pendingRefresh`, so a - // refresh they replaced is never committed — even by a caller that resumes - // after a logout which landed between two callers resuming. + // joined it resumes here, one at a time. The first commits and, through the + // defer, clears `pendingRefresh`, so later callers leave the tokens alone. This + // also covers setTokens/clearTokens, which clear `pendingRefresh`: a refresh + // they replaced is never committed, even by a caller that resumes after a + // logout that happened between two callers resuming. guard pendingRefresh == refreshTask else { return } currentTokens = newTokens } @@ -246,8 +246,8 @@ public actor JWTAuth: AuthenticationMethod { try await refreshHandler(refreshToken) } } catch { - // A superseded refresh is not a failure: the tokens were replaced or - // cleared deliberately, so don't report it (and don't trigger a logout). + // A replaced refresh isn't a failure. The tokens were replaced or cleared + // on purpose, so don't report it or trigger a logout. try Task.checkCancellation() await onRefreshFailed?(error) throw AuthenticationError.refreshFailed(underlying: error) @@ -272,8 +272,7 @@ public actor JWTAuth: AuthenticationMethod { /// Replaces the current tokens, for example after a login. /// - /// Any refresh in flight is superseded: its result is discarded rather than - /// committed, and requests waiting on it proceed with these tokens. + /// A refresh in progress is discarded, and requests waiting for it use these tokens. public func setTokens(_ tokens: TokenPair) { currentTokens = tokens pendingRefresh?.cancel() @@ -282,22 +281,21 @@ public actor JWTAuth: AuthenticationMethod { /// Removes the current tokens, for example after a logout. /// - /// Any refresh in flight is superseded: its result is discarded rather than - /// committed, so a refresh that was already underway cannot silently sign the - /// user back in. Requests waiting on it fail with - /// ``AuthenticationError/notAuthenticated``. See ``TokenUpdateHandler`` for the - /// one case where a persistence callback can still finish after this returns. + /// A refresh in progress is discarded, so it can't sign the user back in. Requests + /// waiting for it fail with ``AuthenticationError/notAuthenticated``. A + /// ``TokenUpdateHandler`` call that already started can still finish afterward. public func clearTokens() { currentTokens = nil pendingRefresh?.cancel() pendingRefresh = nil } - /// The current token pair, if any. + /// The current tokens, or `nil` when signed out. public var tokens: TokenPair? { currentTokens } + /// Whether there are tokens to authenticate with. public var isAuthenticated: Bool { currentTokens != nil } diff --git a/Sources/Endpoints/Authentication/Implementations/NoAuth.swift b/Sources/Endpoints/Authentication/Implementations/NoAuth.swift index aeae4b7..65c7049 100644 --- a/Sources/Endpoints/Authentication/Implementations/NoAuth.swift +++ b/Sources/Endpoints/Authentication/Implementations/NoAuth.swift @@ -4,7 +4,7 @@ import Foundation import FoundationNetworking #endif -/// A no-op authentication method that passes requests through unchanged. +/// Leaves requests unchanged. The default for servers that don't declare ``ServerDefinition/auth``. public struct NoAuth: AuthenticationMethod { public init() {} diff --git a/Sources/Endpoints/Definition+URLResponse.swift b/Sources/Endpoints/Definition+URLResponse.swift index 0fa8d64..4aca030 100644 --- a/Sources/Endpoints/Definition+URLResponse.swift +++ b/Sources/Endpoints/Definition+URLResponse.swift @@ -14,12 +14,15 @@ import FoundationNetworking public extension Definition { - /// Converts data, response and error into a Result type by processing data and throwing errors based on response codes and response data. + /// Converts the result of a data task into the response body or the endpoint's `TaskError`. + /// + /// A 2xx response returns its body, and a 204 returns empty data. Any other status + /// code is decoded as the endpoint's ``Endpoint/ErrorResponse``. /// - Parameters: - /// - data: The raw data fetched in the response - /// - response: The response object - /// - error: Any error encountered by the fetch - /// - Returns: A Result value with either the Data or the T.TaskError + /// - data: The response body. + /// - response: The response. + /// - error: The error the data task reported, if any. + /// - Returns: The response body, or the error to throw. func response(data: Data?, response: URLResponse?, error: Error?) -> Result { if let error = error { guard (error as NSError).code != URLError.Code.notConnectedToInternet.rawValue else { diff --git a/Sources/Endpoints/Endpoint.swift b/Sources/Endpoints/Endpoint.swift index 7bb0271..5d6016d 100644 --- a/Sources/Endpoints/Endpoint.swift +++ b/Sources/Endpoints/Endpoint.swift @@ -12,28 +12,47 @@ import Foundation import FoundationNetworking #endif +/// An error building a `URLRequest` from an ``Endpoint``. public enum EndpointError: Error, Sendable { + /// The path and query didn't form a valid URL relative to the base URL. case invalid(components: URLComponents, relativeTo: URL) + /// A query parameter's value doesn't conform to ``ParameterRepresentable``. case invalidQuery(named: String, type: Any.Type) + /// A form parameter's value doesn't conform to ``ParameterRepresentable``. case invalidForm(named: String, type: Any.Type) + /// A header's value doesn't conform to `CustomStringConvertible`. case invalidHeader(named: String, type: Any.Type) + /// The body encoder threw this error. case invalidBody(Error) + /// The server has no base URL for the requested environment. case misconfiguredServer(server: any ServerDefinition) } +/// A query or form parameter in a ``Definition``. +/// +/// `T` is the endpoint's ``Endpoint/ParameterComponents`` type. public enum Parameter: Sendable { + /// A form body parameter read from a property of the parameter components. case form(String, path: PartialKeyPath & Sendable) + /// A form body parameter with a fixed value. case formValue(String, value: PathRepresentable) + /// A query parameter read from a property of the parameter components. case query(String, path: PartialKeyPath & Sendable) + /// A query parameter with a fixed value. case queryValue(String, value: PathRepresentable) } +/// The value of a header in a ``Definition``. +/// +/// `T` is the endpoint's ``Endpoint/HeaderComponents`` type. public enum HeaderField: Sendable { + /// A value read from a property of the header components. case field(path: PartialKeyPath & Sendable) + /// A fixed value. case fieldValue(value: CustomStringConvertible & Sendable) } -/// A placeholder type for representing empty encodable or decodable Body values and ErrorResponse values. +/// An empty value, used as the default ``Endpoint/Body`` and ``Endpoint/ErrorResponse``. public struct EmptyCodable: Codable, Sendable { } public protocol EncoderType { @@ -61,32 +80,32 @@ public protocol Endpoint: Sendable { associatedtype Server: ServerDefinition = GenericServer - /// The response type received from the server. + /// The type of a successful response. /// - /// This conveys type information which helpers, such as the built-in ``Foundation/URLSession`` extensions, - /// can use to know how to handle particular types. For instance, if this type conforms to `Decodable`, then a JSON decoder is used - /// on the data coming from the server. If it's typealiased to `Void`, then the extension can know to ignore the response. If it's `Data`, then it can deliver the - /// response data unmodified. + /// The `URLSession` methods decode a `Decodable` type with ``responseDecoder``, + /// return the body unchanged for `Data`, and ignore the body for `Void`. associatedtype Response: Sendable - /// The type representing the `Decodable` error response from the server. Defaults to an empty `Decodable` struct, ``EmptyCodable``. + /// The type of an error response body. Defaults to ``EmptyCodable``. /// - /// This can be useful if your server returns a different JSON structure when there's an error versus a success. Often in a project, this can be defined globally - /// and `typealias` can be used to associate this global type on all ``Endpoint``s. + /// A response with a status code outside 200–299 is decoded as this type with + /// ``errorDecoder`` and thrown as ``EndpointTaskError/errorResponse(httpResponse:response:)``. + /// If your endpoints share an error format, define the type once and refer to it + /// with a `typealias` on each endpoint. associatedtype ErrorResponse: Decodable & Sendable = EmptyCodable - /// The body type conforming to `Encodable`. Defaults to ``EmptyCodable``. + /// The type of the request body, encoded with ``bodyEncoder``. Defaults to ``EmptyCodable``, which sends no body. associatedtype Body: Encodable = EmptyCodable - /// The values needed to fill the ``Definition``'s path. + /// The values that fill in the ``Definition``'s path. Defaults to `Void`. /// - /// If a ``Endpoint/PathComponents`` type is associated, properties of that type can be utilized in the `path` of the ``Endpoint`` using a path string interpolation syntax: + /// Refer to its properties in the path with `\(path:)` interpolation: /// /// ```swift - /// struct DeleteEndpoint: Endpoint { - /// static let definition: Definition = Definition( + /// struct DeleteEventEndpoint: Endpoint { + /// static let definition: Definition = Definition( /// method: .delete, - /// path: "calendar/v3/calendars/\(path: \.calendarId)/events\(path: \.eventId)" + /// path: "calendars/\(path: \.calendarId)/events/\(path: \.eventId)" /// ) /// /// typealias Response = Void @@ -101,37 +120,29 @@ public protocol Endpoint: Sendable { /// ``` associatedtype PathComponents: Sendable = Void - /// The values needed to fill the ``Definition``'s parameters. + /// The values that fill in the ``Definition``'s query and form parameters. Defaults to `Void`. /// - /// A ``Endpoint/ParameterComponents`` type, in a similar way to ``Endpoint/PathComponents``, holds properties that can be referenced in the ``Endpoint`` via ``Parameter`` values in order to define form parameters used in the body or query parameters attached to the URL. The enum type is defined as: - /// - /// ```swift - /// public enum Parameter { - /// case form(String, path: PartialKeyPath) - /// case formValue(String, value: PathRepresentable) - /// case query(String, path: PartialKeyPath) - /// case queryValue(String, value: PathRepresentable) - /// } - /// ``` - /// - /// With this enum, either hard-coded values can be injected into the ``Endpoint`` (with ``Parameter/formValue(_:value:)`` or ``Parameter/queryValue(_:value:)``) or key paths can define which reference properties in the ``Endpoint/ParameterComponents`` associated type to define a form or query parameter that is needed at the time of the request. + /// ``Parameter/query(_:path:)`` and ``Parameter/form(_:path:)`` read a property of + /// this type. ``Parameter/queryValue(_:value:)`` and ``Parameter/formValue(_:value:)`` + /// send a fixed value instead. Properties must conform to ``ParameterRepresentable``, + /// and `nil` values are left out of the request. associatedtype ParameterComponents: Sendable = Void - /// The values needed to fill the ``Definition``'s headers. + /// The values that fill in the ``Definition``'s headers. Defaults to `Void`. associatedtype HeaderComponents: Sendable = Void /// The ``EncoderType`` to use when encoding the body of the request. Defaults to `JSONEncoder`. associatedtype BodyEncoder: EncoderType = JSONEncoder - /// The ``DecoderType`` to use when decoding the body of the request. Defaults to `JSONDecoder`. + /// The ``DecoderType`` to use when decoding ``ErrorResponse``. Defaults to `JSONDecoder`. associatedtype ErrorDecoder: DecoderType = JSONDecoder /// 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: + /// Defaults to the server's method, which defaults to ``NoAuth``. Override it to opt + /// out of the server's authentication, as on a login endpoint, or to use a different + /// method: /// /// ```swift /// struct LoginEndpoint: Endpoint { @@ -144,36 +155,35 @@ public protocol Endpoint: Sendable { /// A ``Definition`` which pieces together all the components defined in the endpoint. static var definition: Definition { get } - /// The instance of the associated `Body` type. Must be `Encodable`. + /// The request body. var body: Body { get } - /// The instance of the associated ``Endpoint/PathComponents`` type. Used for filling in request data into the path template of the endpoint. - /// If none are necessary, this can be `Void` + /// The values that fill in the path. var pathComponents: PathComponents { get } - /// The instance of the associated ``Endpoint/ParameterComponents`` type. Used for filling in request data into the query and form parameters of the endpoint. + /// The values that fill in the query and form parameters. var parameterComponents: ParameterComponents { get } - /// The instance of the associated ``Endpoint/HeaderComponents`` type. Used for filling in request data into the headers of the endpoint. + /// The values that fill in the headers. var headerComponents: HeaderComponents { get } - /// The decoder instance to use when decoding the associated ``Endpoint/Body`` type + /// The encoder for ``Body``. static var bodyEncoder: BodyEncoder { get } - /// The decoder instance to use when decoding the associated ``Endpoint/ErrorResponse`` type + /// The decoder for ``ErrorResponse``. static var errorDecoder: ErrorDecoder { get } - /// The decoder instance to use when decoding the associated ``Endpoint/Response`` type + /// The decoder for ``Response``. static var responseDecoder: ResponseDecoder { get } - /// The authentication method instance used to authenticate requests for this endpoint. + /// The authentication method that authenticates 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. + /// Defaults to ``ServerDefinition/auth``, so all endpoints on a server share one + /// instance. A stateful method such as ``JWTAuth`` needs that shared instance to keep + /// its tokens and to combine concurrent refreshes. Declare an override with `static let`. static var auth: Auth { get } - /// A strategy for encoding query parameters. Defaults to `QueryEncodingStrategy.default` + /// How query parameters are percent-encoded. Defaults to ``QueryEncodingStrategy/default``. static var queryEncodingStrategy: QueryEncodingStrategy { get } } @@ -223,31 +233,35 @@ public extension Endpoint { } } +/// How an endpoint percent-encodes its query parameters. public enum QueryEncodingStrategy { + /// The encoding `URLComponents` applies to `queryItems`. case `default` + /// Encodes each query item with a closure that returns the percent-encoded name and + /// value. Returning `nil`, or a `nil` value, leaves the item out. case custom((URLQueryItem) -> (String, String?)?) } public struct Definition: Sendable { - /// The server this endpoints will use + /// The server whose base URLs the endpoint's requests are built against. public let server: T.Server - /// The HTTP method of the ``Endpoint`` + /// The HTTP method. public let method: Method - /// A template including all elements that appear in the path + /// The path, relative to the server's base URL. public let path: PathTemplate - /// The parameters (form and query) that are included in the ``Definition`` + /// The query and form parameters. public let parameters: [Parameter] - /// The headers that are included in the ``Definition`` + /// The headers. public let headers: [Header: HeaderField] - /// Initializes a ``Definition`` with the given properties, defining all dynamic pieces as type-safe parameters. + /// Creates a definition. /// - Parameters: - /// - server: The server to use for this endpoint. Defaults to a new instance of T.Server. - /// - method: The HTTP method to use when fetching the owning ``Endpoint`` - /// - path: The path template representing the path and all path-related parameters - /// - parameters: The parameters passed to the endpoint. Either through query or form body. - /// - headers: The headers associated with this request + /// - server: The server to build requests against. Defaults to `T.Server()`. + /// - method: The HTTP method. + /// - path: The path, relative to the server's base URL. + /// - parameters: The query and form parameters. + /// - headers: The headers. public init(server: T.Server = T.Server(), method: Method, path: PathTemplate, diff --git a/Sources/Endpoints/Endpoints.docc/Authentication.md b/Sources/Endpoints/Endpoints.docc/Authentication.md new file mode 100644 index 0000000..42081ca --- /dev/null +++ b/Sources/Endpoints/Endpoints.docc/Authentication.md @@ -0,0 +1,207 @@ +# Authentication + +Apply credentials to requests, refresh expired tokens, and retry rejected requests. + +## Overview + +An ``AuthenticationMethod`` adds credentials to each request before it is sent. Methods that support refresh can also renew credentials after a rejected response, and the request is then retried. The async/await and Combine request methods handle this for you. + +## Declaring authentication + +A server declares the method its endpoints share with ``ServerDefinition/auth``: + +```swift +struct ApiServer: ServerDefinition { + static let auth = HeaderKeyAuth(key: "my-api-key") + + var baseUrls: [Environments: URL] { ... } + static var defaultEnvironment: Environments { .production } +} +``` + +Every endpoint on the server inherits it, and requests use the usual methods: + +```swift +let profile = try await URLSession.shared.response(with: ProfileEndpoint()) +``` + +A server that declares no `auth` uses ``NoAuth``. + +Declare authentication methods with `static let`. A stateful method such as ``JWTAuth`` keeps its tokens on the instance, and every request needs to see the same one. A computed `static var` creates a new instance per request and loses the tokens. Debug builds assert when an endpoint's `auth` returns a different instance each time. + +### Overriding per endpoint + +Set ``Endpoint/auth`` on an endpoint to opt out of its server's method, or to use a different one: + +```swift +struct LoginEndpoint: Endpoint { + typealias Server = ApiServer + static var auth: NoAuth { NoAuth() } + // ... +} + +struct MetricsEndpoint: Endpoint { + typealias Server = ApiServer + static let auth = HeaderKeyAuth(key: clientKey, header: "X-Client-Key", prefix: nil) + // ... +} +``` + +### Passing credentials per request + +The request methods take an `auth:` argument, which defaults to the endpoint's ``Endpoint/auth``. Credentials usually belong to one environment, so an app that talks to several environments or accounts at once should pass both together: + +```swift +struct ApiClient { + let environment: ApiServer.Environments + let auth: JWTAuth // one instance per client, so its refreshes are shared + + func profile() async throws -> ProfileEndpoint.Response { + try await URLSession.shared.response(with: ProfileEndpoint(), environment: environment, auth: auth) + } +} +``` + +Requests from different clients don't affect each other. A server whose credentials always come from the call site doesn't need to declare `auth`. + +### Completion handlers + +`endpointTask(with:completion:)` returns its task synchronously, so it can't wait for an asynchronous ``AuthenticationMethod/authenticate(request:)``. It only accepts endpoints whose `auth` is ``NoAuth``. Using it with an authenticated endpoint is a compile error. + +## Built-in methods + +- ``HeaderKeyAuth`` sends a static key in a header. It defaults to `Authorization: Bearer `. Pass `header:` and `prefix: nil` for a header like `X-API-Key: `. +- ``BasicAuth`` sends HTTP Basic credentials, encoded as UTF-8. +- ``CookieAuth`` sends a static cookie, merged with any cookies already on the request. +- ``JWTAuth`` sends an access token and refreshes it with a refresh token. +- ``NoAuth`` leaves the request unchanged. + +## Refreshing tokens + +``JWTAuth`` holds a ``JWTAuth/TokenPair``. It refreshes in two cases: + +- **After a rejection.** When a response's status code is in ``JWTAuth/Configuration/refreshTriggerStatusCodes`` (401 by default), it calls your refresh handler and retries the request once with the new tokens. +- **Before expiry.** When ``JWTAuth/TokenPair/expiresAt`` is set and the token is within ``JWTAuth/Configuration/expiryLeeway`` of expiring (30 seconds by default), it refreshes before sending the request. + +Concurrent requests that need a refresh share one call to the handler. A request rejected with tokens that have already been replaced uses the new tokens without refreshing again, so single-use refresh tokens aren't wasted. + +```swift +struct ApiServer: ServerDefinition { + static let auth = JWTAuth( + initialTokens: loadTokensFromKeychain(), + refreshHandler: { refreshToken in + let response = try await URLSession.shared.response(with: RefreshEndpoint(token: refreshToken)) + return JWTAuth.TokenPair( + accessToken: response.access, + refreshToken: response.refresh, + expiresAt: response.expiresAt + ) + }, + onTokensUpdated: { tokens in + saveTokensToKeychain(tokens) + }, + onRefreshFailed: { error in + await logOut() + } + ) + + // ... +} + +struct RefreshEndpoint: Endpoint { + typealias Server = ApiServer + static var auth: NoAuth { NoAuth() } + // ... +} +``` + +> Important: The refresh endpoint must not use the same ``JWTAuth``. Its request would wait for the refresh that is waiting for it. Give it ``NoAuth``, since it authenticates with the refresh token. If a refresh handler does make a request through its own `JWTAuth`, that request fails with ``RefreshReentrancyError`` instead of hanging. + +### Logging in and out + +Call ``JWTAuth/setTokens(_:)`` after a login and ``JWTAuth/clearTokens()`` after a logout. + +Either call replaces a refresh that is still in progress. That refresh's tokens are discarded, and neither `onTokensUpdated` nor `onRefreshFailed` is called for it, so a refresh that started just before a logout can't sign the user back in. Requests waiting for that refresh use the new tokens, or fail with ``AuthenticationError/notAuthenticated`` after a logout. + +An `onTokensUpdated` call that has already started can't be stopped. If a logout happens while it is saving tokens, the save can finish afterward. If your token store must never hold tokens after a logout, have it reject writes from a session that has been signed out. + +### Configuration + +``JWTAuth/Configuration`` sets the header, the token prefix, the status codes that trigger a refresh, and the expiry leeway: + +```swift +let auth = JWTAuth( + initialTokens: tokens, + configuration: .init(header: "X-Access-Token", tokenPrefix: "", refreshTriggerStatusCodes: [401, 419]), + refreshHandler: refresh +) +``` + +An empty prefix sends the token alone. + +## Custom methods + +Conform to ``AuthenticationMethod``. Only ``AuthenticationMethod/authenticate(request:)`` is required: + +```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 + } +} +``` + +To support refresh, also implement: + +- ``AuthenticationMethod/shouldReauthenticate(for:response:)``, which decides whether a failure should trigger a refresh. It usually checks the status code. +- ``AuthenticationMethod/reauthenticate(after:)``, which renews the credentials. It receives the failed request as it was sent. If your credentials have changed since then, return without refreshing. Combine concurrent calls into one refresh. +- ``AuthenticationMethod/maxRetryAttempts``, the number of retries after refreshing. It defaults to 1. + +A method that stores credentials should be an actor or a `final class`, declared with `static let`. + +Wrap failures that don't match a built-in ``AuthenticationError`` case in ``AuthenticationError/custom(underlying:)``. + +## Errors + +Authentication failures are thrown as ``EndpointTaskError/authenticationError(_:)`` on the endpoint's `TaskError`: + +```swift +do { + let profile = try await URLSession.shared.response(with: ProfileEndpoint()) +} catch { + switch error { + case .authenticationError(.notAuthenticated): + // No tokens. Show the login screen. + case .authenticationError(.refreshFailed(let underlying)): + // The refresh handler threw `underlying`. + default: + break + } +} +``` + +If the retried request is rejected again, the error is the server's response, such as ``EndpointTaskError/errorResponse(httpResponse:response:)``, not an authentication error. + +## Mocks + +A mocked request skips authentication. See . + +## Topics + +### Methods + +- ``AuthenticationMethod`` +- ``NoAuth`` +- ``HeaderKeyAuth`` +- ``BasicAuth`` +- ``CookieAuth`` +- ``JWTAuth`` + +### Errors + +- ``AuthenticationError`` +- ``RefreshReentrancyError`` diff --git a/Sources/Endpoints/Endpoints.docc/Endpoints.md b/Sources/Endpoints/Endpoints.docc/Endpoints.md index 80a4cba..5835712 100644 --- a/Sources/Endpoints/Endpoints.docc/Endpoints.md +++ b/Sources/Endpoints/Endpoints.docc/Endpoints.md @@ -1,38 +1,74 @@ # ``Endpoints`` -Endpoints is a small library for creating statically and strongly-typed definitions of endpoints with paths, methods, inputs and outputs. +Describe HTTP endpoints as Swift types, and send them with `URLSession`. ## Overview -The purpose of Endpoints is to, in a type-safe way, define how to create a `URLRequest` from typed properties and, additionally, define how a response for the request should be handled. The library not only includes the ability to create these requests in a type-safe way, but also includes helpers to perform the requests using ``Foundation/URLSession``. Endpoints does not try to wrap the URL loading system to provide features on top of it like Alamofire. Instead, Endpoints focuses on defining endpoints and associated data to produce a request as a URLRequest object to be plugged into vanilla ``Foundation/URLSession``s. However, this library could be used in conjunction with Alamofire if desired. +An ``Endpoint`` describes a request: its server, method, path, parameters, headers, body, and response type. Endpoints builds a `URLRequest` from it and decodes the response, using plain `URLSession` rather than its own networking layer. + +```swift +struct ProfileEndpoint: Endpoint { + typealias Server = ApiServer + + static let definition: Definition = Definition( + method: .get, + path: "users/\(path: \.userId)/profile" + ) + + struct Response: Decodable { + let name: String + } + + struct PathComponents { + let userId: String + } + + let pathComponents: PathComponents +} + +let profile = try await URLSession.shared.response(with: ProfileEndpoint(pathComponents: .init(userId: "42"))) +``` + +Start with for servers, environments, and common request shapes. Then read to attach credentials, and to test. ## Topics ### Essentials +- - ``Endpoint`` - ``Definition`` - ``ServerDefinition`` -- -### Server Configuration +### Servers and Environments -- ``ServerDefinition`` - ``GenericServer`` - ``TypicalEnvironments`` -### Request Components +### Building Requests - ``Method`` - ``PathTemplate`` +- ``PathRepresentable`` - ``Parameter`` +- ``ParameterRepresentable`` +- ``QueryEncodingStrategy`` - ``Header`` - ``HeaderField`` -- ``ParameterRepresentable`` +- ``HeaderCategory`` + +### Encoding and Decoding + +- ``EncoderType`` +- ``DecoderType`` +- ``EmptyCodable`` - ``MultipartFormEncoder`` +- ``MultipartFormFile`` +- ``MultipartFormJSON`` ### Authentication +- - ``AuthenticationMethod`` - ``AuthenticationError`` - ``RefreshReentrancyError`` @@ -42,13 +78,13 @@ The purpose of Endpoints is to, in a type-safe way, define how to create a `URLR - ``CookieAuth`` - ``JWTAuth`` -### Making Requests +### Sending Requests and Handling Errors - ``Foundation/URLSession`` - ``EndpointTaskError`` - ``EndpointError`` -### Testing and Mocking +### Testing - - ``MockRegistry`` diff --git a/Sources/Endpoints/Endpoints.docc/Examples.md b/Sources/Endpoints/Endpoints.docc/Examples.md index 334bfdf..377b6d0 100644 --- a/Sources/Endpoints/Endpoints.docc/Examples.md +++ b/Sources/Endpoints/Endpoints.docc/Examples.md @@ -1,18 +1,15 @@ # Examples -## Defining a Server +Define servers and endpoints for common request shapes. -Before creating endpoints, you first need to define a server that conforms to ``ServerDefinition``. This replaces the old `EnvironmentType` approach and provides a more integrated way to manage environments. +## Servers -### Basic Server Definition +A ``ServerDefinition`` lists a base URL for each environment and names the default: ```swift -import Endpoints -import Foundation - struct ApiServer: ServerDefinition { var baseUrls: [Environments: URL] { - return [ + [ .local: URL(string: "https://local-api.example.com")!, .staging: URL(string: "https://staging-api.example.com")!, .production: URL(string: "https://api.example.com")! @@ -23,627 +20,412 @@ struct ApiServer: ServerDefinition { } ``` -### Using GenericServer +### Custom environments -For simple use cases, you can use the built-in ``GenericServer``: +`Environments` defaults to ``TypicalEnvironments``. Any `Hashable & Sendable` type works: ```swift -let server = GenericServer( - local: URL(string: "https://localhost:8080"), - staging: URL(string: "https://staging-api.example.com"), - production: URL(string: "https://api.example.com") -) -``` - -### Custom Environments - -You can define custom environment types beyond the standard ``TypicalEnvironments``: - -```swift -enum CustomEnvironments: String, CaseIterable, Sendable { - case debug - case testing - case production +enum Region: Hashable, Sendable { + case us + case eu } -struct CustomServer: ServerDefinition { - typealias Environments = CustomEnvironments - +struct RegionalServer: ServerDefinition { + typealias Environments = Region + var baseUrls: [Environments: URL] { - return [ - .debug: URL(string: "https://debug-api.example.com")!, - .testing: URL(string: "https://test-api.example.com")!, - .production: URL(string: "https://api.example.com")! + [ + .us: URL(string: "https://us.api.example.com")!, + .eu: URL(string: "https://eu.api.example.com")! ] } - static var defaultEnvironment: Environments { .debug } - - var requestProcessor: @Sendable (URLRequest) -> URLRequest { - return { request in - var mutableRequest = request - mutableRequest.setValue(buildNumber, forHTTPHeaderField: "X-Client-Build") - return mutableRequest - } - } + static var defaultEnvironment: Environments { .us } } ``` -> 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. +### Choosing an environment -### Changing Environments - -Select the environment when performing a request: +Each request takes an `environment:` argument, which defaults to the server's ``ServerDefinition/defaultEnvironment``: ```swift let response = try await URLSession.shared.response(with: MyEndpoint(), environment: .staging) +let request = try MyEndpoint().urlRequest(in: .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. +The environment belongs to the request, not to global state, so concurrent requests can use different environments. -### Authentication +### Processing requests -A server names the ``AuthenticationMethod`` its endpoints share, and every endpoint on it -inherits that method. Declare it as a `static let` so all endpoints use one instance — -for a stateful method such as ``JWTAuth`` that shared instance is what lets concurrent -token refreshes coalesce: +``ServerDefinition/requestProcessor`` runs synchronously on every request after it is built. Use it for static changes: ```swift struct ApiServer: ServerDefinition { - static let auth = JWTAuth( - initialTokens: loadTokensFromKeychain(), - refreshHandler: { refreshToken in - 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: { _ in await logOut() } - ) - - var baseUrls: [Environments: URL] { ... } - static var defaultEnvironment: Environments { .production } -} -``` - -An individual endpoint can override its server's method. The refresh endpoint above must -do so: it authenticates with the refresh token, and a request authenticated by the same -`JWTAuth` would wait on the refresh that is waiting on it. + // baseUrls and defaultEnvironment as above -```swift -struct RefreshEndpoint: Endpoint { - typealias Server = ApiServer - static var auth: NoAuth { NoAuth() } - ... + var requestProcessor: @Sendable (URLRequest) -> URLRequest { + { request in + var request = request + request.setValue(AppInfo.buildNumber, forHTTPHeaderField: "X-Client-Build") + return request + } + } } ``` -Requests then go through the ordinary `URLSession` API; credentials are applied, refreshed -and retried automatically: +For credentials, use ``ServerDefinition/auth`` instead. See . -```swift -let profile = try await URLSession.shared.response(with: ProfileEndpoint()) -``` - -### Environment and Credentials Per Request +### Using GenericServer -Credentials are usually bound to one environment — a token issued by staging is not valid -against production — so an app that talks to several environments or accounts at once -should pass both per request rather than share one server-wide instance: +An endpoint that doesn't name a server uses ``GenericServer``. Pass a configured instance to its ``Definition``: ```swift -struct Client { - let environment: ApiServer.Environments - let auth: JWTAuth // one instance per client, so its refreshes coalesce +struct StatusEndpoint: Endpoint { + static let definition: Definition = Definition( + server: GenericServer(baseUrl: URL(string: "https://status.example.com")!), + method: .get, + path: "status" + ) - func profile() async throws -> ProfileEndpoint.Response { - try await URLSession.shared.response(with: ProfileEndpoint(), environment: environment, auth: auth) + struct Response: Decodable { + let healthy: Bool } } ``` -Both parameters default to what the endpoint declares, so the common single-environment -case needs neither. A server whose credentials always arrive this way need not declare -``ServerDefinition/auth`` at all. +A `GenericServer()` with no URLs fails every request with ``EndpointError/misconfiguredServer(server:)``. ---- +## Endpoints -## Endpoint Examples +Each example shows the endpoint and a call to it. Every example works with the Combine method `endpointPublisher(with:)` too. -### GET Request +### GET with a decoded response -#### Endpoint and Definition ```swift -struct MyEndpoint: Endpoint { +struct ArticlesEndpoint: Endpoint { typealias Server = ApiServer - - static let definition: Definition = Definition( + + static let definition: Definition = Definition( method: .get, - path: "path/to/resource" + path: "articles" ) struct Response: Decodable { - let resourceId: String - let resourceName: String + let articles: [Article] } } -``` -#### Usage -```swift -URLSession.shared.endpointPublisher(with: MyEndpoint()) - .sink { completion in - guard case .failure(let error) = completion else { return } - // handle error - } receiveValue: { (response: MyEndpoint.Response) in - // handle MyEndpoint.Response - } - .store(in: &cancellables) +let response = try await URLSession.shared.response(with: ArticlesEndpoint()) ``` -### GET Request with ``Endpoint/PathComponents`` +### Path components + +Interpolate key paths into the path with `\(path:)`. Slashes are added between components as needed: -#### Endpoint and Definition ```swift -struct MyEndpoint: Endpoint { +struct EventEndpoint: Endpoint { typealias Server = ApiServer - - static let definition: Definition = Definition( + + static let definition: Definition = Definition( method: .get, - path: "user/\(path: \.userId)/resource" + path: "calendars/\(path: \.calendarId)/events/\(path: \.eventId)" ) struct Response: Decodable { - let value: String + let title: String } struct PathComponents { - let userId: String + let calendarId: String + let eventId: Int } let pathComponents: PathComponents } -``` -#### Usage -```swift -URLSession.shared.endpointPublisher(with: MyEndpoint(pathComponents: .init(userId: "42"))) - .sink { completion in - guard case .failure(let error) = completion else { return } - // handle error - } receiveValue: { (response: MyEndpoint.Response) in - // handle MyEndpoint.Response - } - .store(in: &cancellables) +let event = try await URLSession.shared.response( + with: EventEndpoint(pathComponents: .init(calendarId: "work", eventId: 42)) +) ``` -### GET Request with ``Endpoint/HeaderComponents`` +### Query parameters -#### Endpoint and Definition -```swift -extension Header { - static let myCustomHeader = Header(name: "X-CUSTOM") - static let myOtherCustomHeader = Header(name: "X-OTHER-CUSTOM") - static let myHardCodedHeader = Header(name: "X-HARD-CODED") -} +`.query` reads a value from ``Endpoint/ParameterComponents``, and `.queryValue` sends a fixed value. `nil` values are left out: -struct MyEndpoint: Endpoint { +```swift +struct SearchEndpoint: Endpoint { typealias Server = ApiServer - - static let definition: Definition = Definition( + + static let definition: Definition = Definition( method: .get, - path: "path/to/resource", - headers: [ - .myCustomHeader: .field(path: \MyEndpoint.HeaderValues.headerString), - .myOtherCustomHeader: .field(path: \MyEndpoint.HeaderValues.headerInt), - .myHardCodedHeader: .fieldValue(value: "value") + path: "search", + parameters: [ + .query("q", path: \.query), + .query("page", path: \.page), + .queryValue("format", value: "compact") ] ) struct Response: Decodable { - let value: String + let results: [String] } - struct HeaderValues { - let headerString: String - let headerInt: Int + struct ParameterComponents { + let query: String + let page: Int? } - let headerValues: HeaderValues + let parameterComponents: ParameterComponents } -``` -#### Usage -```swift -URLSession.shared.endpointPublisher(with: MyEndpoint(headerValues: .init(headerString: "headerValue", headerInt: 42))) - .sink { completion in - guard case .failure(let error) = completion else { return } - // handle error - } receiveValue: { (response: MyEndpoint.Response) in - // handle MyEndpoint.Response - } - .store(in: &cancellables) +// https://api.example.com/search?q=swift&format=compact +let results = try await URLSession.shared.response( + with: SearchEndpoint(parameterComponents: .init(query: "swift", page: nil)) +) ``` -### POST Request with ``Endpoint/Body`` +Supported value types are `String`, `Int`, `Double`, `Bool`, `Date` (ISO 8601), `TimeZone`, and optionals of these. Conform other types to ``ParameterRepresentable``. -#### Endpoint and Definition -```swift -struct MyEndpoint: Endpoint { - typealias Server = ApiServer - - static let definition: Definition = Definition( - method: .post, - path: "path/to/resource" - ) +To control percent-encoding, set ``Endpoint/queryEncodingStrategy``. This one also encodes `+`, which some servers read as a space: - struct Response: Decodable { - let value: String - } - - struct Body: Encodable { - let bodyName: String +```swift +static var queryEncodingStrategy: QueryEncodingStrategy { + .custom { item in + var allowed = CharacterSet.urlQueryAllowed + allowed.remove(charactersIn: "+") + return (item.name, item.value?.addingPercentEncoding(withAllowedCharacters: allowed)) } - - let body: Body } ``` -#### Usage -```swift -URLSession.shared.endpointPublisher(with: MyEndpoint(body: .init(bodyName: "value"))) - .sink { completion in - guard case .failure(let error) = completion else { return } - // handle error - } receiveValue: { (response: MyEndpoint.Response) in - // handle MyEndpoint.Response - } - .store(in: &cancellables) -``` +### Form parameters -### POST Request with form ``Endpoint/ParameterComponents`` +`.form` and `.formValue` send an `application/x-www-form-urlencoded` body: -#### Endpoint and Definition ```swift -struct MyEndpoint: Endpoint { +struct TokenEndpoint: Endpoint { typealias Server = ApiServer - - static let definition: Definition = Definition( + + static let definition: Definition = Definition( method: .post, - path: "path/to/resource", + path: "oauth/token", parameters: [ - .form("keyString", path: \MyEndpoint.ParameterComponents.keyString), - .form("keyInt", path: \MyEndpoint.ParameterComponents.keyInt), - .formValue("key", value: "hard-coded") + .form("username", path: \.username), + .form("password", path: \.password), + .formValue("grant_type", value: "password") ] ) struct Response: Decodable { - let resourceId: String - let resourceName: String + let accessToken: String } - struct Parameters { - let keyString: String - let keyInt: Int + struct ParameterComponents { + let username: String + let password: String } - let parameters: Parameters + let parameterComponents: ParameterComponents } ``` -#### Usage -```swift -URLSession.shared.endpointPublisher(with: MyEndpoint(parameters: .init(keyString: "value", keyInt: 42))) - .sink { completion in - guard case .failure(let error) = completion else { return } - // handle error - } receiveValue: { (response: MyEndpoint.Response) in - // handle MyEndpoint.Response - } - .store(in: &cancellables) -``` +### Headers -### POST Request with query ``Endpoint/ParameterComponents`` +`.field` reads a value from ``Endpoint/HeaderComponents``, and `.fieldValue` sends a fixed value. Define your own ``Header`` names by extending the type, or use a string literal: -#### Endpoint and Definition ```swift -struct MyEndpoint: Endpoint { +extension Header { + static let requestId = Header(name: "X-Request-ID") +} + +struct ReportEndpoint: Endpoint { typealias Server = ApiServer - - static let definition: Definition = Definition( - method: .post, - path: "path/to/resource", - parameters: [ - .query("keyString", path: \MyEndpoint.ParameterComponents.keyString), - .query("keyInt", path: \MyEndpoint.ParameterComponents.keyInt), - .queryValue("key", value: "hard-coded") + + static let definition: Definition = Definition( + method: .get, + path: "report", + headers: [ + .requestId: .field(path: \.requestId), + .accept: .fieldValue(value: "application/json"), + "X-Client": .fieldValue(value: "ios") ] ) struct Response: Decodable { - let resourceId: String - let resourceName: String + let total: Int } - struct Parameters { - let keyString: String - let keyInt: Int + struct HeaderComponents { + let requestId: String } - let parameters: Parameters + let headerComponents: HeaderComponents } ``` -#### Usage -```swift -URLSession.shared.endpointPublisher(with: MyEndpoint(parameters: .init(keyString: "value", keyInt: 42))) - .sink { completion in - guard case .failure(let error) = completion else { return } - // handle error - } receiveValue: { (response: MyEndpoint.Response) in - // handle MyEndpoint.Response - } - .store(in: &cancellables) -``` +### JSON body -#### Output URL +A ``Endpoint/Body`` is encoded with `JSONEncoder`, and `Content-Type` is set to `application/json`: -``` -https://production.mydomain.com/path/to/resource?keyString=value&keyInt=42&key=hard-coded -``` - -### DELETE Request with Void ``Endpoint/Response`` - -#### Endpoint and Definition ```swift -struct MyEndpoint: Endpoint { +struct CreateArticleEndpoint: Endpoint { typealias Server = ApiServer - - static let definition: Definition = Definition( - method: .delete, - path: "path/to/resource" - ) - typealias Response = Void -} -``` + static let definition: Definition = Definition( + method: .post, + path: "articles" + ) -#### Usage -```swift -URLSession.shared.endpointPublisher(with: MyEndpoint()) - .sink { completion in - guard case .failure(let error) = completion else { return } - // handle error - } receiveValue: { (response: Void) in - // handle success with ignored response + struct Body: Encodable { + let title: String + let text: String } - .store(in: &cancellables) -``` - -### GET Request with custom ``Endpoint/ResponseDecoder`` - -#### Endpoint and Definition -```swift -struct MyEndpoint: Endpoint { - typealias Server = ApiServer - - static let definition: Definition = Definition( - method: .get, - path: "path/to/resource" - ) struct Response: Decodable { - let resourceId: String - let resourceName: String + let id: String } - static let responseDecoder: JSONDecoder = { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - return decoder - }() + let body: Body } -``` -#### Usage -```swift -URLSession.shared.endpointPublisher(with: MyEndpoint()) - .sink { completion in - guard case .failure(let error) = completion else { return } - // handle error - } receiveValue: { (response: MyEndpoint.Response) in - // handle MyEndpoint.Response and has been decoded - // with the custom responseDecoder - } - .store(in: &cancellables) +let created = try await URLSession.shared.response( + with: CreateArticleEndpoint(body: .init(title: "Hello", text: "...")) +) ``` -### POST Request with custom ``Endpoint/BodyEncoder`` +### Multipart upload + +Set ``Endpoint/bodyEncoder`` to ``MultipartFormEncoder``. Each property of the body becomes a part. Use ``MultipartFormFile`` for files and ``MultipartFormJSON`` for a part that holds JSON: -#### Endpoint and Definition ```swift -struct MyEndpoint: Endpoint { +struct UploadEndpoint: Endpoint { typealias Server = ApiServer - - static let definition: Definition = Definition( + + static let definition: Definition = Definition( method: .post, - path: "path/to/resource" + path: "uploads" ) - struct Response: Decodable { - let value: String + struct Body: Encodable { + let caption: String + let photo: MultipartFormFile + let metadata: MultipartFormJSON } - struct Body: Encodable { - let bodyValue: String + struct Metadata: Encodable, Sendable { + let albumId: String } - let body: Body + struct Response: Decodable { + let fileId: String + } - static let bodyEncoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - return encoder - }() + static var bodyEncoder: MultipartFormEncoder { MultipartFormEncoder() } + + let body: Body } -``` -#### Usage -```swift -URLSession.shared.endpointPublisher(with: MyEndpoint(body: .init(bodyValue: "value"))) - .sink { completion in - guard case .failure(let error) = completion else { return } - // handle error - } receiveValue: { (response: MyEndpoint.Response) in - // handle MyEndpoint.Response - } - .store(in: &cancellables) +let upload = UploadEndpoint(body: .init( + caption: "Profile photo", + photo: MultipartFormFile(data: imageData, fileName: "photo.jpg", contentType: "image/jpeg"), + metadata: MultipartFormJSON(.init(albumId: "a1")) +)) +let response = try await URLSession.shared.response(with: upload) ``` -### GET Request with custom ``Endpoint/ErrorResponse`` +### Empty or raw responses -#### Endpoint and Definition -```swift -struct ServerError: Decodable { - let code: Int - let message: String -} +Use `Void` when the response has no body you need, such as a 204: -struct MyEndpoint: Endpoint { +```swift +struct DeleteArticleEndpoint: Endpoint { typealias Server = ApiServer - - static let definition: Definition = Definition( - method: .get, - path: "path/to/resource" + + static let definition: Definition = Definition( + method: .delete, + path: "articles/\(path: \.id)" ) - typealias ErrorResponse = ServerError + typealias Response = Void - struct Response: Decodable { - let responseValue: String + struct PathComponents { + let id: String } + + let pathComponents: PathComponents } -``` -#### Usage -```swift -URLSession.shared.endpointPublisher(with: MyEndpoint()) - .sink { completion in - guard case .failure(let error) = completion else { return } - switch error { - case .errorResponse(let code, let error): - // handle error, which is typed to ErrorResponse - default: - break - } - } receiveValue: { (response: MyEndpoint.Response) in - // handle MyEndpoint.Response - } - .store(in: &cancellables) +try await URLSession.shared.response(with: DeleteArticleEndpoint(pathComponents: .init(id: "a1"))) ``` -### GET Request with custom ``Endpoint/ErrorResponse`` and ``Endpoint/ErrorDecoder`` +Use `Data` to receive the body without decoding it. -#### Endpoint and Definition -```swift -struct ServerError: Decodable { - let code: Int - let message: String -} +### Custom decoders and encoders -struct MyEndpoint: Endpoint { - typealias Server = ApiServer - - static let definition: Definition = Definition( - method: .get, - path: "path/to/resource" - ) +Override ``Endpoint/responseDecoder``, ``Endpoint/errorDecoder``, or ``Endpoint/bodyEncoder``: - typealias ErrorResponse = ServerError - - struct Response: Decodable { - let responseValue: String - } +```swift +struct ProfileEndpoint: Endpoint { + // ... - static let errorDecoder: JSONDecoder = { + static let responseDecoder: JSONDecoder = { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return decoder }() + + static let bodyEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + return encoder + }() } ``` -#### Usage -```swift -URLSession.shared.endpointPublisher(with: MyEndpoint()) - .sink { completion in - guard case .failure(let error) = completion else { return } - switch error { - case .errorResponse(let code, let error): - // handle error, which is typed to ErrorResponse and - // has been decoded with the custom errorDecoder - default: - break - } - } receiveValue: { (response: MyEndpoint.Response) in - // handle MyEndpoint.Response - } - .store(in: &cancellables) -``` +Any type that conforms to ``DecoderType`` or ``EncoderType`` works. -### Async/Await Usage +### Typed error responses -All endpoints can also be used with Swift's async/await: +When a server returns a structured body for errors, set ``Endpoint/ErrorResponse``. Non-2xx responses are decoded as that type and thrown as ``EndpointTaskError/errorResponse(httpResponse:response:)``: ```swift +struct ServerError: Decodable, Sendable { + let code: Int + let message: String +} + +struct ArticleEndpoint: Endpoint { + typealias Server = ApiServer + typealias ErrorResponse = ServerError + // ... +} + do { - let response = try await URLSession.shared.response(with: MyEndpoint()) - // handle response + let article = try await URLSession.shared.response(with: ArticleEndpoint()) } catch { - // handle error + // error is ArticleEndpoint.TaskError + if case .errorResponse(let httpResponse, let serverError) = error { + print(httpResponse.statusCode, serverError.message) + } } ``` -### Multipart Form Upload +If many endpoints share one error type, define a `typealias` once and reuse it. -For file uploads using multipart/form-data: +### Completion handlers -```swift -struct UploadEndpoint: Endpoint { - typealias Server = ApiServer - - static let definition: Definition = Definition( - method: .post, - path: "upload" - ) +For unauthenticated endpoints, `endpointTask(with:completion:)` creates a data task without starting it: - struct Response: Decodable { - let fileId: String - } - - struct Body: MultipartFormEncodable { - let file: MultipartFile - let description: String +```swift +let task = try URLSession.shared.endpointTask(with: ArticlesEndpoint()) { result in + switch result { + case .success(let response): + // handle response + case .failure(let error): + // handle ArticlesEndpoint.TaskError } - - let body: Body } - -// Usage -let file = MultipartFile( - filename: "photo.jpg", - contentType: "image/jpeg", - data: imageData -) - -let endpoint = UploadEndpoint(body: .init( - file: file, - description: "Profile photo" -)) - -let response = try await URLSession.shared.response(with: endpoint) +task.resume() ``` + +It throws if the request can't be built, and it doesn't accept authenticated endpoints. Use `response(with:)` or `endpointPublisher(with:)` for those. diff --git a/Sources/Endpoints/Endpoints.docc/Mocking.md b/Sources/Endpoints/Endpoints.docc/Mocking.md index 5d55ba3..848b1d4 100644 --- a/Sources/Endpoints/Endpoints.docc/Mocking.md +++ b/Sources/Endpoints/Endpoints.docc/Mocking.md @@ -1,261 +1,182 @@ # Mocking -The Endpoints library includes a powerful mocking system through the `EndpointsMocking` module that allows you to intercept and mock network requests during testing. This enables fast, reliable tests without making actual network calls. +Replace endpoint responses in tests without making network requests. ## Overview -The mocking system works by: -1. Intercepting URLSession data task `resume()` calls when a mock is active -2. Providing mock responses through a continuation-based API -3. Supporting async/await, Combine, and closure-based callbacks +The `EndpointsMocking` module provides `withMock`, which replaces the response for an endpoint type while a block of test code runs. Requests for that type return the mock instead of reaching the network. Requests for other types make real requests. + +Mocking works with the async/await, Combine, and completion-handler request methods. It is available in DEBUG builds on Apple platforms. ## Setup -Add the `EndpointsMocking` module to your test target dependencies: +Add `EndpointsMocking` to your test target: ```swift -// Package.swift -testTarget( - name: "YourTests", - dependencies: ["Endpoints", "EndpointsMocking"] +.testTarget( + name: "YourAppTests", + dependencies: [ + .product(name: "Endpoints", package: "Endpoints"), + .product(name: "EndpointsMocking", package: "Endpoints") + ] ) ``` -Import the mocking module in your tests: - -```swift -import Testing // or XCTest -import Endpoints -import EndpointsMocking -``` - -## Basic Mocking - -### Mocking a Successful Response - -Use `withMock` to wrap your test code and provide mock responses: +Then import it in your tests: ```swift import Testing import Endpoints import EndpointsMocking - -@Test func testSuccessfulResponse() async throws { - try await withMock(MyEndpoint.self) { continuation in - // Provide the mock response - continuation.resume(returning: .init(userId: "123", name: "John Doe")) - } test: { - // Your actual test code - let endpoint = MyEndpoint(pathComponents: .init(userId: "123")) - let response = try await URLSession.shared.response(with: endpoint) - - #expect(response.userId == "123") - #expect(response.name == "John Doe") - } -} ``` -### Inline Mock Action +## Mocking a response -For simple cases, use the inline action syntax: +Pass a ``MockAction`` for simple cases: ```swift -@Test func testWithInlineAction() async throws { - try await withMock( - MyEndpoint.self, - action: .return(.init(userId: "456", name: "Jane Smith")) - ) { - let endpoint = MyEndpoint(pathComponents: .init(userId: "456")) - let response = try await URLSession.shared.response(with: endpoint) - - #expect(response.name == "Jane Smith") +@Test func loadsProfile() async throws { + try await withMock(ProfileEndpoint.self, action: .return(.init(name: "Zac"))) { + let profile = try await URLSession.shared.response(with: ProfileEndpoint()) + #expect(profile.name == "Zac") } } ``` -## Mock Actions - -The `MockAction` enum provides four different actions: - -### 1. Return a Success Response +Or pass a closure that receives a ``MockContinuation``. The closure runs once per request, and it can be `async`: ```swift -continuation.resume(returning: responseObject) -// or inline: -withMock(MyEndpoint.self, action: .return(responseObject)) +try await withMock(ProfileEndpoint.self) { continuation in + continuation.resume(returning: .init(name: "Zac")) +} test: { + let profile = try await URLSession.shared.response(with: ProfileEndpoint()) + #expect(profile.name == "Zac") +} ``` -### 2. Return an Error Response +### Actions -Use this when the server returns a structured error (matching your endpoint's `ErrorResponse` type): +| Action | Continuation method | Result | +|---|---|---| +| `.return(value)` | `resume(returning:)` | The request returns `value`. | +| `.fail(errorResponse)` | `resume(failingWith:)` | The request throws ``EndpointTaskError/errorResponse(httpResponse:response:)`` with your ``Endpoint/ErrorResponse`` value. | +| `.throw(error)` | `resume(throwing:)` | The request throws `error`, such as `.internetConnectionOffline`. | +| `.none` | Don't call a resume method. | The request is sent to the network. | -```swift -continuation.resume(failingWith: ErrorResponse(code: 404, message: "Not found")) -// or inline: -withMock(MyEndpoint.self, action: .fail(errorResponse)) -``` - -### 3. Throw a Task Error +The `httpResponse` in a `.fail` error is a placeholder with status code 200. Assert on the error response value, not on the status code. To test status-code handling, use a fake transport. See . -Use this to simulate network or parsing errors: - -```swift -continuation.resume(throwing: .internetConnectionOffline) -// or inline: -withMock(MyEndpoint.self, action: .throw(.internetConnectionOffline)) -``` +> Note: With the completion-handler method `endpointTask(with:completion:)`, a `.none` mock doesn't send the request, and the completion handler isn't called. Return a value or an error instead. -### 4. Do Nothing +## Mocking several endpoints -For cases where you want the mock to not interfere (rarely used): +Register several endpoint types in one scope instead of nesting `withMock` calls: ```swift -// Just don't call any resume method, or: -withMock(MyEndpoint.self, action: .none) +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 URLSession.shared.response(with: ProfileEndpoint()) + #expect(profile.name == "Zac") +} ``` -## Advanced Mocking +Nested scopes combine. An inner mock for the same endpoint type replaces the outer one until the inner scope ends. -### Dynamic Responses +## Dynamic responses -The mock closure runs once per request, so it can vary its response over time. The -continuation does not receive the endpoint instance, so decide based on state you -control rather than on the request's components. Because the closure is `@Sendable`, -keep that state in an actor: +The continuation doesn't include the endpoint instance, so a dynamic mock decides from state you control. The closure is `@Sendable`, so keep that state in an actor: ```swift -actor ResponseSequence { - private var names = ["Administrator", "Regular User"] +actor CallCounter { + private(set) var count = 0 - func next() -> String { - names.isEmpty ? "Guest" : names.removeFirst() + func next() -> Int { + count += 1 + return count } } -@Test func testDynamicResponse() async throws { - let sequence = ResponseSequence() +@Test func returnsDifferentProfiles() async throws { + let calls = CallCounter() - try await withMock(MyEndpoint.self) { continuation in - continuation.resume(returning: .init(userId: "1", name: await sequence.next())) + try await withMock(ProfileEndpoint.self) { continuation in + let call = await calls.next() + continuation.resume(returning: .init(name: "User \(call)")) } test: { - let first = try await URLSession.shared.response(with: MyEndpoint(pathComponents: .init(userId: "1"))) - let second = try await URLSession.shared.response(with: MyEndpoint(pathComponents: .init(userId: "2"))) + let first = try await URLSession.shared.response(with: ProfileEndpoint()) + let second = try await URLSession.shared.response(with: ProfileEndpoint()) - #expect(first.name == "Administrator") - #expect(second.name == "Regular User") + #expect(first.name == "User 1") + #expect(second.name == "User 2") + #expect(await calls.count == 2) } } ``` -### Async Mock Data Loading - -You can load mock data asynchronously from files or other sources: +To load a fixture file, catch errors inside the closure, since it can't throw: ```swift -@Test func testWithAsyncMockLoading() async throws { - try await withMock(MyEndpoint.self) { continuation in - // Load mock from JSON file - let mockData = try await loadMockData(filename: "user_response.json") - let decoder = JSONDecoder() - let response = try decoder.decode(MyEndpoint.Response.self, from: mockData) - - continuation.resume(returning: response) - } test: { - let endpoint = MyEndpoint(pathComponents: .init(userId: "123")) - let response = try await URLSession.shared.response(with: endpoint) - - #expect(response.userId == "123") +try await withMock(ProfileEndpoint.self) { continuation in + do { + let url = Bundle.module.url(forResource: "profile", withExtension: "json")! + let data = try Data(contentsOf: url) + continuation.resume(returning: try JSONDecoder().decode(ProfileEndpoint.Response.self, from: data)) + } catch { + continuation.resume(throwing: .urlLoadError(error)) } -} - -func loadMockData(filename: String) async throws -> Data { - let url = Bundle.module.url(forResource: filename, withExtension: nil)! - return try Data(contentsOf: url) +} test: { + // ... } ``` -### Multiple Requests in One Mock Block - -The mock applies to all requests of the specified endpoint type within the test block: +## Testing errors ```swift -@Test func testMultipleRequests() async throws { - let calls = CallCounter() - - try await withMock(MyEndpoint.self) { continuation in - let count = await calls.increment() - continuation.resume(returning: .init(userId: "\(count)", name: "User \(count)")) - } test: { - let response1 = try await URLSession.shared.response(with: MyEndpoint(pathComponents: .init(userId: "1"))) - let response2 = try await URLSession.shared.response(with: MyEndpoint(pathComponents: .init(userId: "2"))) - - #expect(await calls.value == 2) - #expect(response1.name == "User 1") - #expect(response2.name == "User 2") +@Test func showsServerError() async throws { + try await withMock(ArticleEndpoint.self, action: .fail(.init(code: 500, message: "Server error"))) { + do { + _ = try await URLSession.shared.response(with: ArticleEndpoint()) + Issue.record("Expected an error") + } catch let error as ArticleEndpoint.TaskError { + guard case .errorResponse(_, let serverError) = error else { + Issue.record("Unexpected error: \(error)") + return + } + #expect(serverError.message == "Server error") + } } } -actor CallCounter { - private(set) var value = 0 - - func increment() -> Int { - value += 1 - return value +@Test func handlesOffline() async { + await #expect(throws: ArticleEndpoint.TaskError.self) { + try await withMock(ArticleEndpoint.self, action: .throw(.internetConnectionOffline)) { + try await URLSession.shared.response(with: ArticleEndpoint()) + } } } ``` -### Mocking Several Endpoints at Once +The `test` closure's thrown errors are untyped, so cast to the endpoint's `TaskError` before matching its cases. -When a flow touches more than one endpoint, register them together instead of nesting -`withMock` calls. Endpoint types without a registered mock pass through to the real -transport, nested scopes merge, and an inner mock for the same endpoint type shadows -the outer one for the duration of its scope: +## Combine -```swift -@Test func testProfileAfterRefresh() async throws { - 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 URLSession.shared.response(with: ProfileEndpoint()) - #expect(profile.name == "Zac") - } -} -``` - -## Combine Support - -Mocking works seamlessly with Combine publishers: +Publishers from `endpointPublisher(with:)` return mocks too: ```swift -import Testing -import Endpoints -import EndpointsMocking -@preconcurrency import Combine - -@Suite("Combine Mocking") @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) -struct CombineMockingTests { - - @Test func testCombinePublisher() async throws { - try await withMock(MyEndpoint.self, action: .return(.init(userId: "123", name: "Test"))) { - let endpoint = MyEndpoint(pathComponents: .init(userId: "123")) - - let response = try await URLSession.shared - .endpointPublisher(with: endpoint) - .awaitFirst() - - #expect(response.name == "Test") - } +@Test func loadsProfileWithCombine() async throws { + try await withMock(ProfileEndpoint.self, action: .return(.init(name: "Zac"))) { + let profile = try await URLSession.shared + .endpointPublisher(with: ProfileEndpoint()) + .firstValue() + #expect(profile.name == "Zac") } } -// Helper to await the first value of a publisher. -// `Publisher.values` needs iOS 15 / macOS 12, so annotate it for older deployment targets. @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) extension Publisher where Output: Sendable { - func awaitFirst() async throws -> Output { + func firstValue() async throws -> Output { for try await value in values { return value } @@ -264,159 +185,94 @@ extension Publisher where Output: Sendable { } ``` -## Testing Errors +Subscribe inside the `withMock` block. Mocks apply to work started there. -### Testing Error Responses +## Authentication -```swift -@Test func testErrorResponse() async throws { - struct ServerError: Codable, Equatable { - let code: Int - let message: String - } - - struct ErrorEndpoint: Endpoint { - typealias Server = ApiServer - typealias ErrorResponse = ServerError - - static let definition: Definition = Definition( - method: .get, - path: "error" - ) - - struct Response: Decodable { - let value: String - } - } - - try await withMock(ErrorEndpoint.self) { continuation in - continuation.resume(failingWith: ServerError(code: 500, message: "Server Error")) - } test: { - do { - _ = try await URLSession.shared.response(with: ErrorEndpoint()) - #expect(Bool(false), "Expected error to be thrown") - } catch { - guard case .errorResponse(_, let errorResponse) = error as? ErrorEndpoint.TaskError else { - #expect(Bool(false), "Wrong error type") - return - } - #expect(errorResponse.code == 500) - #expect(errorResponse.message == "Server Error") - } - } -} -``` - -### Testing Thrown Errors +A mocked request skips authentication. The endpoint's ``AuthenticationMethod`` isn't called, and a mocked error doesn't start a refresh or a retry. To test how your code handles an authentication failure, throw one: ```swift -@Test func testThrownError() async throws { - await #expect(throws: MyEndpoint.TaskError.self) { - try await withMock(MyEndpoint.self) { continuation in - continuation.resume(throwing: .internetConnectionOffline) - } test: { - _ = try await URLSession.shared.response(with: MyEndpoint()) - } +await #expect(throws: ProfileEndpoint.TaskError.self) { + try await withMock(ProfileEndpoint.self, action: .throw(.authenticationError(.notAuthenticated))) { + try await URLSession.shared.response(with: ProfileEndpoint()) } } ``` -## Best Practices - -### 1. Mock at the Endpoint Type - -Mocks are keyed by endpoint type, so one `withMock(MyEndpoint.self)` covers every -request of that type inside the block regardless of the instance's path, query, or body -values. Keep endpoint types focused so a mock does not have to answer for unrelated -requests. +### Testing the refresh flow -### 2. Organize Mock Data - -Create helper functions or extensions for common mock scenarios: +To test refresh and retry, send real requests through a `URLSession` whose `URLProtocol` returns canned responses: ```swift -extension MyEndpoint { - static func mockSuccess(userId: String, name: String) -> MockAction { - .return(.init(userId: userId, name: name)) - } - - static func mockNotFound() -> MockAction { - .fail(.init(code: 404, message: "User not found")) +final class StubProtocol: URLProtocol { + nonisolated(unsafe) static var responses: [(Int, Data)] = [] + nonisolated(unsafe) static var requests: [URLRequest] = [] + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.requests.append(request) + let (status, data) = Self.responses.removeFirst() + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) } -} -// Usage -try await withMock(MyEndpoint.self, action: .mockSuccess(userId: "123", name: "Test")) { - // test code + override func stopLoading() {} } -``` -### 3. Test Error Cases +@Suite(.serialized) +struct RefreshTests { + @Test func retriesWithRefreshedToken() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubProtocol.self] + let session = URLSession(configuration: configuration) + + StubProtocol.requests = [] + StubProtocol.responses = [ + (401, Data(#"{}"#.utf8)), + (200, Data(#"{"name":"Zac"}"#.utf8)) + ] + + let auth = JWTAuth(initialTokens: .init(accessToken: "old", refreshToken: "refresh")) { refreshToken in + .init(accessToken: "new", refreshToken: refreshToken) + } -Always test both success and failure paths: + let profile = try await session.response(with: ProfileEndpoint(), auth: auth) -```swift -@Suite("User Endpoint Tests") -struct UserEndpointTests { - - @Test func successCase() async throws { ... } - - @Test func notFoundCase() async throws { ... } - - @Test func networkErrorCase() async throws { ... } - - @Test func decodingErrorCase() async throws { ... } + #expect(profile.name == "Zac") + #expect(StubProtocol.requests.map { $0.value(forHTTPHeaderField: "Authorization") } == ["Bearer old", "Bearer new"]) + } } ``` -### 4. Select the Environment Per Request +The stub's static state is shared, so run these tests serially. -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: +## Testing request construction + +To check the URL, headers, or body an endpoint produces, build the request without sending it: ```swift -@Test func testStagingEnvironment() async throws { - let response = try await URLSession.shared.response( - with: MyEndpoint(), - environment: .staging - ) +@Test func searchQuery() throws { + let request = try SearchEndpoint(parameterComponents: .init(query: "swift", page: 2)) + .urlRequest(in: .staging) - // Test code... + #expect(request.url?.absoluteString == "https://staging-api.example.com/search?q=swift&page=2&format=compact") } ``` -## Authentication and Mocks - -A mocked request short-circuits before authentication: the endpoint's -``AuthenticationMethod`` is never invoked, no credentials are applied, and a mock error -does not enter the refresh/retry loop. To simulate an authentication failure, throw one -directly: - -```swift -try await withMock(ProfileEndpoint.self, action: .throw(.authenticationError(.notAuthenticated))) { - await #expect(throws: ProfileEndpoint.TaskError.self) { - try await URLSession.shared.response(with: ProfileEndpoint()) - } -} -``` +``Endpoint/urlRequest(in:)`` doesn't apply authentication, so the request has no credentials. -To exercise the refresh flow itself — credentials applied, a 401, a refresh, a retry — -use a `URLProtocol`-based fake transport on a dedicated `URLSession` instead of a mock. +## How it works -## Limitations +`withMock` stores mocks in task-local values. They apply to requests made inside the `test` closure and in child tasks, and don't affect tests running in parallel. A request made from `Task.detached` doesn't see them. -- Mocking only works in DEBUG builds and on Apple platforms; the `EndpointsMocking` - module is not built on Linux. -- A mock applies to every request of its endpoint type within the block. To vary the - response between requests, use a dynamic mock as shown above. -- Mocks bypass authentication entirely (see above). +The async/await and Combine methods check for a mock before building the request. `endpointTask(with:completion:)` returns its task before it can check, so in DEBUG builds the library swizzles `URLSessionTask.resume()` to deliver the mock when the task starts. -## How It Works +## Topics -`withMock` stores the registered mocks in task-local state, so they are visible to -every request made from within the `test` closure — including from child tasks — and -invisible to anything running concurrently outside it. The async/await and Combine -request methods consult that state before building a request. The closure-based -`endpointTask` cannot, because it returns a `URLSessionDataTask` synchronously; for that -path the library swizzles `URLSessionTask.resume()` in DEBUG builds so the task delivers -the mock instead of hitting the network. +- ``MockRegistry`` +- ``MockContinuation`` +- ``MockAction`` diff --git a/Sources/Endpoints/Extensions/URLSession+Async.swift b/Sources/Endpoints/Extensions/URLSession+Async.swift index 349f6a3..d6b1f8f 100644 --- a/Sources/Endpoints/Extensions/URLSession+Async.swift +++ b/Sources/Endpoints/Extensions/URLSession+Async.swift @@ -15,11 +15,12 @@ import FoundationNetworking @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) public extension URLSession { - /// Perform the request for the endpoint. + /// Sends the endpoint's request and ignores the response body. /// - /// Use this when the response body is expected to be `Void` or empty as you would have in a 204. + /// The endpoint's credentials are applied, and the request is retried after a + /// refresh when the authentication method asks for one. /// - Parameters: - /// - endpoint: The endpoint instance to be used to make the request + /// - endpoint: The endpoint to request. /// - environment: The environment to resolve the base URL against. Defaults to the /// server's ``ServerDefinition/defaultEnvironment``. /// - auth: The credentials to authenticate with. Defaults to the endpoint's @@ -32,9 +33,12 @@ public extension URLSession { try await performRequest(with: endpoint, environment: environment, auth: auth) { (_) throws(T.TaskError) in () } } - /// Perform the request for the endpoint, returning the raw response body. + /// Sends the endpoint's request and returns the response body without decoding it. + /// + /// The endpoint's credentials are applied, and the request is retried after a + /// refresh when the authentication method asks for one. /// - Parameters: - /// - endpoint: The endpoint instance to be used to make the request + /// - endpoint: The endpoint to request. /// - environment: The environment to resolve the base URL against. Defaults to the /// server's ``ServerDefinition/defaultEnvironment``. /// - auth: The credentials to authenticate with. Defaults to the endpoint's @@ -47,9 +51,12 @@ public extension URLSession { try await performRequest(with: endpoint, environment: environment, auth: auth) { (data) throws(T.TaskError) in data } } - /// Perform the request for the endpoint, decoding the response body. + /// Sends the endpoint's request and decodes the response with its ``Endpoint/responseDecoder``. + /// + /// The endpoint's credentials are applied, and the request is retried after a + /// refresh when the authentication method asks for one. /// - Parameters: - /// - endpoint: The endpoint instance to be used to make the request + /// - endpoint: The endpoint to request. /// - environment: The environment to resolve the base URL against. Defaults to the /// server's ``ServerDefinition/defaultEnvironment``. /// - auth: The credentials to authenticate with. Defaults to the endpoint's @@ -75,8 +82,8 @@ public extension URLSession { /// `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. + /// An active mock returns here, before authentication. A mocked request never + /// applies credentials and never enters the retry loop. private func performRequest( with endpoint: T, environment: T.Server.Environments, diff --git a/Sources/Endpoints/Extensions/URLSession+Combine.swift b/Sources/Endpoints/Extensions/URLSession+Combine.swift index 2c98d6e..e3a9133 100644 --- a/Sources/Endpoints/Extensions/URLSession+Combine.swift +++ b/Sources/Endpoints/Extensions/URLSession+Combine.swift @@ -13,7 +13,7 @@ import Combine /// Bridges a single-value async operation into Combine. /// -/// Holds the in-flight `Task` so that cancelling the subscription cancels the work, and +/// Holds the in-flight `Task` so that canceling 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. @@ -23,7 +23,7 @@ private final class AsyncBridge: @unchecked Se private var task: Task? /// Starts `work`, delivering its result to `promise` unless the subscription is - /// cancelled first. + /// canceled first. func begin( promise: @escaping (Result) -> Void, work: @escaping @Sendable () async -> Result @@ -38,7 +38,7 @@ private final class AsyncBridge: @unchecked Se lock.lock() if self.promise == nil { - // Cancelled between starting and storing the task. + // Canceled between starting and storing the task. lock.unlock() task.cancel() } else { @@ -92,18 +92,18 @@ private func endpointPublisher( @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. + /// Creates a publisher that sends the endpoint's request and ignores the response body. /// - /// 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. + /// The request is sent when the publisher is subscribed to. The endpoint's credentials + /// are applied, and the request is retried after a refresh when the authentication + /// method asks for one. Canceling the subscription cancels the request. /// - Parameters: - /// - endpoint: The request data to insert into the ``Definition`` + /// - endpoint: The endpoint to request. /// - environment: The environment to resolve the base URL against. Defaults to the /// server's ``ServerDefinition/defaultEnvironment``. /// - auth: The credentials to authenticate with. Defaults to the endpoint's /// declared ``Endpoint/auth``. - /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` + /// - Returns: A publisher that emits one value or fails with the endpoint's `TaskError`. func endpointPublisher( with endpoint: T, environment: T.Server.Environments = T.Server.defaultEnvironment, @@ -114,18 +114,18 @@ public extension URLSession { } } - /// Creates a publisher and starts the request for the given ``Definition``. This function expects a result value of `Data`. + /// Creates a publisher that sends the endpoint's request and returns the response body without decoding it. /// - /// 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. + /// The request is sent when the publisher is subscribed to. The endpoint's credentials + /// are applied, and the request is retried after a refresh when the authentication + /// method asks for one. Canceling the subscription cancels the request. /// - Parameters: - /// - endpoint: The request data to insert into the ``Definition`` + /// - endpoint: The endpoint to request. /// - environment: The environment to resolve the base URL against. Defaults to the /// server's ``ServerDefinition/defaultEnvironment``. /// - auth: The credentials to authenticate with. Defaults to the endpoint's /// declared ``Endpoint/auth``. - /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` + /// - Returns: A publisher that emits one value or fails with the endpoint's `TaskError`. func endpointPublisher( with endpoint: T, environment: T.Server.Environments = T.Server.defaultEnvironment, @@ -136,18 +136,18 @@ public extension URLSession { } } - /// Creates a publisher and starts the request for the given ``Definition``. This function expects a result value which is `Decodable`. + /// Creates a publisher that sends the endpoint's request and decodes the response with its ``Endpoint/responseDecoder``. /// - /// 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. + /// The request is sent when the publisher is subscribed to. The endpoint's credentials + /// are applied, and the request is retried after a refresh when the authentication + /// method asks for one. Canceling the subscription cancels the request. /// - Parameters: - /// - endpoint: The request data to insert into the ``Definition`` + /// - endpoint: The endpoint to request. /// - environment: The environment to resolve the base URL against. Defaults to the /// server's ``ServerDefinition/defaultEnvironment``. /// - auth: The credentials to authenticate with. Defaults to the endpoint's /// declared ``Endpoint/auth``. - /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` + /// - Returns: A publisher that emits one value or fails with the endpoint's `TaskError`. func endpointPublisher( with endpoint: T, environment: T.Server.Environments = T.Server.defaultEnvironment, diff --git a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift index b36da8e..3ae8f25 100644 --- a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift +++ b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift @@ -12,17 +12,26 @@ import Foundation import FoundationNetworking #endif -/// A error when creating or requesting an Endpoint +/// An error building, sending, or decoding an endpoint's request. public enum EndpointTaskError: Error, Sendable { + /// The request couldn't be built. case endpointError(EndpointError) + /// A successful response's body couldn't be decoded as the endpoint's ``Endpoint/Response``. case responseParseError(data: Data, error: Error) + /// The response had no body, and its status code wasn't 204. case unexpectedResponse(httpResponse: HTTPURLResponse) + /// The server returned an error status code, and its body was decoded as the + /// endpoint's ``Endpoint/ErrorResponse``. case errorResponse(httpResponse: HTTPURLResponse, response: ErrorResponseType) + /// The server returned an error status code, and its body couldn't be decoded as + /// the endpoint's ``Endpoint/ErrorResponse``. case errorResponseParseError(httpResponse: HTTPURLResponse, data: Data, error: Error) + /// `URLSession` failed to load the request. case urlLoadError(Error) + /// The device isn't connected to the internet. case internetConnectionOffline /// An authentication operation failed while applying or refreshing the @@ -31,11 +40,10 @@ public enum EndpointTaskError: Error, Sendable { } public extension EndpointTaskError { - /// The HTTP response associated with this error, when the failure came from a - /// response the server actually returned. + /// The HTTP response the server returned, if the error came from one. /// - /// `nil` for failures that happened before or instead of a response, such as - /// request construction, transport errors, and being offline. + /// `nil` when the request failed before a response arrived, such as when it couldn't + /// be built, the connection failed, or the device was offline. var httpResponse: HTTPURLResponse? { switch self { case .errorResponse(let httpResponse, _), @@ -49,22 +57,24 @@ public extension EndpointTaskError { } public extension Endpoint { - /// Shorthand for an ``EndpointTaskError`` with the request's generic ``Endpoint/ErrorResponse`` + /// The ``EndpointTaskError`` thrown by this endpoint's requests. typealias TaskError = EndpointTaskError } public extension URLSession { - /// Creates a session data task using the ``Definition`` associated with the passed in request on the passed in environment. - /// This function does not expect a result value from the endpoint. - /// Note: This does not start the request. That must be done with `resume()`. + /// Creates a data task for an endpoint whose response body is ignored. + /// + /// Only accepts endpoints whose ``Endpoint/auth`` is ``NoAuth``, because the task is + /// returned before an asynchronous authentication method could run. Use + /// `response(with:environment:auth:)` for authenticated endpoints. /// - Parameters: - /// - endpoint: The request data to use when filling in the ``Definition`` + /// - endpoint: The endpoint to request. /// - environment: The environment to resolve the base URL against. Defaults to the /// server's ``ServerDefinition/defaultEnvironment``. - /// - 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. + /// - completion: Called with the result on the session's delegate queue. + /// - Throws: ``EndpointTaskError/endpointError(_:)`` if the request can't be built. + /// - Returns: A data task. Call `resume()` to start it. 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, in: environment) @@ -96,16 +106,18 @@ public extension URLSession { return task } - /// Creates a session data task using the ``Definition`` associated with the passed in request on the passed in environment. - /// This function expects a result value of `Data`. - /// Note: This does not start the request. That must be done with `resume()`. + /// Creates a data task for an endpoint that returns the raw response body. + /// + /// Only accepts endpoints whose ``Endpoint/auth`` is ``NoAuth``, because the task is + /// returned before an asynchronous authentication method could run. Use + /// `response(with:environment:auth:)` for authenticated endpoints. /// - Parameters: - /// - endpoint: The request data to use when filling in the ``Definition`` + /// - endpoint: The endpoint to request. /// - environment: The environment to resolve the base URL against. Defaults to the /// server's ``ServerDefinition/defaultEnvironment``. - /// - 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. + /// - completion: Called with the result on the session's delegate queue. + /// - Throws: ``EndpointTaskError/endpointError(_:)`` if the request can't be built. + /// - Returns: A data task. Call `resume()` to start it. 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, in: environment) @@ -135,16 +147,18 @@ public extension URLSession { return task } - /// Creates a session data task using the ``Definition`` associated with the passed in request on the passed in environment. - /// This function expects a result value which is `Decodable`. - /// Note: This does not start the request. That must be done with `resume()`. + /// Creates a data task for an endpoint whose response is decoded. + /// + /// Only accepts endpoints whose ``Endpoint/auth`` is ``NoAuth``, because the task is + /// returned before an asynchronous authentication method could run. Use + /// `response(with:environment:auth:)` for authenticated endpoints. /// - Parameters: - /// - endpoint: The request data to use when filling in the ``Definition`` + /// - endpoint: The endpoint to request. /// - environment: The environment to resolve the base URL against. Defaults to the /// server's ``ServerDefinition/defaultEnvironment``. - /// - 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. + /// - completion: Called with the result on the session's delegate queue. + /// - Throws: ``EndpointTaskError/endpointError(_:)`` if the request can't be built. + /// - Returns: A data task. Call `resume()` to start it. 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, in: environment) diff --git a/Sources/Endpoints/Header.swift b/Sources/Endpoints/Header.swift index 5126ff8..028ec0e 100644 --- a/Sources/Endpoints/Header.swift +++ b/Sources/Endpoints/Header.swift @@ -8,51 +8,52 @@ import Foundation -/// An HTTP header name and category +/// An HTTP header name. /// -/// Custom headers can be included in your ``Definition`` by passing a dictionary of `[Header: HeaderField]` to the headers property. The ``HeaderField`` enum values can include references by key paths to ``Endpoint/HeaderComponents``s or to hard-coded strings: +/// Pass headers to a ``Definition`` as a dictionary of ``HeaderField`` values. A field +/// reads a property of the endpoint's ``Endpoint/HeaderComponents``, or sends a fixed +/// value: /// /// ```swift /// static let definition: Definition = Definition( /// method: .get, -/// path: "/request", +/// path: "user", /// headers: [ -/// "X-TYPE": HeaderField.field(path: \UserEndpoint.HeaderValues.type), -/// "X-VALUE": .fieldValue(value: "value"), +/// "X-Type": .field(path: \.type), +/// "X-Value": .fieldValue(value: "value"), /// .keepAlive: .fieldValue(value: "timeout=5, max=1000") /// ] /// ) /// ``` /// -/// Custom keys in the headers dictionary can be defined ad-hoc using a String, or by extending the encapsulating type `Header`. Basic named headers, such as `.keepAlive`, `.accept`, etc., are already defined as part of the library. +/// Write a custom header as a string literal, or add a static property in an extension +/// of `Header`. Common headers such as ``accept`` and ``keepAlive`` are already defined. public struct Header: Hashable, ExpressibleByStringLiteral, Sendable { - /// The name of the header. Example: "Accept-Language" + /// The header name, such as `Accept-Language`. public let name: String - /// The category of the header. - /// See: https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 + /// The header's category, as defined in [RFC 2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2). public let category: HeaderCategory - /// Initializes a Header instance with the name and a category. + /// Creates a header. /// - Parameters: - /// - name: The name of the Header. Example: "Accept-Language" - /// - category: The category of the header. Defaults to `.general` + /// - name: The header name, such as `Accept-Language`. + /// - category: The header's category. Defaults to ``HeaderCategory/general``. public init(name: String, category: HeaderCategory = .general) { self.name = name self.category = category } - /// Initializes a Header instance with the name of the header as a string literal. The category defaults to `.general` - /// - Parameter value: The string literal to use as the name of the Header. Example: "X-MY-CUSTOM-HEADER" + /// Creates a header from a string literal, in the ``HeaderCategory/general`` category. + /// - Parameter value: The header name, such as `X-Request-ID`. public init(stringLiteral value: StringLiteralType) { self.name = value self.category = .general } } -/// The Header category. -/// See: https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 +/// A header category, as defined in [RFC 2616](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2). public enum HeaderCategory: Sendable { case general case request diff --git a/Sources/Endpoints/Method.swift b/Sources/Endpoints/Method.swift index f0aa351..4ec0a30 100644 --- a/Sources/Endpoints/Method.swift +++ b/Sources/Endpoints/Method.swift @@ -8,7 +8,7 @@ import Foundation -/// The HTTP Method +/// An HTTP request method. public enum Method: Sendable { case options case get @@ -20,6 +20,7 @@ public enum Method: Sendable { case trace case connect + /// The method name sent in the request, such as `GET`. public var methodString: String { switch self { case .options: return "OPTIONS" diff --git a/Sources/Endpoints/Mocking/MockContinuation.swift b/Sources/Endpoints/Mocking/MockContinuation.swift index 488646d..18aa7ab 100644 --- a/Sources/Endpoints/Mocking/MockContinuation.swift +++ b/Sources/Endpoints/Mocking/MockContinuation.swift @@ -9,13 +9,13 @@ import Foundation -/// Actions that can be performed by a mock response. +/// How a mocked request responds. /// -/// Use these actions in the `withMock` closure to specify how the mock should respond: -/// - `.return(value)`: Return a successful response -/// - `.fail(errorResponse)`: Return a server error response -/// - `.throw(error)`: Throw a network or task error -/// - `.none`: Perform no action (pass through to actual request) +/// - `.return(value)`: The request returns `value`. +/// - `.fail(errorResponse)`: The request throws ``EndpointTaskError/errorResponse(httpResponse:response:)``. +/// - `.throw(error)`: The request throws `error`. +/// - `.none`: The request is sent to the network. With `endpointTask(with:completion:)`, +/// the request isn't sent and the completion handler isn't called. public enum MockAction: Sendable { case none case `return`(Value) @@ -23,14 +23,14 @@ public enum MockAction: Sendable { case `throw`(EndpointTaskError) } -/// A continuation passed to the `withMock` closure to configure mock responses. +/// Sets the response for one mocked request. /// -/// The continuation provides methods to specify what response or error should be returned -/// when the endpoint is requested. Call one of the `resume` methods to configure the mock. +/// `withMock` passes a continuation to your closure for each request. Call one of the +/// `resume` methods. If you call none, the mock acts as ``MockAction/none``. /// /// ```swift /// try await withMock(MyEndpoint.self) { continuation in -/// continuation.resume(returning: .init(userId: "123", name: "Test")) +/// continuation.resume(returning: .init(name: "Zac")) /// } test: { /// let response = try await URLSession.shared.response(with: MyEndpoint()) /// } @@ -46,28 +46,28 @@ public class MockContinuation where T.Response: Sendable { self.action = action } - /// Resumes the mock with a successful response value. - /// - Parameter value: The response value to return + /// Returns `value` from the request. + /// - Parameter value: The response to return. public func resume(returning value: T.Response) { action = .return(value) } - /// Resumes the mock with an error response. - /// Use this when the server returns a structured error. - /// - Parameter error: The error response from the server + /// Throws ``EndpointTaskError/errorResponse(httpResponse:response:)`` with `error`. + /// + /// The error's `httpResponse` is a placeholder with status code 200. + /// - Parameter error: The decoded error response. public func resume(failingWith error: T.ErrorResponse) { action = .fail(error) } - /// Resumes the mock by throwing a task error. - /// Use this to simulate network failures or other request errors. - /// - Parameter error: The error to throw + /// Throws `error` from the request, such as ``EndpointTaskError/internetConnectionOffline``. + /// - Parameter error: The error to throw. 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) + /// Responds with `action`. + /// - Parameter action: The action to perform. public func resume(with action: MockAction) { self.action = action } diff --git a/Sources/Endpoints/Mocking/MockRegistry.swift b/Sources/Endpoints/Mocking/MockRegistry.swift index 06d6ac1..a2336fe 100644 --- a/Sources/Endpoints/Mocking/MockRegistry.swift +++ b/Sources/Endpoints/Mocking/MockRegistry.swift @@ -7,19 +7,17 @@ import Foundation -/// Collects mocks for multiple endpoint types within a single `withMock(registering:test:)` scope. +/// Collects mocks for several endpoint types in one `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. +/// Register a ``MockAction`` or a closure for each endpoint type. Endpoint types +/// without a mock make real requests. Registering a type again replaces its mock. /// /// ```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()) +/// let profile = try await URLSession.shared.response(with: ProfileEndpoint()) /// } /// ``` public final class MockRegistry { @@ -27,20 +25,20 @@ public final class MockRegistry { init() {} - /// Registers a pre-configured mock action for an endpoint type. + /// Mocks every request of `type` with `action`. /// - Parameters: - /// - type: The endpoint type to mock - /// - action: The mock action to perform (return, fail, throw, or none) + /// - type: The endpoint type to mock. + /// - action: How the requests respond. 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. + /// Mocks every request of `type` with a closure that runs once per request. /// - Parameters: - /// - type: The endpoint type to mock - /// - body: A closure that receives a ``MockContinuation`` to configure the mock response + /// - type: The endpoint type to mock. + /// - body: A closure that sets the response on the ``MockContinuation`` it receives. public func register(_ type: T.Type, _ body: @Sendable @escaping (MockContinuation) async -> Void) { wrappers[ObjectIdentifier(type)] = ToReturnWrapper(body) } diff --git a/Sources/Endpoints/ParameterRepresentable.swift b/Sources/Endpoints/ParameterRepresentable.swift index 2045cd9..03f6fba 100644 --- a/Sources/Endpoints/ParameterRepresentable.swift +++ b/Sources/Endpoints/ParameterRepresentable.swift @@ -8,15 +8,15 @@ import Foundation -/// A type that can be converted for use on the value of a Parameter +/// A type that can be sent as a query or form parameter value. public protocol ParameterRepresentable { - /// Returns a path safe version for use in query parameters + /// The value to send, or `nil` to leave the parameter out. var parameterValue: String? { get } } extension String: ParameterRepresentable { - /// Returns `self` as the parameter representable version. + /// Returns the string. public var parameterValue: String? { return self } @@ -24,7 +24,7 @@ extension String: ParameterRepresentable { extension Double: ParameterRepresentable { - /// Returns a string representation of the `Double` value. + /// Returns the value as a string. public var parameterValue: String? { return "\(self)" } @@ -32,7 +32,7 @@ extension Double: ParameterRepresentable { extension Int: ParameterRepresentable { - /// Returns a string representation of the `Int` value. + /// Returns the value as a string. public var parameterValue: String? { return "\(self)" } @@ -40,7 +40,7 @@ extension Int: ParameterRepresentable { extension Bool: ParameterRepresentable { - /// Returns "true" and "false" based on the value of `self`. + /// Returns `"true"` or `"false"`. public var parameterValue: String? { return self ? "true" : "false" } @@ -48,7 +48,7 @@ extension Bool: ParameterRepresentable { extension Date: ParameterRepresentable { - /// Returns an ISO8601 formatted string for the value of `self`. + /// Returns the date in ISO 8601 format. public var parameterValue: String? { return ISO8601DateFormatter.string(from: self, timeZone: Calendar.current.timeZone, @@ -58,7 +58,7 @@ extension Date: ParameterRepresentable { extension TimeZone: ParameterRepresentable { - /// Returns the `identifier` string of this `TimeZone`. + /// Returns the time zone's identifier. public var parameterValue: String? { return self.identifier } diff --git a/Sources/Endpoints/PathTemplate.swift b/Sources/Endpoints/PathTemplate.swift index c71d5e7..d396c3d 100644 --- a/Sources/Endpoints/PathTemplate.swift +++ b/Sources/Endpoints/PathTemplate.swift @@ -8,8 +8,9 @@ import Foundation -public protocol PathRepresentable: Sendable{ - /// A path-safe version of the value, suitable for a URL path +/// A type that can be interpolated into a ``PathTemplate``. +public protocol PathRepresentable: Sendable { + /// The value, percent-encoded for a URL path. var pathSafe: String { get } } @@ -34,7 +35,16 @@ extension Int: PathRepresentable { } } -/// A template representing a URL path +/// A URL path with placeholders filled from an endpoint's ``Endpoint/PathComponents``. +/// +/// Create one from a string literal, interpolating key paths with `\(path:)`: +/// +/// ```swift +/// let path: PathTemplate = "calendars/\(path: \.calendarId)/events/\(path: \.eventId)" +/// ``` +/// +/// Slashes are added between components as needed. Pass `includesSlash: false` to +/// place a value directly against the surrounding text. public struct PathTemplate: Sendable { private struct RepresentableInfo: Equatable, Sendable { @@ -87,6 +97,7 @@ public struct PathTemplate: Sendable { } } + /// Returns the path with each placeholder replaced by the matching property of `value`. public func path(with value: T) -> String { let values = keyPathComponents.map { (index, path, includesSlash) -> RepresentableInfo in return RepresentableInfo(index: index, representable: value[keyPath: path] as! PathRepresentable, includesSlash: includesSlash) diff --git a/Sources/Endpoints/Server.swift b/Sources/Endpoints/Server.swift index 64f4376..b8c826a 100644 --- a/Sources/Endpoints/Server.swift +++ b/Sources/Endpoints/Server.swift @@ -24,21 +24,19 @@ public enum TypicalEnvironments: String, CaseIterable, Sendable { /// Defines the server configuration for endpoints. /// -/// Conform to this protocol to create a server definition that specifies -/// base URLs for different environments, the authentication method its endpoints -/// share, and request processing behavior. +/// A server lists a base URL for each environment, the authentication method its +/// endpoints share, and an optional request processor. /// -/// 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, which is what -/// lets two clients talk to different environments at the same time. -/// ``defaultEnvironment`` provides the default for those parameters. +/// Each request chooses its environment with the `environment:` argument of the +/// `URLSession` methods or ``Endpoint/urlRequest(in:)``. The environment isn't global +/// state, so concurrent requests can use different environments. +/// ``defaultEnvironment`` is used when a request doesn't choose one. /// -/// Credentials are usually bound to one environment: a token issued by staging is not -/// valid against production. A server-wide ``auth`` therefore suits apps that talk to -/// a single environment at a time. An app that switches environments per request -/// should pass matching credentials with the `auth:` parameter, and may leave the -/// server's ``auth`` undeclared. +/// Credentials usually belong to one environment: a token issued by staging isn't +/// valid against production. A server-wide ``auth`` fits an app that talks to one +/// environment at a time. An app that switches environments should pass matching +/// credentials with each request's `auth:` argument, and doesn't need to declare +/// ``auth``. /// /// ```swift /// struct ApiServer: ServerDefinition { @@ -53,32 +51,30 @@ public enum TypicalEnvironments: String, CaseIterable, Sendable { /// } /// ``` public protocol ServerDefinition: Sendable { - /// The environment type for this server. Defaults to ``TypicalEnvironments``. - /// - /// Must be `Sendable`: environments are passed per request and may cross task - /// boundaries. + /// The server's environments. Defaults to ``TypicalEnvironments``. associatedtype Environments: Hashable & Sendable = TypicalEnvironments - /// The ``AuthenticationMethod`` applied to endpoints on this server. Defaults to ``NoAuth``. + /// The type of ``auth``. Defaults to ``NoAuth``. associatedtype Auth: AuthenticationMethod = NoAuth - /// Required initializer for creating server instances. + /// Creates the server. ``Definition`` uses this when an endpoint doesn't pass a server. init() - /// Maps environments to their base URLs. + /// The base URL for each environment. A request for an environment with no entry + /// fails with ``EndpointError/misconfiguredServer(server:)``. var baseUrls: [Environments: URL] { get } - /// Optional request processor to modify requests before sending. + /// Changes each request after it is built. Defaults to returning the request unchanged. /// - /// Use this for static, synchronous request modification such as signing. For - /// credentials that can expire and be refreshed, use ``auth`` instead. + /// Use it for static changes, such as adding a build-number header. Use ``auth`` + /// for credentials. var requestProcessor: @Sendable (URLRequest) -> URLRequest { get } - /// The authentication method instance shared by all endpoints on this server. + /// The authentication method 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. + /// Declare it with `static let`, so every endpoint uses the same instance. A stateful + /// method such as ``JWTAuth`` needs that to keep its tokens and to combine concurrent + /// refreshes. /// /// ```swift /// struct ApiServer: ServerDefinition { @@ -88,12 +84,12 @@ public protocol ServerDefinition: Sendable { /// ``` static var auth: Auth { get } - /// The default environment to use when none is explicitly set. + /// The environment a request uses when it doesn't choose one. static var defaultEnvironment: Environments { get } } public extension ServerDefinition { - /// Default passthrough request processor that returns the request unchanged. + /// Returns the request unchanged. var requestProcessor: @Sendable (URLRequest) -> URLRequest { return { $0 } } } @@ -102,19 +98,21 @@ public extension ServerDefinition where Auth == NoAuth { static var auth: NoAuth { return NoAuth() } } -/// A generic server implementation that can be used as a default for simple endpoints. -/// Supports multiple environments (development, staging, production) with configurable base URLs. +/// A server configured with base URLs at runtime. +/// +/// Endpoints that don't name a server use this type. Pass a configured instance to +/// ``Definition/init(server:method:path:parameters:headers:)``. public struct GenericServer: ServerDefinition { public let baseUrls: [Environments: URL] public let requestProcessor: @Sendable (URLRequest) -> URLRequest - /// Creates a GenericServer with the given base URLs for different environments. + /// Creates a server with a base URL for each environment you pass. /// - Parameters: - /// - local: URL for local development (optional) - /// - development: URL for development environment (optional) - /// - staging: URL for staging environment (optional) - /// - production: URL for production environment (optional) - /// - requestProcessor: Optional request processor for modifying requests (default: passthrough) + /// - local: The base URL for ``TypicalEnvironments/local``. + /// - development: The base URL for ``TypicalEnvironments/development``. + /// - staging: The base URL for ``TypicalEnvironments/staging``. + /// - production: The base URL for ``TypicalEnvironments/production``. + /// - requestProcessor: Changes each request after it is built. public init( local: URL? = nil, development: URL? = nil, @@ -131,10 +129,10 @@ public struct GenericServer: ServerDefinition { self.requestProcessor = requestProcessor } - /// Creates a GenericServer with a single base URL used for all environments. + /// Creates a server that uses one base URL for every environment. /// - Parameters: - /// - baseUrl: The base URL to use for all environments - /// - requestProcessor: Optional request processor for modifying requests (default: passthrough) + /// - baseUrl: The base URL. + /// - requestProcessor: Changes each request after it is built. public init(baseUrl: URL, requestProcessor: @Sendable @escaping (URLRequest) -> URLRequest = { $0 }) { self.baseUrls = [ .local: baseUrl, @@ -145,12 +143,10 @@ public struct GenericServer: ServerDefinition { self.requestProcessor = requestProcessor } - /// Required parameterless initializer for ServerDefinition conformance. + /// Creates a server with no base URLs. /// - /// Creates a GenericServer with no base URLs configured, so every request built - /// against it fails with ``EndpointError/misconfiguredServer(server:)``. Pass a - /// configured instance to ``Definition/init(server:method:path:parameters:headers:)`` - /// instead. + /// Every request built against it fails with + /// ``EndpointError/misconfiguredServer(server:)``. Use one of the other initializers. public init() { self.baseUrls = [:] self.requestProcessor = { @Sendable in $0 } diff --git a/Sources/EndpointsMocking/EndpointsMocking.swift b/Sources/EndpointsMocking/EndpointsMocking.swift index 54691ea..012adb3 100644 --- a/Sources/EndpointsMocking/EndpointsMocking.swift +++ b/Sources/EndpointsMocking/EndpointsMocking.swift @@ -8,75 +8,70 @@ import Foundation @testable import Endpoints -/// Executes a test block with mocking enabled for the specified endpoint type. +/// Runs `test` with requests of an endpoint type answered by a closure. /// -/// Use this function to intercept network requests and provide mock responses instead of -/// making actual network calls. The mock applies to all requests of the specified endpoint -/// type within the test block. +/// `body` runs once per request of `ofType` made inside `test`, including from child +/// tasks. Requests of other endpoint types make real requests. /// /// ```swift /// try await withMock(MyEndpoint.self) { continuation in -/// continuation.resume(returning: .init(userId: "123", name: "Test")) +/// continuation.resume(returning: .init(name: "Zac")) /// } test: { /// let response = try await URLSession.shared.response(with: MyEndpoint()) -/// #expect(response.userId == "123") +/// #expect(response.name == "Zac") /// } /// ``` /// /// - Parameters: -/// - ofType: The endpoint type to mock -/// - body: A closure that receives a ``MockContinuation`` to configure the mock response -/// - test: The test code that will execute with mocking enabled -/// - Returns: The value returned by the test block +/// - ofType: The endpoint type to mock. +/// - body: A closure that sets the response on the ``MockContinuation`` it receives. +/// - test: The code to run with the mock in place. +/// - Returns: The value `test` returns. public func withMock(_ ofType: T.Type, _ body: @Sendable @escaping (MockContinuation) async -> Void, test: @Sendable @escaping () async throws -> R) async rethrows -> R { return try await Mocking.shared.withMock(T.self, body, test: test) } -/// Executes a test block with a pre-configured mock action. -/// -/// This is a convenience variant that accepts a ``MockAction`` directly instead of a closure. -/// Use this for simple cases where you don't need dynamic response generation. +/// Runs `test` with every request of an endpoint type answered by `action`. /// /// ```swift -/// try await withMock(MyEndpoint.self, action: .return(.init(userId: "123", name: "Test"))) { +/// try await withMock(MyEndpoint.self, action: .return(.init(name: "Zac"))) { /// let response = try await URLSession.shared.response(with: MyEndpoint()) -/// #expect(response.name == "Test") +/// #expect(response.name == "Zac") /// } /// ``` /// /// - Parameters: -/// - ofType: The endpoint type to mock -/// - action: The mock action to perform (return, fail, throw, or none) -/// - test: The test code that will execute with mocking enabled -/// - Returns: The value returned by the test block +/// - ofType: The endpoint type to mock. +/// - action: How the requests respond. +/// - test: The code to run with the mock in place. +/// - Returns: The value `test` returns. 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 continuation.resume(with: action) }, test: test) } -/// Executes a test block with mocks registered for multiple endpoint types. +/// Runs `test` with mocks for several 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. +/// Use this instead of nesting `withMock` calls when a flow uses several endpoints, +/// such as a request whose token refresh calls a second endpoint. Endpoint types +/// without a mock make real requests. Nested scopes combine, and an inner mock for the +/// same endpoint type replaces the outer one until the inner scope ends. /// /// ```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()) +/// let profile = try await URLSession.shared.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 +/// - registering: A closure that registers mocks on the ``MockRegistry`` it receives. +/// - test: The code to run with the mocks in place. +/// - Returns: The value `test` returns. 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) } From 19402316a05b62dbb72de355720af3dd40f004bf Mon Sep 17 00:00:00 2001 From: Zac White Date: Tue, 6 Oct 2026 10:21:40 -0700 Subject: [PATCH 3/5] Host docs on the Swift Package Index and remove the Pages workflow The Documentation workflow had failed on every run since the release trigger was added, so the hosted docs were from 2024. It also built only the Endpoints module. .spi.yml asks the Swift Package Index to build and host docs for both Endpoints and EndpointsMocking on each release. EndpointsMocking gets a landing page, and its doc comments no longer link to types in the other module, which separately built archives can't resolve. The README badges and documentation link now point to the index. --- .github/workflows/documentation.yml | 59 ------------------- .spi.yml | 4 ++ README.md | 6 +- .../EndpointsMocking.docc/EndpointsMocking.md | 32 ++++++++++ .../EndpointsMocking/EndpointsMocking.swift | 4 +- 5 files changed, 42 insertions(+), 63 deletions(-) delete mode 100644 .github/workflows/documentation.yml create mode 100644 .spi.yml create mode 100644 Sources/EndpointsMocking/EndpointsMocking.docc/EndpointsMocking.md diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml deleted file mode 100644 index 9281941..0000000 --- a/.github/workflows/documentation.yml +++ /dev/null @@ -1,59 +0,0 @@ -# Workflow for building DocC documentation and deploying to GitHub Pages -name: Documentation - -on: - # Runs on releases - release: - types: [published] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - docs: - runs-on: macos-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Pages - uses: actions/configure-pages@v5 - - name: Cache Swift packages - uses: actions/cache@v4 - with: - path: .build - key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} - restore-keys: | - ${{ runner.os }}-spm- - - name: Generate Docs - run: | - xcodebuild docbuild -scheme Endpoints -destination generic/platform=iOS OTHER_DOCC_FLAGS="--transform-for-static-hosting --hosting-base-path Endpoints --output-path docs" - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: ./docs - - # Single deploy job since we're just deploying - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - runs-on: ubuntu-latest - needs: docs - - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..592aabf --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [Endpoints, EndpointsMocking] diff --git a/README.md b/README.md index c6ea2b9..5b569c7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Endpoints -![CI](https://github.com/velos/Endpoints/workflows/CI/badge.svg) ![Documentation](https://github.com/velos/Endpoints/workflows/Documentation/badge.svg) +![CI](https://github.com/velos/Endpoints/workflows/CI/badge.svg) +[![Swift versions](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fvelos%2FEndpoints%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/velos/Endpoints) +[![Platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fvelos%2FEndpoints%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/velos/Endpoints) Endpoints describes HTTP endpoints as Swift types: the path, method, parameters, headers, body, and response. From that description it builds a `URLRequest` and decodes the response, using plain `URLSession`. It doesn't replace the URL loading system the way Alamofire does, and the requests it builds work with Alamofire if you prefer that. @@ -312,7 +314,7 @@ Endpoint types without a mock make real requests. Mocked requests skip authentic ## Documentation -The [API documentation](https://velosmobile.com/Endpoints/documentation/endpoints/) is published with each release. You can also build it in Xcode with Product > Build Documentation. The [Examples](Sources/Endpoints/Endpoints.docc/Examples.md) and [Mocking](Sources/Endpoints/Endpoints.docc/Mocking.md) guides are readable on GitHub. +The [API documentation](https://swiftpackageindex.com/velos/Endpoints/documentation) is hosted on the Swift Package Index and updated with each release, for both `Endpoints` and `EndpointsMocking`. You can also build it in Xcode with Product > Build Documentation. The [Examples](Sources/Endpoints/Endpoints.docc/Examples.md), [Authentication](Sources/Endpoints/Endpoints.docc/Authentication.md), and [Mocking](Sources/Endpoints/Endpoints.docc/Mocking.md) guides are readable on GitHub. ## Migrating from 0.4 diff --git a/Sources/EndpointsMocking/EndpointsMocking.docc/EndpointsMocking.md b/Sources/EndpointsMocking/EndpointsMocking.docc/EndpointsMocking.md new file mode 100644 index 0000000..630b98d --- /dev/null +++ b/Sources/EndpointsMocking/EndpointsMocking.docc/EndpointsMocking.md @@ -0,0 +1,32 @@ +# ``EndpointsMocking`` + +Replace endpoint responses in tests without making network requests. + +## Overview + +`withMock` replaces the response for an endpoint type while a block of test code runs: + +```swift +import Testing +import Endpoints +import EndpointsMocking + +@Test func loadsProfile() async throws { + try await withMock(ProfileEndpoint.self, action: .return(.init(name: "Zac"))) { + let profile = try await URLSession.shared.response(with: ProfileEndpoint()) + #expect(profile.name == "Zac") + } +} +``` + +The types you use inside a mock, `MockAction`, `MockContinuation`, and `MockRegistry`, are part of the Endpoints module. The Mocking article in the Endpoints documentation covers errors, dynamic responses, mocking several endpoints, and testing the token refresh flow. + +Mocking is available in DEBUG builds on Apple platforms. + +## Topics + +### Mocking Endpoints + +- ``withMock(_:action:test:)`` +- ``withMock(_:_:test:)`` +- ``withMock(registering:test:)`` diff --git a/Sources/EndpointsMocking/EndpointsMocking.swift b/Sources/EndpointsMocking/EndpointsMocking.swift index 012adb3..ca4abe0 100644 --- a/Sources/EndpointsMocking/EndpointsMocking.swift +++ b/Sources/EndpointsMocking/EndpointsMocking.swift @@ -24,7 +24,7 @@ import Foundation /// /// - Parameters: /// - ofType: The endpoint type to mock. -/// - body: A closure that sets the response on the ``MockContinuation`` it receives. +/// - body: A closure that sets the response on the `MockContinuation` it receives. /// - test: The code to run with the mock in place. /// - Returns: The value `test` returns. public func withMock(_ ofType: T.Type, _ body: @Sendable @escaping (MockContinuation) async -> Void, test: @Sendable @escaping () async throws -> R) async rethrows -> R { @@ -69,7 +69,7 @@ public func withMock(_ ofType: T.Type, action: MockAct /// ``` /// /// - Parameters: -/// - registering: A closure that registers mocks on the ``MockRegistry`` it receives. +/// - registering: A closure that registers mocks on the `MockRegistry` it receives. /// - test: The code to run with the mocks in place. /// - Returns: The value `test` returns. public func withMock(registering: (MockRegistry) -> Void, test: @Sendable @escaping () async throws -> R) async rethrows -> R { From a63e47cbd80ccd100589bc549836c884681c1a79 Mon Sep 17 00:00:00 2001 From: Zac White Date: Tue, 6 Oct 2026 10:33:04 -0700 Subject: [PATCH 4/5] Point the install snippet at 0.6.0 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b569c7..a743e4f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Add the package to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/velos/Endpoints.git", from: "0.5.0") + .package(url: "https://github.com/velos/Endpoints.git", from: "0.6.0") ] ``` From 12acb3d31fe7240de1952264ac2b0ee742295e8a Mon Sep 17 00:00:00 2001 From: Zac White Date: Tue, 6 Oct 2026 10:33:33 -0700 Subject: [PATCH 5/5] Point the install snippet at 0.5.1 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a743e4f..c471d0b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Add the package to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/velos/Endpoints.git", from: "0.6.0") + .package(url: "https://github.com/velos/Endpoints.git", from: "0.5.1") ] ```