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 2a1ebb0..c471d0b 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,45 @@ # 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 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.1") +] +``` -## 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") + ] +) +``` + +## Getting Started -### Defining a Server +### Define a server -First, define a server that conforms to `ServerDefinition`. This encapsulates your base URLs for different environments: +A server lists its base URL for each environment and names the environment requests use by default: ```swift import Endpoints @@ -31,7 +47,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,87 +58,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()`. -### Async/Await +To build the request without sending it, call `endpoint.urlRequest()`. + +## Environments + +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 { @@ -131,27 +148,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() } ... } @@ -162,34 +168,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) }, @@ -206,13 +209,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()`. +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. -### Custom authentication methods +An app that talks to several environments or accounts at once should pass the environment and credentials together on each request: -Conform to `AuthenticationMethod` to implement your own scheme. Only `authenticate(request:)` is required; refreshable credentials also implement `shouldReauthenticate(for:response:)` and `reauthenticate(after:)`: +```swift +struct 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`. Only `authenticate(request:)` is required: ```swift struct SignatureAuth: AuthenticationMethod { @@ -226,120 +252,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://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. -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 5a43573..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,27 +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". + /// 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( @@ -77,18 +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. 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 @@ -98,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 = [] @@ -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 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) 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, 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 } 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 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) } + + // 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,28 +265,37 @@ 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. + /// + /// A refresh in progress is discarded, and requests waiting for it use these tokens. public func setTokens(_ tokens: TokenPair) { currentTokens = tokens pendingRefresh?.cancel() pendingRefresh = nil } + /// Removes the current tokens, for example after a logout. + /// + /// 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 360b9e3..5d6016d 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. @@ -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 1983729..5835712 100644 --- a/Sources/Endpoints/Endpoints.docc/Endpoints.md +++ b/Sources/Endpoints/Endpoints.docc/Endpoints.md @@ -1,48 +1,92 @@ # ``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`` -- ``ServerDefinition`` - ``Definition`` -- +- ``ServerDefinition`` -### Server Configuration +### Servers and Environments -- ``ServerDefinition`` - ``GenericServer`` - ``TypicalEnvironments`` +### Building Requests + +- ``Method`` +- ``PathTemplate`` +- ``PathRepresentable`` +- ``Parameter`` +- ``ParameterRepresentable`` +- ``QueryEncodingStrategy`` +- ``Header`` +- ``HeaderField`` +- ``HeaderCategory`` + +### Encoding and Decoding + +- ``EncoderType`` +- ``DecoderType`` +- ``EmptyCodable`` +- ``MultipartFormEncoder`` +- ``MultipartFormFile`` +- ``MultipartFormJSON`` + ### Authentication +- - ``AuthenticationMethod`` - ``AuthenticationError`` +- ``RefreshReentrancyError`` - ``NoAuth`` - ``HeaderKeyAuth`` - ``BasicAuth`` - ``CookieAuth`` - ``JWTAuth`` -### Testing and Mocking +### Sending Requests and Handling Errors + +- ``Foundation/URLSession`` +- ``EndpointTaskError`` +- ``EndpointError`` + +### Testing - -- ``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..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,563 +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") -) +enum Region: Hashable, Sendable { + case us + case eu +} + +struct RegionalServer: ServerDefinition { + typealias Environments = Region + + var baseUrls: [Environments: URL] { + [ + .us: URL(string: "https://us.api.example.com")!, + .eu: URL(string: "https://eu.api.example.com")! + ] + } + + static var defaultEnvironment: Environments { .us } +} ``` -### Custom Environments +### Choosing an environment -You can define custom environment types beyond the standard ``TypicalEnvironments``: +Each request takes an `environment:` argument, which defaults to the server's ``ServerDefinition/defaultEnvironment``: ```swift -enum CustomEnvironments: String, CaseIterable, Sendable { - case debug - case testing - case production -} +let response = try await URLSession.shared.response(with: MyEndpoint(), environment: .staging) +let request = try MyEndpoint().urlRequest(in: .staging) +``` -struct CustomServer: ServerDefinition { - typealias Environments = CustomEnvironments - - 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")! - ] - } +The environment belongs to the request, not to global state, so concurrent requests can use different environments. + +### Processing requests + +``ServerDefinition/requestProcessor`` runs synchronously on every request after it is built. Use it for static changes: + +```swift +struct ApiServer: ServerDefinition { + // baseUrls and defaultEnvironment as above - 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 + { request in + var request = request + request.setValue(AppInfo.buildNumber, forHTTPHeaderField: "X-Client-Build") + return request } } } ``` -> 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. +For credentials, use ``ServerDefinition/auth`` instead. See . -### Changing Environments +### Using GenericServer -Select the environment when performing a request: +An endpoint that doesn't name a server uses ``GenericServer``. Pass a configured instance to its ``Definition``: ```swift -let response = try await URLSession.shared.response(with: MyEndpoint(), environment: .staging) +struct StatusEndpoint: Endpoint { + static let definition: Definition = Definition( + server: GenericServer(baseUrl: URL(string: "https://status.example.com")!), + method: .get, + path: "status" + ) + + struct Response: Decodable { + let healthy: Bool + } +} ``` -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. +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 8363d0d..848b1d4 100644 --- a/Sources/Endpoints/Endpoints.docc/Mocking.md +++ b/Sources/Endpoints/Endpoints.docc/Mocking.md @@ -1,373 +1,278 @@ # 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: +Then import it in your tests: ```swift -import Testing // or XCTest +import Testing import Endpoints import EndpointsMocking ``` -## Basic Mocking +## Mocking a response -### Mocking a Successful Response - -Use `withMock` to wrap your test code and provide mock responses: +Pass a ``MockAction`` for simple cases: ```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") +@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") } } ``` -### Inline Mock Action - -For simple cases, use the inline action syntax: +Or pass a closure that receives a ``MockContinuation``. The closure runs once per request, and it can be `async`: ```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") - } +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") } ``` -## Mock Actions +### Actions -The `MockAction` enum provides four different actions: +| 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. | -### 1. Return a Success Response +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 . -```swift -continuation.resume(returning: responseObject) -// or inline: -withMock(MyEndpoint.self, action: .return(responseObject)) -``` +> 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. -### 2. Return an Error Response +## Mocking several endpoints -Use this when the server returns a structured error (matching your endpoint's `ErrorResponse` type): +Register several endpoint types in one scope instead of nesting `withMock` calls: ```swift -continuation.resume(failingWith: ErrorResponse(code: 404, message: "Not found")) -// or inline: -withMock(MyEndpoint.self, action: .fail(errorResponse)) +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") +} ``` -### 3. Throw a Task Error - -Use this to simulate network or parsing errors: +Nested scopes combine. An inner mock for the same endpoint type replaces the outer one until the inner scope ends. -```swift -continuation.resume(throwing: .internetConnectionOffline) -// or inline: -withMock(MyEndpoint.self, action: .throw(.internetConnectionOffline)) -``` +## Dynamic responses -### 4. Do Nothing - -For cases where you want the mock to not interfere (rarely used): +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 -// Just don't call any resume method, or: -withMock(MyEndpoint.self, action: .none) -``` - -## Advanced Mocking - -### Dynamic Responses Based on Request +actor CallCounter { + private(set) var count = 0 -The continuation closure receives the endpoint instance, allowing dynamic responses: - -```swift -@Test func testDynamicResponse() async throws { - 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")) - } - } test: { - let adminEndpoint = MyEndpoint(pathComponents: .init(userId: "admin")) - let adminResponse = try await URLSession.shared.response(with: adminEndpoint) - #expect(adminResponse.name == "Administrator") + func next() -> Int { + count += 1 + return count } } -``` - -### Async Mock Data Loading -You can load mock data asynchronously from files or other sources: +@Test func returnsDifferentProfiles() async throws { + let calls = CallCounter() -```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) + try await withMock(ProfileEndpoint.self) { continuation in + let call = await calls.next() + continuation.resume(returning: .init(name: "User \(call)")) } test: { - let endpoint = MyEndpoint(pathComponents: .init(userId: "123")) - let response = try await URLSession.shared.response(with: endpoint) - - #expect(response.userId == "123") - } -} + let first = try await URLSession.shared.response(with: ProfileEndpoint()) + let second = try await URLSession.shared.response(with: ProfileEndpoint()) -func loadMockData(filename: String) async throws -> Data { - let url = Bundle.module.url(forResource: filename, withExtension: nil)! - return try Data(contentsOf: url) + #expect(first.name == "User 1") + #expect(second.name == "User 2") + #expect(await calls.count == 2) + } } ``` -### Multiple Requests in One Mock Block - -The mock applies to all requests of the specified endpoint type within the test block: +To load a fixture file, catch errors inside the closure, since it can't throw: ```swift -@Test func testMultipleRequests() async throws { - var callCount = 0 - - try await withMock(MyEndpoint.self) { continuation in - callCount += 1 - continuation.resume(returning: .init(userId: "\(callCount)", name: "User \(callCount)")) - } 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) - #expect(response1.name == "User 1") - #expect(response2.name == "User 2") +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)) } +} test: { + // ... } ``` -## Combine Support - -Mocking works seamlessly with Combine publishers: +## Testing errors ```swift -import Testing -import Endpoints -import EndpointsMocking -@preconcurrency import Combine - -@Suite("Combine Mocking") -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 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") } } } -// 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() +@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()) } } } ``` -## Testing Errors +The `test` closure's thrown errors are untyped, so cast to the endpoint's `TaskError` before matching its cases. -### Testing Error Responses +## Combine + +Publishers from `endpointPublisher(with:)` return mocks too: ```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") - } +@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) +@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") } } -``` - -### Testing Thrown Errors -```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()) +@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) +extension Publisher where Output: Sendable { + func firstValue() async throws -> Output { + for try await value in values { + return value } + throw CancellationError() } } ``` -## Best Practices +Subscribe inside the `withMock` block. Mocks apply to work started there. -### 1. Use Type-Specific Mocks +## Authentication -Always specify the endpoint type explicitly to ensure type safety: +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 -// Good -withMock(MySpecificEndpoint.self) { ... } - -// Avoid (if possible) -withMock(endpoint) { ... } +await #expect(throws: ProfileEndpoint.TaskError.self) { + try await withMock(ProfileEndpoint.self, action: .throw(.authenticationError(.notAuthenticated))) { + try await URLSession.shared.response(with: ProfileEndpoint()) + } +} ``` -### 2. Organize Mock Data +### Testing the refresh flow -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)) +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) } - - static func mockNotFound() -> MockAction { - .fail(.init(code: 404, message: "User not found")) - } -} -// 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. + +## Testing request construction -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: +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") } ``` -## Limitations +``Endpoint/urlRequest(in:)`` doesn't apply authentication, so the request has no credentials. -- 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 +## How it works -## Migration from Old Mocking +`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. -If you were previously using a different mocking approach, the new `withMock` API offers several advantages: +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. -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 +## Topics -Replace manual URLProtocol mocking or stubbing with `withMock` for cleaner, more maintainable tests. +- ``MockRegistry`` +- ``MockContinuation`` +- ``MockAction`` 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..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,11 +33,16 @@ 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 - /// - environment: The environment to resolve the base URL against. - /// - auth: The credentials to authenticate with. + /// - 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``. func response( with endpoint: T, environment: T.Server.Environments = T.Server.defaultEnvironment, @@ -45,11 +51,16 @@ 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 - /// - environment: The environment to resolve the base URL against. - /// - auth: The credentials to authenticate with. + /// - 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``. func response( with endpoint: T, environment: T.Server.Environments = T.Server.defaultEnvironment, @@ -71,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 10626af..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,14 +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`` - /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` + /// - 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 that emits one value or fails with the endpoint's `TaskError`. func endpointPublisher( with endpoint: T, environment: T.Server.Environments = T.Server.defaultEnvironment, @@ -110,14 +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`` - /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` + /// - 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 that emits one value or fails with the endpoint's `TaskError`. func endpointPublisher( with endpoint: T, environment: T.Server.Environments = T.Server.defaultEnvironment, @@ -128,14 +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`` - /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` + /// - 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 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 ddcb144..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,20 +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`` - /// - 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. + /// - endpoint: The endpoint to request. + /// - environment: The environment to resolve the base URL against. Defaults to the + /// server's ``ServerDefinition/defaultEnvironment``. + /// - 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) @@ -94,14 +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`` - /// - 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. + /// - endpoint: The endpoint to request. + /// - environment: The environment to resolve the base URL against. Defaults to the + /// server's ``ServerDefinition/defaultEnvironment``. + /// - 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) @@ -131,14 +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`` - /// - 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. + /// - endpoint: The endpoint to request. + /// - environment: The environment to resolve the base URL against. Defaults to the + /// server's ``ServerDefinition/defaultEnvironment``. + /// - 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/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/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 6aba352..b8c826a 100644 --- a/Sources/Endpoints/Server.swift +++ b/Sources/Endpoints/Server.swift @@ -2,14 +2,155 @@ // 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. +/// +/// A server lists a base URL for each environment, the authentication method its +/// endpoints share, and an optional request processor. +/// +/// 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 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 { +/// 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 server's environments. Defaults to ``TypicalEnvironments``. + associatedtype Environments: Hashable & Sendable = TypicalEnvironments + + /// The type of ``auth``. Defaults to ``NoAuth``. + associatedtype Auth: AuthenticationMethod = NoAuth + + /// Creates the server. ``Definition`` uses this when an endpoint doesn't pass a server. + init() + + /// The base URL for each environment. A request for an environment with no entry + /// fails with ``EndpointError/misconfiguredServer(server:)``. + var baseUrls: [Environments: URL] { get } + + /// Changes each request after it is built. Defaults to returning the request unchanged. + /// + /// 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 shared by all endpoints on this server. + /// + /// 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 { + /// static let auth = JWTAuth(initialTokens: loadTokens(), refreshHandler: refresh) + /// ... + /// } + /// ``` + static var auth: Auth { get } + + /// The environment a request uses when it doesn't choose one. + static var defaultEnvironment: Environments { get } +} + +public extension ServerDefinition { + /// 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 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 server with a base URL for each environment you pass. + /// - Parameters: + /// - 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, + 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 server that uses one base URL for every environment. + /// - Parameters: + /// - 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, + .development: baseUrl, + .staging: baseUrl, + .production: baseUrl + ] + self.requestProcessor = requestProcessor + } + + /// Creates a server with no base URLs. + /// + /// 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 } + } + + public static var defaultEnvironment: Environments { .production } +} 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 54691ea..ca4abe0 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) } 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 } +}