diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 089c3ff..e694341 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,19 +1,70 @@ -name: Build +name: CI on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] -jobs: - build: +permissions: + contents: read +jobs: + build-macos: runs-on: macos-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + + - name: Cache Swift packages + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Build + run: swift build -v + + - name: Run tests + run: swift test -v + + - name: Upload test results on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results-macos + path: .build/debug/*Tests* + retention-days: 5 + build-linux: + runs-on: ubuntu-latest + timeout-minutes: 15 + container: swift:6.0 + steps: - - uses: actions/checkout@v2 - - name: Build - run: swift build -v - - name: Run tests - run: swift test -v + - uses: actions/checkout@v4 + + - name: Cache Swift packages + uses: actions/cache@v4 + with: + path: .build + key: ${{ runner.os }}-spm-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Build + run: swift build -v + + - name: Run tests + run: swift test -v + + - name: Upload test results on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results-linux + path: .build/debug/*Tests* + retention-days: 5 diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 126ff77..9281941 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -2,9 +2,9 @@ name: Documentation on: - # Runs on pushes targeting the default branch - push: - branches: ["main"] + # Runs on releases + release: + types: [published] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -26,14 +26,21 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Pages - uses: actions/configure-pages@v3 + 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@v2 + uses: actions/upload-pages-artifact@v3 with: path: ./docs @@ -49,4 +56,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@v4 diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Endpoints.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Endpoints.xcscheme index 14214dc..088a7f3 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Endpoints.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Endpoints.xcscheme @@ -62,6 +62,16 @@ ReferencedContainer = "container:"> + + + + = Definition( method: .get, path: "path/to/resource" @@ -36,13 +63,13 @@ struct MyEndpoint: Endpoint { } ``` -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. +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: #### Usage ```Swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) +URLSession.shared.endpointPublisher(with: MyEndpoint()) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -52,4 +79,94 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) .store(in: &cancellables) ``` -To browse more complex examples, make sure to check out the [Examples](https://github.com/velos/Endpoints/wiki/Examples) wiki page. +Notice that the API no longer requires passing an environment - it's handled automatically by the server definition. + +### Async/Await + +```swift +do { + let response = try await URLSession.shared.response(with: MyEndpoint()) + // handle response +} catch { + // handle error +} +``` + +## Testing with EndpointsMocking + +Endpoints includes a comprehensive mocking system through the `EndpointsMocking` module: + +```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") + } +} +``` + +The mocking system supports: +- Returning successful responses +- Returning error responses +- Throwing network errors +- Dynamic response generation +- Combine publisher mocking + +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. + +## Requirements + +- Swift 6.0+ +- iOS 15.0+ / macOS 12.0+ / tvOS 15.0+ / watchOS 8.0+ + +## Installation + +### Swift Package Manager + +Add the following to your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/velos/Endpoints.git", from: "2.0.0") +] +``` + +For testing, also add: + +```swift +testTarget( + name: "YourTests", + dependencies: ["Endpoints", "EndpointsMocking"] +) +``` + +## 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: + +1. **ServerDefinition replaces EnvironmentType** - Define your environments in a `ServerDefinition` conforming type +2. **No more environment parameter** - Remove `in: .production` from all API calls +3. **Add Server typealias** - Add `typealias Server = YourServer` to your endpoints +4. **Swift 6.0 required** - Update your Swift toolchain + +See the [Migration Guide](https://github.com/velos/Endpoints/wiki/Migration) for detailed instructions. + +## License + +Endpoints is released under the MIT license. See LICENSE for details. diff --git a/Sources/Endpoints/Definition+URLResponse.swift b/Sources/Endpoints/Definition+URLResponse.swift index f65402c..0fa8d64 100644 --- a/Sources/Endpoints/Definition+URLResponse.swift +++ b/Sources/Endpoints/Definition+URLResponse.swift @@ -8,6 +8,10 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + 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. diff --git a/Sources/Endpoints/Endpoint+URLRequest.swift b/Sources/Endpoints/Endpoint+URLRequest.swift index c5f7ac6..4e39d69 100644 --- a/Sources/Endpoints/Endpoint+URLRequest.swift +++ b/Sources/Endpoints/Endpoint+URLRequest.swift @@ -18,7 +18,7 @@ extension Endpoint { /// - Parameter environment: The environment in which to create the request /// - Throws: An ``EndpointError`` which describes the error filling in data to the associated ``Definition``. /// - Returns: A `URLRequest` ready for requesting with all values from `self` filled in according to the associated ``Endpoint``. - public func urlRequest(in environment: EnvironmentType) throws -> URLRequest { + public func urlRequest() throws -> URLRequest { var components = URLComponents() components.path = Self.definition.path.path(with: pathComponents) @@ -92,7 +92,13 @@ extension Endpoint { .joined(separator: "&") } - let baseUrl = environment.baseUrl + let server = Self.definition.server + let baseUrl = server.baseUrls[type(of: server).environment] + + guard let baseUrl else { + throw EndpointError.misconfiguredServer(server: Self.definition.server) + } + guard let url = components.url(relativeTo: baseUrl) else { throw EndpointError.invalid(components: components, relativeTo: baseUrl) } @@ -148,7 +154,7 @@ extension Endpoint { urlRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: Header.contentType.name) } - urlRequest = environment.requestProcessor(urlRequest) + urlRequest = Self.definition.server.requestProcessor(urlRequest) return urlRequest } diff --git a/Sources/Endpoints/Endpoint.swift b/Sources/Endpoints/Endpoint.swift index b8021f2..c7e6883 100644 --- a/Sources/Endpoints/Endpoint.swift +++ b/Sources/Endpoints/Endpoint.swift @@ -12,28 +12,29 @@ import Foundation import FoundationNetworking #endif -public enum EndpointError: Error { +public enum EndpointError: Error, Sendable { case invalid(components: URLComponents, relativeTo: URL) case invalidQuery(named: String, type: Any.Type) case invalidForm(named: String, type: Any.Type) case invalidHeader(named: String, type: Any.Type) case invalidBody(Error) + case misconfiguredServer(server: any (ServerDefinition & Sendable)) } -public enum Parameter { - case form(String, path: PartialKeyPath) +public enum Parameter: Sendable { + case form(String, path: PartialKeyPath & Sendable) case formValue(String, value: PathRepresentable) - case query(String, path: PartialKeyPath) + case query(String, path: PartialKeyPath & Sendable) case queryValue(String, value: PathRepresentable) } -public enum HeaderField { - case field(path: PartialKeyPath) - case fieldValue(value: CustomStringConvertible) +public enum HeaderField: Sendable { + case field(path: PartialKeyPath & Sendable) + case fieldValue(value: CustomStringConvertible & Sendable) } /// A placeholder type for representing empty encodable or decodable Body values and ErrorResponse values. -public struct EmptyCodable: Codable { } +public struct EmptyCodable: Codable, Sendable { } public protocol EncoderType { static var contentType: String? { get } @@ -56,7 +57,9 @@ public protocol DecoderType { extension JSONDecoder: DecoderType { } -public protocol Endpoint { +public protocol Endpoint: Sendable { + + associatedtype Server: ServerDefinition = GenericServer /// The response type received from the server. /// @@ -64,13 +67,13 @@ public protocol Endpoint { /// 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. - associatedtype Response + associatedtype Response: Sendable /// The type representing the `Decodable` error response from the server. Defaults to an empty `Decodable` struct, ``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. - associatedtype ErrorResponse: Decodable = EmptyCodable + associatedtype ErrorResponse: Decodable & Sendable = EmptyCodable /// The body type conforming to `Encodable`. Defaults to ``EmptyCodable``. associatedtype Body: Encodable = EmptyCodable @@ -96,7 +99,7 @@ public protocol Endpoint { /// let pathComponents: PathComponents /// } /// ``` - associatedtype PathComponents = Void + associatedtype PathComponents: Sendable = Void /// The values needed to fill the ``Definition``'s parameters. /// @@ -112,10 +115,10 @@ public protocol Endpoint { /// ``` /// /// 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. - associatedtype ParameterComponents = Void + associatedtype ParameterComponents: Sendable = Void /// The values needed to fill the ``Definition``'s headers. - associatedtype HeaderComponents = Void + associatedtype HeaderComponents: Sendable = Void /// The ``EncoderType`` to use when encoding the body of the request. Defaults to `JSONEncoder`. associatedtype BodyEncoder: EncoderType = JSONEncoder @@ -197,8 +200,10 @@ public enum QueryEncodingStrategy { case custom((URLQueryItem) -> (String, String?)?) } -public struct Definition { +public struct Definition: Sendable { + /// The server this endpoints will use + public let server: T.Server /// The HTTP method of the ``Endpoint`` public let method: Method /// A template including all elements that appear in the path @@ -210,14 +215,17 @@ public struct Definition { /// Initializes a ``Definition`` with the given properties, defining all dynamic pieces as type-safe parameters. /// - 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. - /// - headerValues: The headers associated with this request - public init(method: Method, + /// - headers: The headers associated with this request + public init(server: T.Server = T.Server(), + method: Method, path: PathTemplate, parameters: [Parameter] = [], headers: [Header: HeaderField] = [:]) { + self.server = server self.method = method self.path = path self.parameters = parameters diff --git a/Sources/Endpoints/Endpoints.docc/Endpoints.md b/Sources/Endpoints/Endpoints.docc/Endpoints.md index 7dc0831..4a5e5cb 100644 --- a/Sources/Endpoints/Endpoints.docc/Endpoints.md +++ b/Sources/Endpoints/Endpoints.docc/Endpoints.md @@ -11,9 +11,24 @@ The purpose of Endpoints is to, in a type-safe way, define how to create a `URLR ### Essentials - ``Endpoint`` -- ``EnvironmentType`` +- ``ServerDefinition`` +- ``Definition`` - +### Server Configuration + +- ``ServerDefinition`` +- ``GenericServer`` +- ``TypicalEnvironments`` + +### Testing and Mocking + +- +- ``EndpointsMocking`` +- ``withMock(_:_:test:)`` +- ``MockContinuation`` +- ``MockAction`` + ### Making Requests #### Combine diff --git a/Sources/Endpoints/Endpoints.docc/Examples.md b/Sources/Endpoints/Endpoints.docc/Examples.md index 5851df7..fad2e3d 100644 --- a/Sources/Endpoints/Endpoints.docc/Examples.md +++ b/Sources/Endpoints/Endpoints.docc/Examples.md @@ -1,10 +1,96 @@ # Examples +## Defining a Server + +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. + +### Basic Server Definition + +```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")! + ] + } + + static var defaultEnvironment: Environments { .production } +} +``` + +### Using GenericServer + +For simple use cases, you can use the built-in ``GenericServer``: + +```swift +let server = GenericServer( + local: URL(string: "https://localhost:8080"), + staging: URL(string: "https://staging-api.example.com"), + production: URL(string: "https://api.example.com") +) +``` + +### Custom Environments + +You can define custom environment types beyond the standard ``TypicalEnvironments``: + +```swift +enum CustomEnvironments: String, CaseIterable, Sendable { + case debug + case testing + case production +} + +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")! + ] + } + + static var defaultEnvironment: Environments { .debug } + + var requestProcessor: (URLRequest) -> URLRequest { + return { request in + var mutableRequest = request + mutableRequest.setValue("Bearer token", forHTTPHeaderField: "Authorization") + return mutableRequest + } + } +} +``` + +### Changing Environments + +To switch environments at runtime, set the environment on the server type: + +```swift +// Switch to staging environment +ApiServer.environment = .staging + +// All subsequent requests will use the staging URL +``` + +--- + +## Endpoint Examples + ### GET Request #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { + typealias Server = ApiServer + static let definition: Definition = Definition( method: .get, path: "path/to/resource" @@ -19,7 +105,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) +URLSession.shared.endpointPublisher(with: MyEndpoint()) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -34,6 +120,8 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { + typealias Server = ApiServer + static let definition: Definition = Definition( method: .get, path: "user/\(path: \.userId)/resource" @@ -53,7 +141,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(pathComponents: .init(userId: "42"))) +URLSession.shared.endpointPublisher(with: MyEndpoint(pathComponents: .init(userId: "42"))) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -74,6 +162,8 @@ extension Header { } struct MyEndpoint: Endpoint { + typealias Server = ApiServer + static let definition: Definition = Definition( method: .get, path: "path/to/resource", @@ -99,7 +189,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(headerValues: .init(headerString: "headerValue", headerInt: 42))) +URLSession.shared.endpointPublisher(with: MyEndpoint(headerValues: .init(headerString: "headerValue", headerInt: 42))) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -114,6 +204,8 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(headerValu #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { + typealias Server = ApiServer + static let definition: Definition = Definition( method: .post, path: "path/to/resource" @@ -133,7 +225,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(body: .init(bodyName: "value"))) +URLSession.shared.endpointPublisher(with: MyEndpoint(body: .init(bodyName: "value"))) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -148,6 +240,8 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(body: .ini #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { + typealias Server = ApiServer + static let definition: Definition = Definition( method: .post, path: "path/to/resource", @@ -174,7 +268,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(parameters: .init(keyString: "value", keyInt: 42))) +URLSession.shared.endpointPublisher(with: MyEndpoint(parameters: .init(keyString: "value", keyInt: 42))) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -189,6 +283,8 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(parameters #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { + typealias Server = ApiServer + static let definition: Definition = Definition( method: .post, path: "path/to/resource", @@ -215,7 +311,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(parameters: .init(keyString: "value", keyInt: 42))) +URLSession.shared.endpointPublisher(with: MyEndpoint(parameters: .init(keyString: "value", keyInt: 42))) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -236,6 +332,8 @@ https://production.mydomain.com/path/to/resource?keyString=value&keyInt=42&key=h #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { + typealias Server = ApiServer + static let definition: Definition = Definition( method: .delete, path: "path/to/resource" @@ -247,7 +345,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) +URLSession.shared.endpointPublisher(with: MyEndpoint()) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -262,6 +360,8 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { + typealias Server = ApiServer + static let definition: Definition = Definition( method: .get, path: "path/to/resource" @@ -282,7 +382,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) +URLSession.shared.endpointPublisher(with: MyEndpoint()) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -298,6 +398,8 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) #### Endpoint and Definition ```swift struct MyEndpoint: Endpoint { + typealias Server = ApiServer + static let definition: Definition = Definition( method: .post, path: "path/to/resource" @@ -323,7 +425,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(body: .init(bodyValue: "value"))) +URLSession.shared.endpointPublisher(with: MyEndpoint(body: .init(bodyValue: "value"))) .sink { completion in guard case .failure(let error) = completion else { return } // handle error @@ -337,13 +439,14 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint(body: .ini #### Endpoint and Definition ```swift - struct ServerError: Decodable { let code: Int let message: String } struct MyEndpoint: Endpoint { + typealias Server = ApiServer + static let definition: Definition = Definition( method: .get, path: "path/to/resource" @@ -359,7 +462,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) +URLSession.shared.endpointPublisher(with: MyEndpoint()) .sink { completion in guard case .failure(let error) = completion else { return } switch error { @@ -384,6 +487,8 @@ struct ServerError: Decodable { } struct MyEndpoint: Endpoint { + typealias Server = ApiServer + static let definition: Definition = Definition( method: .get, path: "path/to/resource" @@ -405,7 +510,7 @@ struct MyEndpoint: Endpoint { #### Usage ```swift -URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) +URLSession.shared.endpointPublisher(with: MyEndpoint()) .sink { completion in guard case .failure(let error) = completion else { return } switch error { @@ -420,3 +525,56 @@ URLSession.shared.endpointPublisher(in: .production, with: MyEndpoint()) } .store(in: &cancellables) ``` + +### Async/Await Usage + +All endpoints can also be used with Swift's async/await: + +```swift +do { + let response = try await URLSession.shared.response(with: MyEndpoint()) + // handle response +} catch { + // handle error +} +``` + +### Multipart Form Upload + +For file uploads using multipart/form-data: + +```swift +struct UploadEndpoint: Endpoint { + typealias Server = ApiServer + + static let definition: Definition = Definition( + method: .post, + path: "upload" + ) + + struct Response: Decodable { + let fileId: String + } + + struct Body: MultipartFormEncodable { + let file: MultipartFile + let description: String + } + + 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) +``` diff --git a/Sources/Endpoints/Endpoints.docc/Mocking.md b/Sources/Endpoints/Endpoints.docc/Mocking.md new file mode 100644 index 0000000..57a7cdd --- /dev/null +++ b/Sources/Endpoints/Endpoints.docc/Mocking.md @@ -0,0 +1,374 @@ +# 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. + +## 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 + +## Setup + +Add the `EndpointsMocking` module to your test target dependencies: + +```swift +// Package.swift +testTarget( + name: "YourTests", + dependencies: ["Endpoints", "EndpointsMocking"] +) +``` + +Import the mocking module in your tests: + +```swift +import Testing // or XCTest +import Endpoints +import EndpointsMocking +``` + +## Basic Mocking + +### Mocking a Successful Response + +Use `withMock` to wrap your test code and provide mock responses: + +```swift +import Testing +import Endpoints +import EndpointsMocking + +@Test func testSuccessfulResponse() async throws { + try await withMock(MyEndpoint.self) { continuation in + // Provide the mock response + continuation.resume(returning: .init(userId: "123", name: "John Doe")) + } test: { + // Your actual test code + let endpoint = MyEndpoint(pathComponents: .init(userId: "123")) + let response = try await URLSession.shared.response(with: endpoint) + + #expect(response.userId == "123") + #expect(response.name == "John Doe") + } +} +``` + +### Inline Mock Action + +For simple cases, use the inline action syntax: + +```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") + } +} +``` + +## Mock Actions + +The `MockAction` enum provides four different actions: + +### 1. Return a Success Response + +```swift +continuation.resume(returning: responseObject) +// or inline: +withMock(MyEndpoint.self, action: .return(responseObject)) +``` + +### 2. Return an Error Response + +Use this when the server returns a structured error (matching your endpoint's `ErrorResponse` type): + +```swift +continuation.resume(failingWith: ErrorResponse(code: 404, message: "Not found")) +// or inline: +withMock(MyEndpoint.self, action: .fail(errorResponse)) +``` + +### 3. Throw a Task Error + +Use this to simulate network or parsing errors: + +```swift +continuation.resume(throwing: .internetConnectionOffline) +// or inline: +withMock(MyEndpoint.self, action: .throw(.internetConnectionOffline)) +``` + +### 4. Do Nothing + +For cases where you want the mock to not interfere (rarely used): + +```swift +// Just don't call any resume method, or: +withMock(MyEndpoint.self, action: .none) +``` + +## Advanced Mocking + +### Dynamic Responses Based on Request + +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") + } +} +``` + +### Async Mock Data Loading + +You can load mock data asynchronously from files or other sources: + +```swift +@Test func testWithAsyncMockLoading() async throws { + try await withMock(MyEndpoint.self) { continuation in + // Load mock from JSON file + let mockData = try await loadMockData(filename: "user_response.json") + let decoder = JSONDecoder() + let response = try decoder.decode(MyEndpoint.Response.self, from: mockData) + + continuation.resume(returning: response) + } test: { + let endpoint = MyEndpoint(pathComponents: .init(userId: "123")) + let response = try await URLSession.shared.response(with: endpoint) + + #expect(response.userId == "123") + } +} + +func loadMockData(filename: String) async throws -> Data { + let url = Bundle.module.url(forResource: filename, withExtension: nil)! + return try Data(contentsOf: url) +} +``` + +### Multiple Requests in One Mock Block + +The mock applies to all requests of the specified endpoint type within the test block: + +```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") + } +} +``` + +## Combine Support + +Mocking works seamlessly with Combine publishers: + +```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") + } + } +} + +// 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() + } + } +} +``` + +## Testing Errors + +### Testing Error Responses + +```swift +@Test func testErrorResponse() async throws { + struct ServerError: Codable, Equatable { + let code: Int + let message: String + } + + struct ErrorEndpoint: Endpoint { + typealias Server = ApiServer + typealias ErrorResponse = ServerError + + static let definition: Definition = Definition( + method: .get, + path: "error" + ) + + struct Response: Decodable { + let value: String + } + } + + try await withMock(ErrorEndpoint.self) { continuation in + continuation.resume(failingWith: ServerError(code: 500, message: "Server Error")) + } test: { + do { + _ = try await URLSession.shared.response(with: ErrorEndpoint()) + #expect(Bool(false), "Expected error to be thrown") + } catch { + guard case .errorResponse(_, let errorResponse) = error as? ErrorEndpoint.TaskError else { + #expect(Bool(false), "Wrong error type") + return + } + #expect(errorResponse.code == 500) + #expect(errorResponse.message == "Server Error") + } + } +} +``` + +### Testing Thrown Errors + +```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()) + } + } +} +``` + +## Best Practices + +### 1. Use Type-Specific Mocks + +Always specify the endpoint type explicitly to ensure type safety: + +```swift +// Good +withMock(MySpecificEndpoint.self) { ... } + +// Avoid (if possible) +withMock(endpoint) { ... } +``` + +### 2. Organize Mock Data + +Create helper functions or extensions for common mock scenarios: + +```swift +extension MyEndpoint { + static func mockSuccess(userId: String, name: String) -> MockAction { + .return(.init(userId: userId, name: name)) + } + + static func mockNotFound() -> MockAction { + .fail(.init(code: 404, message: "User not found")) + } +} + +// Usage +try await withMock(MyEndpoint.self, action: .mockSuccess(userId: "123", name: "Test")) { + // test code +} +``` + +### 3. Test Error Cases + +Always test both success and failure paths: + +```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 { ... } +} +``` + +### 4. Reset Environment After Tests + +If your tests change the server environment, reset it afterward: + +```swift +@Test func testStagingEnvironment() async throws { + let originalEnvironment = ApiServer.environment + ApiServer.environment = .staging + + defer { + ApiServer.environment = originalEnvironment + } + + // Test code... +} +``` + +## Limitations + +- 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 + +## Migration from Old Mocking + +If you were previously using a different mocking approach, the new `withMock` API offers several advantages: + +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 + +Replace manual URLProtocol mocking or stubbing with `withMock` for cleaner, more maintainable tests. diff --git a/Sources/Endpoints/EnvironmentType.swift b/Sources/Endpoints/EnvironmentType.swift index 226e7f7..ca49fea 100644 --- a/Sources/Endpoints/EnvironmentType.swift +++ b/Sources/Endpoints/EnvironmentType.swift @@ -12,13 +12,132 @@ import Foundation import FoundationNetworking #endif -public protocol EnvironmentType { - /// The baseUrl of the Environment - var baseUrl: URL { get } - /// Processes the built URLRequest right before sending in order to attach any Environment related authentication or data to the outbound request - var requestProcessor: (URLRequest) -> URLRequest { get } +/// 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 } -public extension EnvironmentType { - var requestProcessor: (URLRequest) -> URLRequest { return { $0 } } +/// 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``. + associatedtype Environments: Hashable = TypicalEnvironments + + /// 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 to add authentication headers or signatures. + var requestProcessor: @Sendable (URLRequest) -> URLRequest { 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 } } +} + +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 1509a0b..40f3afb 100644 --- a/Sources/Endpoints/Extensions/URLSession+Async.swift +++ b/Sources/Endpoints/Extensions/URLSession+Async.swift @@ -8,6 +8,10 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12, *) public extension URLSession { @@ -17,8 +21,14 @@ public extension URLSession { /// - Parameters: /// - environment: The environment in which to make the request /// - endpoint: The endpoint instance to be used to make the request - func response(in environment: EnvironmentType, with endpoint: T) async throws where T.Response == Void { - let urlRequest = try createUrlRequest(in: environment, for: endpoint) + func response(with endpoint: T) async throws where T.Response == Void { + let urlRequest = try createUrlRequest(for: endpoint) + + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) + if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { + return mockResponse + } + #endif let result: (data: Data, response: URLResponse) do { @@ -34,8 +44,14 @@ public extension URLSession { _ = try T.definition.response(data: result.data, response: result.response, error: nil).get() } - func response(in environment: EnvironmentType, with endpoint: T) async throws -> T.Response where T.Response == Data { - let urlRequest = try createUrlRequest(in: environment, for: endpoint) + func response(with endpoint: T) async throws -> T.Response where T.Response == Data { + let urlRequest = try createUrlRequest(for: endpoint) + + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) + if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { + return mockResponse + } + #endif let result: (data: Data, response: URLResponse) do { @@ -51,8 +67,14 @@ public extension URLSession { return try T.definition.response(data: result.data, response: result.response, error: nil).get() } - func response(in environment: EnvironmentType, with endpoint: T) async throws -> T.Response where T.Response: Decodable { - let urlRequest = try createUrlRequest(in: environment, for: endpoint) + func response(with endpoint: T) async throws -> T.Response where T.Response: Decodable { + let urlRequest = try createUrlRequest(for: endpoint) + + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) + if let mockResponse = try await Mocking.shared.handlMock(for: T.self) { + return mockResponse + } + #endif let result: (data: Data, response: URLResponse) do { diff --git a/Sources/Endpoints/Extensions/URLSession+Combine.swift b/Sources/Endpoints/Extensions/URLSession+Combine.swift index 55be5af..0163f3d 100644 --- a/Sources/Endpoints/Extensions/URLSession+Combine.swift +++ b/Sources/Endpoints/Extensions/URLSession+Combine.swift @@ -19,16 +19,16 @@ public extension URLSession { /// - environment: The environment with which to make the request /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` - func endpointPublisher(in environment: EnvironmentType, with endpoint: T) -> AnyPublisher where T.Response == Void { + func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Void { let urlRequest: URLRequest do { - urlRequest = try createUrlRequest(in: environment, for: endpoint) + urlRequest = try createUrlRequest(for: endpoint) } catch { return Fail(outputType: T.Response.self, failure: error as! T.TaskError) .eraseToAnyPublisher() } - return dataTaskPublisher(for: urlRequest) + let load = dataTaskPublisher(for: urlRequest) .subscribe(on: DispatchQueue.global()) .receive(on: DispatchQueue.global()) .mapError { error -> T.TaskError in @@ -43,7 +43,24 @@ public extension URLSession { } // swiftlint:disable:next force_cast .mapError { $0 as! T.TaskError } - .eraseToAnyPublisher() + + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) + return Mocking.shared.handleMock(for: T.self) + .flatMap { mock in + if let mock { + return Just(mock) + .setFailureType(to: T.TaskError.self) + .eraseToAnyPublisher() + } else { + return load + .eraseToAnyPublisher() + } + } + .eraseToAnyPublisher() + #else + return load + .eraseToAnyPublisher() + #endif } /// Creates a publisher and starts the request for the given ``Definition``. This function expects a result value of `Data`. @@ -51,17 +68,17 @@ public extension URLSession { /// - environment: The environment with which to make the request /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` - func endpointPublisher(in environment: EnvironmentType, with endpoint: T) -> AnyPublisher where T.Response == Data { + func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response == Data { let urlRequest: URLRequest do { - urlRequest = try createUrlRequest(in: environment, for: endpoint) + urlRequest = try createUrlRequest(for: endpoint) } catch { return Fail(outputType: T.Response.self, failure: error as! T.TaskError) .eraseToAnyPublisher() } - return dataTaskPublisher(for: urlRequest) + let load = dataTaskPublisher(for: urlRequest) .subscribe(on: DispatchQueue.global()) .receive(on: DispatchQueue.global()) .mapError { error -> T.TaskError in @@ -76,7 +93,24 @@ public extension URLSession { } // swiftlint:disable:next force_cast .mapError { $0 as! T.TaskError } - .eraseToAnyPublisher() + + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) + return Mocking.shared.handleMock(for: T.self) + .flatMap { mock in + if let mock { + return Just(mock) + .setFailureType(to: T.TaskError.self) + .eraseToAnyPublisher() + } else { + return load + .eraseToAnyPublisher() + } + } + .eraseToAnyPublisher() + #else + return load + .eraseToAnyPublisher() + #endif } /// Creates a publisher and starts the request for the given ``Definition``. This function expects a result value which is `Decodable`. @@ -84,17 +118,18 @@ public extension URLSession { /// - environment: The environment with which to make the request /// - endpoint: The request data to insert into the ``Definition`` /// - Returns: A `Publisher` which fetches the ``Endpoint``'s contents. Any failures when creating the request are sent as errors in the `Publisher` - func endpointPublisher(in environment: EnvironmentType, with endpoint: T) -> AnyPublisher where T.Response: Decodable { + func endpointPublisher(with endpoint: T) -> AnyPublisher where T.Response: Decodable { let urlRequest: URLRequest do { - urlRequest = try createUrlRequest(in: environment, for: endpoint) + urlRequest = try createUrlRequest(for: endpoint) } catch { return Fail(outputType: T.Response.self, failure: error as! T.TaskError) .eraseToAnyPublisher() } + - return dataTaskPublisher(for: urlRequest) + let load = dataTaskPublisher(for: urlRequest) .subscribe(on: DispatchQueue.global()) .receive(on: DispatchQueue.global()) .mapError { error -> T.TaskError in @@ -114,7 +149,24 @@ public extension URLSession { } // swiftlint:disable:next force_cast .mapError { $0 as! T.TaskError } + + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) + return Mocking.shared.handleMock(for: T.self) + .flatMap { mock in + if let mock { + return Just(mock) + .setFailureType(to: T.TaskError.self) + .eraseToAnyPublisher() + } else { + return load + .eraseToAnyPublisher() + } + } + .eraseToAnyPublisher() + #else + return load .eraseToAnyPublisher() + #endif } } diff --git a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift index 0ede3cd..7e2b2af 100644 --- a/Sources/Endpoints/Extensions/URLSession+Endpoints.swift +++ b/Sources/Endpoints/Extensions/URLSession+Endpoints.swift @@ -13,7 +13,7 @@ import FoundationNetworking #endif /// A error when creating or requesting an Endpoint -public enum EndpointTaskError: Error { +public enum EndpointTaskError: Error, Sendable { case endpointError(EndpointError) case responseParseError(data: Data, error: Error) @@ -42,13 +42,35 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(in environment: EnvironmentType, with endpoint: T, completion: @escaping (Result) -> Void) throws -> URLSessionDataTask where T.Response == Void { + func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws -> URLSessionDataTask where T.Response == Void { - let urlRequest = try createUrlRequest(in: environment, for: endpoint) + let urlRequest = try createUrlRequest(for: endpoint) - return dataTask(with: urlRequest) { (data, response, error) in + let task = dataTask(with: urlRequest) { (data, response, error) in completion(T.definition.response(data: data, response: response, error: error).map { _ in }) } + + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) + if Mocking.shared.shouldHandleMock(for: T.self) { + task.resumeOverride = { + Task { + let action = await Mocking.shared.actionForMock(for: T.self)! + switch action { + case .none: + break + case .return(let value): + completion(.success(value)) + case .fail(let errorResponse): + completion(.failure(T.TaskError.errorResponse(httpResponse: HTTPURLResponse(), response: errorResponse))) + case .throw(let error): + completion(.failure(error)) + } + } + } + } + #endif + + return task } /// Creates a session data task using the ``Definition`` associated with the passed in request on the passed in environment. @@ -60,13 +82,33 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(in environment: EnvironmentType, with endpoint: T, completion: @escaping (Result) -> Void) throws -> URLSessionDataTask where T.Response == Data { + func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws -> URLSessionDataTask where T.Response == Data { - let urlRequest = try createUrlRequest(in: environment, for: endpoint) + let urlRequest = try createUrlRequest(for: endpoint) - return dataTask(with: urlRequest) { (data, response, error) in + let task = dataTask(with: urlRequest) { (data, response, error) in completion(T.definition.response(data: data, response: response, error: error)) } + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) + if Mocking.shared.shouldHandleMock(for: T.self) { + task.resumeOverride = { + Task { + let action = await Mocking.shared.actionForMock(for: T.self)! + switch action { + case .none: + break + case .return(let value): + completion(.success(value)) + case .fail(let errorResponse): + completion(.failure(T.TaskError.errorResponse(httpResponse: HTTPURLResponse(), response: errorResponse))) + case .throw(let error): + completion(.failure(error)) + } + } + } + } + #endif + return task } /// Creates a session data task using the ``Definition`` associated with the passed in request on the passed in environment. @@ -78,11 +120,11 @@ public extension URLSession { /// - completion: The completion handler to call when the load request is complete. This handler is executed on the delegate queue. /// - Throws: Throws an ``EndpointTaskError`` of ``EndpointTaskError/endpointError(_:)`` if there is an issue constructing the request. /// - Returns: The new session data task. - func endpointTask(in environment: EnvironmentType, with endpoint: T, completion: @escaping (Result) -> Void) throws -> URLSessionDataTask where T.Response: Decodable { + func endpointTask(with endpoint: T, completion: @escaping @Sendable (Result) -> Void) throws -> URLSessionDataTask where T.Response: Decodable { - let urlRequest = try createUrlRequest(in: environment, for: endpoint) + let urlRequest = try createUrlRequest(for: endpoint) - return dataTask(with: urlRequest) { (data, response, error) in + let task = dataTask(with: urlRequest) { (data, response, error) in let response = T.definition.response(data: data, response: response, error: error) switch response { case .success(let data): @@ -98,13 +140,33 @@ public extension URLSession { completion(.failure(failure)) } } + #if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) + if Mocking.shared.shouldHandleMock(for: T.self) { + task.resumeOverride = { + Task { + let action = await Mocking.shared.actionForMock(for: T.self)! + switch action { + case .none: + break + case .return(let value): + completion(.success(value)) + case .fail(let errorResponse): + completion(.failure(T.TaskError.errorResponse(httpResponse: HTTPURLResponse(), response: errorResponse))) + case .throw(let error): + completion(.failure(error)) + } + } + } + } + #endif + return task } - func createUrlRequest(in environment: EnvironmentType, for endpoint: T) throws -> URLRequest { + func createUrlRequest(for endpoint: T) throws -> URLRequest { let urlRequest: URLRequest do { - urlRequest = try endpoint.urlRequest(in: environment) + urlRequest = try endpoint.urlRequest() } catch { guard let endpointError = error as? EndpointError else { fatalError("Unhandled endpoint error: \(error)") diff --git a/Sources/Endpoints/Header.swift b/Sources/Endpoints/Header.swift index aee332c..95f763d 100644 --- a/Sources/Endpoints/Header.swift +++ b/Sources/Endpoints/Header.swift @@ -25,7 +25,7 @@ import Foundation /// ``` /// /// 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. -public struct Header: Hashable, ExpressibleByStringLiteral { +public struct Header: Hashable, ExpressibleByStringLiteral, Sendable { /// The name of the header. Example: "Accept-Language" public let name: String @@ -53,7 +53,7 @@ public struct Header: Hashable, ExpressibleByStringLiteral { /// The Header category. /// See: https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 -public enum HeaderCategory { +public enum HeaderCategory: Sendable { case general case request case response diff --git a/Sources/Endpoints/Method.swift b/Sources/Endpoints/Method.swift index a73560a..f0aa351 100644 --- a/Sources/Endpoints/Method.swift +++ b/Sources/Endpoints/Method.swift @@ -9,7 +9,7 @@ import Foundation /// The HTTP Method -public enum Method { +public enum Method: Sendable { case options case get case head diff --git a/Sources/Endpoints/Mocking/MockContinuation.swift b/Sources/Endpoints/Mocking/MockContinuation.swift new file mode 100644 index 0000000..57d6805 --- /dev/null +++ b/Sources/Endpoints/Mocking/MockContinuation.swift @@ -0,0 +1,70 @@ +// +// MockContinuation.swift +// Endpoints +// +// Created by Zac White on 11/30/24. +// + +#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) + +import Foundation + +/// Actions that can be performed by a mock response. +/// +/// 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) +public enum MockAction: Sendable { + case none + case `return`(Value) + case fail(ErrorResponse) + case `throw`(EndpointTaskError) +} + +/// A continuation passed to the `withMock` closure to configure mock responses. +/// +/// 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. +/// +/// ```swift +/// try await withMock(MyEndpoint.self) { continuation in +/// continuation.resume(returning: .init(userId: "123", name: "Test")) +/// } test: { +/// let response = try await URLSession.shared.response(with: MyEndpoint()) +/// } +/// ``` +public class MockContinuation where T.Response: Sendable { + var action: MockAction + + init(_ type: T.Type) { + self.action = .none + } + + init(action: MockAction = .none) { + self.action = action + } + + /// Resumes the mock with a successful response value. + /// - Parameter value: The response value 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 + 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 + public func resume(throwing error: EndpointTaskError) where T.ErrorResponse: Sendable { + action = .throw(error) + } +} + +#endif diff --git a/Sources/Endpoints/Mocking/Mocking.swift b/Sources/Endpoints/Mocking/Mocking.swift new file mode 100644 index 0000000..f2c1548 --- /dev/null +++ b/Sources/Endpoints/Mocking/Mocking.swift @@ -0,0 +1,136 @@ +// +// Mocking.swift +// Endpoints +// +// Created by Zac White on 11/30/24. +// + +#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) + +import Foundation + +struct ToReturnWrapper: Sendable { + private let toReturn: @Sendable (Any) async -> Void + init(_ toReturn: @Sendable @escaping (MockContinuation) async -> Void) { + self.toReturn = { value in + await toReturn(value as! MockContinuation) + } + } + + func toReturn(for: T.Type) -> ((MockContinuation) async -> Void) { + return { continuation in + await toReturn(continuation) + } + } +} + +/// Internal mocking system that intercepts URLSession requests. +/// +/// This type manages the mock state and coordinates between the `withMock` functions +/// and the URLSession task interception. It uses TaskLocal storage to track active mocks +/// and method swizzling to intercept data task resume calls. +struct Mocking { + + static let shared = Mocking() + + @TaskLocal + static private var current: ToReturnWrapper? + + init() { + // Initialize URLSession swizzling on first use + URLSessionTask.classInit + } + + /// Handles a mock request for the specified endpoint type (async/await version). + /// - Parameter endpointsOfType: The endpoint type being requested + /// - Returns: The mock response, or nil if no mock is active + func handlMock(for endpointsOfType: T.Type) async throws -> T.Response? { + guard let action = await actionForMock(for: T.self) else { + return nil + } + + switch action { + case .none: + return nil + case .return(let value): + return value + case .fail(let errorResponse): + throw T.TaskError.errorResponse(httpResponse: HTTPURLResponse(), response: errorResponse) + case .throw(let error): + throw error + } + } + + /// Sets up a mock context and executes the test block within it. + /// - Parameters: + /// - ofType: The endpoint type to mock + /// - body: Closure that configures the mock response + /// - test: The test code to execute with mocking enabled + func withMock(_ ofType: T.Type, _ body: @Sendable @escaping (MockContinuation) async -> Void, test: @escaping () async throws -> R) async rethrows -> R { + try await Self.$current.withValue(ToReturnWrapper(body)) { + try await test() + } + } +} + +extension Mocking { + /// Checks if a mock is currently active for the given endpoint type. + func shouldHandleMock(for endpointsOfType: T.Type) -> Bool { + Self.current != nil + } + + /// Retrieves the mock action for the specified endpoint type. + /// - Parameter endpointsOfType: The endpoint type being requested + /// - Returns: The configured mock action, or nil if no mock is active + func actionForMock(for endpointsOfType: T.Type) async -> MockAction? { + guard let current = Self.current else { + return nil + } + + let continuation = MockContinuation() + await current.toReturn(for: T.self)(continuation) + return continuation.action + } +} + +#if canImport(Combine) +@preconcurrency import Combine + +extension Mocking { + /// Handles a mock request for Combine publishers. + /// - Parameter endpointsOfType: The endpoint type being requested + /// - Returns: A publisher that emits the mock response or error + func handleMock(for endpointsOfType: T.Type) -> AnyPublisher { + guard shouldHandleMock(for: T.self) else { + return Just(nil) + .setFailureType(to: T.TaskError.self) + .eraseToAnyPublisher() + } + + let subject = CurrentValueSubject(nil) + + Task { + guard let action = await actionForMock(for: T.self) else { + subject.send(nil) + return + } + + switch action { + case .none: + subject.send(nil) + case .return(let value): + subject.send(value) + case .fail(let errorResponse): + subject.send(completion: .failure(T.TaskError.errorResponse(httpResponse: HTTPURLResponse(), response: errorResponse))) + case .throw(let error): + subject.send(completion: .failure(error)) + } + } + + return subject + .eraseToAnyPublisher() + } +} +#endif + +#endif diff --git a/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift b/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift new file mode 100644 index 0000000..49436be --- /dev/null +++ b/Sources/Endpoints/Mocking/URLSessionTask+Swizzling.swift @@ -0,0 +1,47 @@ +// +// URLSessionTask+Swizzling.swift +// Endpoints +// +// Created by Zac White on 12/4/24. +// + +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +#if DEBUG && (os(macOS) || os(iOS) || os(tvOS) || os(watchOS)) +/// Storage key for the resume override closure. +nonisolated(unsafe) private var resumeOverrideKey: UInt8 = 0 + +extension URLSessionTask { + /// A closure that overrides the default resume behavior. + /// When set, this closure is called instead of the actual network request. + var resumeOverride: (() -> Void)? { + get { + return (objc_getAssociatedObject(self, &resumeOverrideKey) as? () -> Void) ?? nil + } + set { + objc_setAssociatedObject(self, &resumeOverrideKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + /// One-time initialization that swizzles the resume method. + /// This is called automatically when the mocking system is first used. + static let classInit: Void = { + guard let originalMethod = class_getInstanceMethod(URLSessionTask.self, #selector(resume)), + let swizzledMethod = class_getInstanceMethod(URLSessionTask.self, #selector(swizzled_resume)) else { return } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + + /// The swizzled implementation of resume that checks for an override. + @objc func swizzled_resume() { + if let resumeOverride { + resumeOverride() + } else { + swizzled_resume() + } + } +} +#endif diff --git a/Sources/Endpoints/MultipartFormEncoder.swift b/Sources/Endpoints/MultipartFormEncoder.swift index 0418caa..3c7dfe6 100644 --- a/Sources/Endpoints/MultipartFormEncoder.swift +++ b/Sources/Endpoints/MultipartFormEncoder.swift @@ -91,7 +91,7 @@ public final class MultipartFormEncoder: EncoderType { } /// Represents a binary field in a multipart payload. -public struct MultipartFormFile: Encodable { +public struct MultipartFormFile: Encodable, Sendable { public let data: Data public let fileName: String public let contentType: String @@ -123,7 +123,7 @@ fileprivate protocol MultipartFormJSONProtocol { } /// Wraps an ``Encodable`` value so it is embedded as a JSON part within a multipart payload. -public struct MultipartFormJSON: Encodable { +public struct MultipartFormJSON: Encodable, Sendable { public let value: Value fileprivate let jsonEncoder: JSONEncoder public let fileName: String? @@ -326,7 +326,9 @@ final class _MultipartFormDataEncoder: Encoder { case .millisecondsSince1970: return String(Int(date.timeIntervalSince1970 * 1000)) case .iso8601: - return iso8601Formatter.string(from: date) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) case .formatted(let formatter): return formatter.string(from: date) case .custom(let block): @@ -584,11 +586,3 @@ final class _MultipartSuperEncoder: Encoder { parent.singleValueContainer(at: codingPath) } } - -// MARK: - Date helpers - -private let iso8601Formatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter -}() diff --git a/Sources/Endpoints/PathTemplate.swift b/Sources/Endpoints/PathTemplate.swift index f71575e..c71d5e7 100644 --- a/Sources/Endpoints/PathTemplate.swift +++ b/Sources/Endpoints/PathTemplate.swift @@ -8,7 +8,7 @@ import Foundation -public protocol PathRepresentable { +public protocol PathRepresentable: Sendable{ /// A path-safe version of the value, suitable for a URL path var pathSafe: String { get } } @@ -35,9 +35,9 @@ extension Int: PathRepresentable { } /// A template representing a URL path -public struct PathTemplate { +public struct PathTemplate: Sendable { - private struct RepresentableInfo: Equatable { + private struct RepresentableInfo: Equatable, Sendable { static func == (lhs: RepresentableInfo, rhs: RepresentableInfo) -> Bool { return lhs.index == rhs.index && lhs.includesSlash == rhs.includesSlash && @@ -50,7 +50,7 @@ public struct PathTemplate { } private var pathComponents: [RepresentableInfo] = [] - private var keyPathComponents: [(Int, PartialKeyPath, Bool)] = [] + private var keyPathComponents: [(Int, PartialKeyPath & Sendable, Bool)] = [] private var currentIndex: Int = 0 @@ -66,7 +66,7 @@ public struct PathTemplate { } } - mutating func append(keyPath: PartialKeyPath, indexOverride: Int? = nil, includesSlash: Bool = true) { + mutating func append(keyPath: PartialKeyPath & Sendable, indexOverride: Int? = nil, includesSlash: Bool = true) { if let index = indexOverride { keyPathComponents.append((index, keyPath, includesSlash)) currentIndex = index @@ -160,14 +160,14 @@ extension PathTemplate: ExpressibleByStringInterpolation { path.append(path: literal) } - mutating public func appendInterpolation(path value: KeyPath, includesSlash: Bool = true) { + mutating public func appendInterpolation(path value: KeyPath & Sendable, includesSlash: Bool = true) { path.append(keyPath: value, includesSlash: includesSlash) } } } // PathRepresentable + KeyPath -public func +(lhs: U, rhs: KeyPath) -> PathTemplate { +public func +(lhs: U, rhs: KeyPath & Sendable) -> PathTemplate { var template = PathTemplate() template.append(path: lhs) template.append(keyPath: rhs) @@ -175,7 +175,7 @@ public func +(lhs: U, rhs: KeyPat } // KeyPath + PathRepresentable -public func +(lhs: KeyPath, rhs: U) -> PathTemplate { +public func +(lhs: KeyPath & Sendable, rhs: U) -> PathTemplate { var template = PathTemplate() template.append(keyPath: lhs) template.append(path: rhs) @@ -183,7 +183,7 @@ public func +(lhs: KeyPath, } // Template + KeyPath -public func +(lhs: PathTemplate, rhs: KeyPath) -> PathTemplate { +public func +(lhs: PathTemplate, rhs: KeyPath & Sendable) -> PathTemplate { var template = lhs template.append(keyPath: rhs) return template diff --git a/Sources/Endpoints/Server.swift b/Sources/Endpoints/Server.swift new file mode 100644 index 0000000..3f6f7d8 --- /dev/null +++ b/Sources/Endpoints/Server.swift @@ -0,0 +1,49 @@ +// +// Server.swift +// Endpoints +// +// Created by Zac White on 11/27/24. +// + +import Foundation + +/// Thread-safe storage for server environments. +/// Maps environment types to their current values, allowing runtime switching. +enum EnvironmentStorage { + private static let lock = NSLock() + nonisolated(unsafe) private static var environments: [ObjectIdentifier: Any] = [:] + + static func getEnvironment(for type: T.Type) -> T? { + lock.lock() + defer { lock.unlock() } + let typeKey = ObjectIdentifier(type) + return environments[typeKey] as? T + } + + static func setEnvironment(_ environment: T, for type: T.Type) { + lock.lock() + defer { lock.unlock() } + let typeKey = ObjectIdentifier(type) + environments[typeKey] = environment + } +} + +extension ServerDefinition { + /// The current environment for this server type. + /// + /// Use this property to switch environments at runtime. The value persists across + /// all endpoints using this server type. + /// + /// ```swift + /// // Switch to staging for all subsequent requests + /// ApiServer.environment = .staging + /// ``` + public static var environment: Self.Environments { + get { + EnvironmentStorage.getEnvironment(for: Self.Environments.self) ?? Self.defaultEnvironment + } + set { + EnvironmentStorage.setEnvironment(newValue, for: Self.Environments.self) + } + } +} diff --git a/Sources/EndpointsMocking/EndpointsMocking.swift b/Sources/EndpointsMocking/EndpointsMocking.swift new file mode 100644 index 0000000..09a8d8e --- /dev/null +++ b/Sources/EndpointsMocking/EndpointsMocking.swift @@ -0,0 +1,65 @@ +// +// EndpointsMocking.swift +// Endpoints +// +// Created by Zac White on 11/30/24. +// + +import Foundation +@testable import Endpoints + +/// Executes a test block with mocking enabled for the specified endpoint type. +/// +/// 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. +/// +/// ```swift +/// try await withMock(MyEndpoint.self) { continuation in +/// continuation.resume(returning: .init(userId: "123", name: "Test")) +/// } test: { +/// let response = try await URLSession.shared.response(with: MyEndpoint()) +/// #expect(response.userId == "123") +/// } +/// ``` +/// +/// - 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 +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. +/// +/// ```swift +/// try await withMock(MyEndpoint.self, action: .return(.init(userId: "123", name: "Test"))) { +/// let response = try await URLSession.shared.response(with: MyEndpoint()) +/// #expect(response.name == "Test") +/// } +/// ``` +/// +/// - 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 +public func withMock(_ ofType: T.Type, action: MockAction, test: @Sendable @escaping () async throws -> R) async rethrows -> R { + return try await Mocking.shared.withMock(T.self, { continuation in + switch action { + case .none: + return + case .fail(let errorResponse): + continuation.resume(failingWith: errorResponse) + case .return(let value): + continuation.resume(returning: value) + case .throw(let error): + continuation.resume(throwing: error) + } + }, test: test) +} diff --git a/Tests/EndpointsMockingTests/AsyncURLSessionMocking.swift b/Tests/EndpointsMockingTests/AsyncURLSessionMocking.swift new file mode 100644 index 0000000..a84999f --- /dev/null +++ b/Tests/EndpointsMockingTests/AsyncURLSessionMocking.swift @@ -0,0 +1,121 @@ +// +// AsyncURLSessionMocking.swift +// Endpoints +// +// Created by Zac White on 11/30/24. +// + +import Testing +import Endpoints +import Foundation +@testable import EndpointsMocking + +struct MockTestServer: ServerDefinition { + var baseUrls: [Environments: URL] { + return [ + .production: URL(string: "https://api.velosmobile.com")! + ] + } + + static var defaultEnvironment: Environments { .production } +} + +struct MockSimpleEndpoint: Endpoint { + typealias Server = MockTestServer + + static let definition: Definition = Definition( + method: .get, + path: "user/\(path: \.name)/\(path: \.id)/profile" + ) + + struct Response: Codable { + let response1: String + } + + struct ErrorResponse: Codable, Equatable { + let errorDescription: String + } + + struct PathComponents { + let name: String + let id: String + } + + let pathComponents: PathComponents +} + +@Suite("Async URLSession Mocking") +struct AsyncURLSessionMocking { + @Test func basicThrow() async throws { + await #expect(throws: MockSimpleEndpoint.TaskError.self) { + try await withMock(MockSimpleEndpoint.self) { continuation in + continuation.resume(throwing: .internetConnectionOffline) + } test: { + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + _ = try await URLSession.shared.response(with: simple) + } + } + } + + @Test func basicThrowInline() async throws { + await #expect(throws: MockSimpleEndpoint.TaskError.self) { + try await withMock(MockSimpleEndpoint.self, action: .throw(.internetConnectionOffline)) { + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + _ = try await URLSession.shared.response(with: simple) + } + } + } + + @Test func basicFail() async throws { + try await withMock(MockSimpleEndpoint.self) { continuation in + continuation.resume(failingWith: .init(errorDescription: "error")) + } test: { + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + do { + _ = try await URLSession.shared.response(with: simple) + } catch { + let error = try #require(error as? MockSimpleEndpoint.TaskError) + if case .errorResponse(_, let response) = error { + #expect(response.errorDescription == "error") + } else { + #expect(Bool(false), "unexpected error \(error)") + } + } + } + } + + @Test func basicFailInline() async throws { + try await withMock(MockSimpleEndpoint.self, action: .fail(.init(errorDescription: "error"))) { + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + do { + _ = try await URLSession.shared.response(with: simple) + } catch { + let error = try #require(error as? MockSimpleEndpoint.TaskError) + if case .errorResponse(_, let response) = error { + #expect(response.errorDescription == "error") + } else { + #expect(Bool(false), "unexpected error \(error)") + } + } + } + } + + @Test func basicResponse() async throws { + try await withMock(MockSimpleEndpoint.self) { continuation in + // possibly load mocks async from json + continuation.resume(returning: .init(response1: "test")) + } test: { + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let response = try await URLSession.shared.response(with: simple) + #expect(response.response1 == "test") + } + } + + @Test func basicResponseInline() async throws { + try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "test"))) { + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let response = try await URLSession.shared.response(with: simple) + #expect(response.response1 == "test") + } + } +} diff --git a/Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift b/Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift new file mode 100644 index 0000000..0443b50 --- /dev/null +++ b/Tests/EndpointsMockingTests/ClosureURLSessionMocking.swift @@ -0,0 +1,47 @@ +// +// BasicMocking.swift +// Endpoints +// +// Created by Zac White on 11/30/24. +// + +import Testing +import Endpoints +import Foundation +@testable import EndpointsMocking + +@Suite("Closure URLSession Mocking") +struct ClosureURLSessionMocking { + + @Test func inline() async throws { + try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "test"))) { + try await wait { continuation in + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let task = try URLSession.shared.endpointTask(with: simple) { result in + #expect(throws: Never.self) { + let response = try result.get() + #expect(response.response1 == "test") + } + continuation.resume() + } + task.resume() + } + } + } + + @Test func inline2() async throws { + try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: "test2"))) { + try await wait { continuation in + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let task = try URLSession.shared.endpointTask(with: simple) { result in + #expect(throws: Never.self) { + let response = try result.get() + #expect(response.response1 == "test2") + } + continuation.resume() + } + task.resume() + } + } + } +} diff --git a/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift b/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift new file mode 100644 index 0000000..6fb15dc --- /dev/null +++ b/Tests/EndpointsMockingTests/CombineURLSessionMocking.swift @@ -0,0 +1,68 @@ +// +// BasicMocking.swift +// Endpoints +// +// Created by Zac White on 11/30/24. +// + +#if canImport(Combine) + +import Testing +import Endpoints +import Foundation +@testable import EndpointsMocking +@preconcurrency import Combine + +@available(iOS 15.0, *) +extension AnyPublisher where Output: Sendable { + var awaitFirst: Output { + get async throws { + let waiter = PublisherWaiter(publisher: self) + return try await waiter.wait() + } + } +} + + +enum WaiterError: Error { + case noElement +} + +final class PublisherWaiter { + let publisher: P + + init(publisher: P) { + self.publisher = publisher + } + + @available(iOS 15.0, *) + func wait() async throws -> P.Output { + var iterator = publisher + .assertNoFailure() + .first() + .values + .makeAsyncIterator() + + guard let value = await iterator.next() else { + throw WaiterError.noElement + } + + return value + } +} + +@Suite("Combine URLSession Mocking") +struct CombineURLSessionMocking { + + @available(iOS 15.0, *) + @Test(arguments: ["test", "test2"]) + func combineInline(response: String) async throws { + try await withMock(MockSimpleEndpoint.self, action: .return(.init(response1: response))) { + let simple = MockSimpleEndpoint(pathComponents: .init(name: "a", id: "b")) + let endpointResponse = try await URLSession.shared.endpointPublisher(with: simple).awaitFirst + #expect(endpointResponse.response1 == response) + } + } +} + +#endif diff --git a/Tests/EndpointsMockingTests/Testing+Helpers.swift b/Tests/EndpointsMockingTests/Testing+Helpers.swift new file mode 100644 index 0000000..92f1bd8 --- /dev/null +++ b/Tests/EndpointsMockingTests/Testing+Helpers.swift @@ -0,0 +1,11 @@ +import Foundation + +public func wait(_ body: (CheckedContinuation) throws -> Void) async throws -> R { + return try await withCheckedThrowingContinuation { continuation in + do { + try body(continuation) + } catch { + continuation.resume(throwing: error) + } + } +} diff --git a/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift b/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift index ce6e8c7..adb77b5 100644 --- a/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/CustomEncodingEndpoint.swift @@ -10,11 +10,13 @@ import Endpoints import Foundation struct CustomEncodingEndpoint: Endpoint { + typealias Server = TestServer + static let definition: Definition = Definition( method: .get, path: "/", parameters: [ - .query("key", path: \ParameterComponents.needsCustomEncoding) + .query("key", path: \.needsCustomEncoding) ] ) diff --git a/Tests/EndpointsTests/Endpoints/Environment.swift b/Tests/EndpointsTests/Endpoints/Environment.swift index 762ed95..2a15b0b 100644 --- a/Tests/EndpointsTests/Endpoints/Environment.swift +++ b/Tests/EndpointsTests/Endpoints/Environment.swift @@ -9,8 +9,14 @@ import Foundation @testable import Endpoints -struct Environment: EnvironmentType { - let baseUrl: URL +struct MyServer: ServerDefinition { + var baseUrls: [Environments: URL] { + return [ + .local: URL(string: "https://api.velos.me")!, + .staging: URL(string: "https://api.velos.me")!, + .production: URL(string: "https://api.velos.me")! + ] + } - static let test = Environment(baseUrl: URL(string: "https://velosmobile.com")!) + static var defaultEnvironment: Environments { .production } } diff --git a/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift b/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift index d8d3bcb..13f5c1d 100644 --- a/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/InvalidEndpoint.swift @@ -9,11 +9,13 @@ import Endpoints struct InvalidEndpoint: Endpoint { + typealias Server = TestServer + static let definition: Definition = Definition( method: .get, path: "/", parameters: [ - .query("path", path: \ParameterComponents.nonEncodable) + .query("path", path: \.nonEncodable) ] ) diff --git a/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift b/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift index 63017d2..8a9069c 100644 --- a/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/JSONProviderEndpoint.swift @@ -10,8 +10,9 @@ import Foundation @testable import Endpoints struct JSONProviderEndpoint: Endpoint { + typealias Server = TestServer - static var definition: Definition = Definition( + static let definition: Definition = Definition( method: .get, path: "user/\(path: \.name)/\(path: \.id)/profile" ) diff --git a/Tests/EndpointsTests/Endpoints/MultipartUploadEndpoint.swift b/Tests/EndpointsTests/Endpoints/MultipartUploadEndpoint.swift index af2daf6..74872b5 100644 --- a/Tests/EndpointsTests/Endpoints/MultipartUploadEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/MultipartUploadEndpoint.swift @@ -2,6 +2,8 @@ import Foundation @testable import Endpoints struct MultipartUploadEndpoint: Endpoint { + typealias Server = TestServer + static let definition: Definition = Definition( method: .post, path: "upload" diff --git a/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift b/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift index b43e029..afa9dcd 100644 --- a/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift +++ b/Tests/EndpointsTests/Endpoints/PostEndpoint1.swift @@ -10,7 +10,9 @@ import Foundation @testable import Endpoints struct PostEndpoint1: Endpoint { - static var definition: Definition = Definition( + typealias Server = TestServer + + static let definition: Definition = Definition( method: .post, path: "path" ) diff --git a/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift b/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift index 269fdf7..c6e46e5 100644 --- a/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift +++ b/Tests/EndpointsTests/Endpoints/PostEndpoint2.swift @@ -10,7 +10,9 @@ import Foundation @testable import Endpoints struct PostEndpoint2: Endpoint { - static var definition: Definition = Definition( + typealias Server = TestServer + + static let definition: Definition = Definition( method: .post, path: "path" ) diff --git a/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift b/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift index 657b8a2..8ab6bfb 100644 --- a/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/SimpleEndpoint.swift @@ -10,7 +10,9 @@ import Foundation @testable import Endpoints struct SimpleEndpoint: Endpoint { - static var definition: Definition = Definition( + typealias Server = TestServer + + static let definition: Definition = Definition( method: .get, path: "user/\(path: \.name)/\(path: \.id)/profile" ) diff --git a/Tests/EndpointsTests/Endpoints/TestServer.swift b/Tests/EndpointsTests/Endpoints/TestServer.swift new file mode 100644 index 0000000..531f374 --- /dev/null +++ b/Tests/EndpointsTests/Endpoints/TestServer.swift @@ -0,0 +1,21 @@ +// +// TestServer.swift +// Endpoints +// +// Created by Zac White on 11/1/24. +// + +import Endpoints +import Foundation + +struct TestServer: ServerDefinition { + var baseUrls: [Environments: URL] { + return [ + .local: URL(string: "https://local-api.velosmobile.com")!, + .staging: URL(string: "https://staging-api.velosmobile.com")!, + .production: URL(string: "https://api.velosmobile.com")! + ] + } + + static var defaultEnvironment: Environments { .production } +} diff --git a/Tests/EndpointsTests/Endpoints/UserEndpoint.swift b/Tests/EndpointsTests/Endpoints/UserEndpoint.swift index 5ed5c0d..8ea511d 100644 --- a/Tests/EndpointsTests/Endpoints/UserEndpoint.swift +++ b/Tests/EndpointsTests/Endpoints/UserEndpoint.swift @@ -10,27 +10,29 @@ import Foundation @testable import Endpoints struct UserEndpoint: Endpoint { - static var definition: Definition = Definition( + typealias Server = TestServer + + static let definition: Definition = Definition( method: .get, - path: "hey" + \UserEndpoint.PathComponents.userId, + path: "hey" + \.userId, parameters: [ - .form("string", path: \UserEndpoint.ParameterComponents.string), - .form("date", path: \UserEndpoint.ParameterComponents.date), - .form("double", path: \UserEndpoint.ParameterComponents.double), - .form("int", path: \UserEndpoint.ParameterComponents.int), - .form("bool_true", path: \UserEndpoint.ParameterComponents.boolTrue), - .form("bool_false", path: \UserEndpoint.ParameterComponents.boolFalse), - .form("time_zone", path: \UserEndpoint.ParameterComponents.timeZone), - .form("optional_string", path: \UserEndpoint.ParameterComponents.optionalString), - .form("optional_date", path: \UserEndpoint.ParameterComponents.optionalDate), + .form("string", path: \.string), + .form("date", path: \.date), + .form("double", path: \.double), + .form("int", path: \.int), + .form("bool_true", path: \.boolTrue), + .form("bool_false", path: \.boolFalse), + .form("time_zone", path: \.timeZone), + .form("optional_string", path: \.optionalString), + .form("optional_date", path: \.optionalDate), .formValue("hard_coded_form", value: "true"), - .query("string", path: \UserEndpoint.ParameterComponents.string), - .query("optional_string", path: \UserEndpoint.ParameterComponents.optionalString), - .query("optional_date", path: \UserEndpoint.ParameterComponents.optionalDate), + .query("string", path: \.string), + .query("optional_string", path: \.optionalString), + .query("optional_date", path: \.optionalDate), .queryValue("hard_coded_query", value: "true") ], headers: [ - "HEADER_TYPE": .field(path: \UserEndpoint.HeaderComponents.headerValue), + "HEADER_TYPE": .field(path: \.headerValue), "HARD_CODED_HEADER": .fieldValue(value: "test2"), .keepAlive: .fieldValue(value: "timeout=5, max=1000") ] diff --git a/Tests/EndpointsTests/EndpointsTests.swift b/Tests/EndpointsTests/EndpointsTests.swift index e0e18ab..19df9f3 100644 --- a/Tests/EndpointsTests/EndpointsTests.swift +++ b/Tests/EndpointsTests/EndpointsTests.swift @@ -6,62 +6,73 @@ // Copyright © 2019 Velos Mobile LLC. All rights reserved. // -import XCTest +import Testing +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + @testable import Endpoints -class EndpointsTests: XCTestCase { +@Suite +struct EndpointsTests { - func testBasicEndpoint() throws { + @Test + func basicEndpoint() throws { let request = try SimpleEndpoint( pathComponents: .init(name: "zac", id: "42") - ).urlRequest(in: Environment.test) + ).urlRequest() - XCTAssertEqual(request.url?.path, "/user/zac/42/profile") + #expect(request.url?.path == "/user/zac/42/profile") let responseData = #"{"response1": "testing"}"#.data(using: .utf8)! let response = try SimpleEndpoint.responseDecoder.decode(SimpleEndpoint.Response.self, from: responseData) - XCTAssertEqual(response.response1, "testing") + #expect(response.response1 == "testing") } - func testBasicEndpointWithCustomDecoder() throws { + @Test + func basicEndpointWithCustomDecoder() throws { let request = try JSONProviderEndpoint( body: .init(bodyValueOne: "value"), pathComponents: .init(name: "zac", id: "42") - ).urlRequest(in: Environment.test) + ).urlRequest() - XCTAssertEqual(request.url?.path, "/user/zac/42/profile") + #expect(request.url?.path == "/user/zac/42/profile") let bodyData = #"{"body_value_one":"value"}"#.data(using: .utf8)! - XCTAssertEqual(request.httpBody, bodyData) + #expect(request.httpBody == bodyData) let responseData = #"{"response_one": "testing"}"#.data(using: .utf8)! let response = try JSONProviderEndpoint.responseDecoder.decode(JSONProviderEndpoint.Response.self, from: responseData) - XCTAssertEqual(response.responseOne, "testing") + #expect(response.responseOne == "testing") } - func testPostEndpointWithEncoder() throws { + @Test + func postEndpointWithEncoder() throws { let date = Date() let request = try PostEndpoint1( body: .init(property1: date, property2: nil) - ).urlRequest(in: Environment.test) + ).urlRequest() let encodedDate = ISO8601DateFormatter().string(from: date) let bodyData = "{\"property1\":\"\(encodedDate)\"}".data(using: .utf8)! - XCTAssertEqual(request.httpBody, bodyData) + #expect(request.httpBody == bodyData) } - func testPostEndpoint() throws { + @Test + func postEndpoint() throws { let request = try PostEndpoint2( body: .init(property1: "test", property2: nil) - ).urlRequest(in: Environment.test) + ).urlRequest() - XCTAssertEqual(request.url?.path, "/path") - XCTAssertEqual(request.httpMethod, "POST") + #expect(request.url?.path == "/path") + #expect(request.httpMethod == "POST") } - func testMultipartBodyEncoding() throws { + @Test + func multipartBodyEncoding() throws { let fileData = Data("hello world".utf8) let endpoint = MultipartUploadEndpoint( body: .init( @@ -74,43 +85,44 @@ class EndpointsTests: XCTestCase { ) ) - let request = try endpoint.urlRequest(in: Environment.test) + let request = try endpoint.urlRequest() - let contentType = try XCTUnwrap(request.value(forHTTPHeaderField: Header.contentType.name)) - XCTAssertTrue(contentType.hasPrefix("multipart/form-data; boundary=")) + let contentType = try #require(request.value(forHTTPHeaderField: Header.contentType.name)) + #expect(contentType.hasPrefix("multipart/form-data; boundary=")) let boundaryComponents = contentType.components(separatedBy: "boundary=") - XCTAssertEqual(boundaryComponents.count, 2) + #expect(boundaryComponents.count == 2) let boundary = boundaryComponents[1] - let bodyData = try XCTUnwrap(request.httpBody) - let bodyString = try XCTUnwrap(String(data: bodyData, encoding: .utf8)) - - XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"description\"")) - XCTAssertTrue(bodyString.contains("Test description")) - XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"tags[0]\"")) - XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"tags[1]\"")) - XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"greeting.txt\"")) - XCTAssertTrue(bodyString.contains("Content-Type: text/plain")) - XCTAssertTrue(bodyString.contains("hello world")) - XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"metadata\"")) - XCTAssertFalse(bodyString.contains("name=\"metadata\"; filename=")) - XCTAssertTrue(bodyString.contains("Content-Type: application/json")) - XCTAssertTrue(bodyString.contains("\"owner\":\"zac\"")) - XCTAssertTrue(bodyString.contains("\"priority\":1")) - XCTAssertTrue(bodyString.hasSuffix("--\(boundary)--\r\n")) + let bodyData = try #require(request.httpBody) + let bodyString = try #require(String(data: bodyData, encoding: .utf8)) + + #expect(bodyString.contains("Content-Disposition: form-data; name=\"description\"")) + #expect(bodyString.contains("Test description")) + #expect(bodyString.contains("Content-Disposition: form-data; name=\"tags[0]\"")) + #expect(bodyString.contains("Content-Disposition: form-data; name=\"tags[1]\"")) + #expect(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"greeting.txt\"")) + #expect(bodyString.contains("Content-Type: text/plain")) + #expect(bodyString.contains("hello world")) + #expect(bodyString.contains("Content-Disposition: form-data; name=\"metadata\"")) + #expect(!bodyString.contains("name=\"metadata\"; filename=")) + #expect(bodyString.contains("Content-Type: application/json")) + #expect(bodyString.contains("\"owner\":\"zac\"")) + #expect(bodyString.contains("\"priority\":1")) + #expect(bodyString.hasSuffix("--\(boundary)--\r\n")) } - func testCustomParameterEncoding() throws { + @Test + func customParameterEncoding() throws { let request = try CustomEncodingEndpoint( parameterComponents: .init(needsCustomEncoding: "++++") - ).urlRequest(in: Environment.test) + ).urlRequest() - XCTAssertEqual(request.url?.query, "key=%2B%2B%2B%2B") + #expect(request.url?.query == "key=%2B%2B%2B%2B") } - func testParameterEndpoint() throws { - + @Test + func parameterEndpoint() throws { let request = try UserEndpoint( pathComponents: .init(userId: "3"), parameterComponents: .init( @@ -125,41 +137,38 @@ class EndpointsTests: XCTestCase { optionalDate: nil ), headerComponents: .init(headerValue: "test") - ).urlRequest(in: Environment.test) + ).urlRequest() - XCTAssertEqual(request.httpMethod, "GET") - XCTAssertEqual(request.url?.path, "/hey/3") - XCTAssertEqual(request.url?.query, "string=test:of:%2Bthing%25asdf&hard_coded_query=true") + #expect(request.httpMethod == "GET") + #expect(request.url?.path == "/hey/3") + #expect(request.url?.query == "string=test:of:%2Bthing%25asdf&hard_coded_query=true") - XCTAssertEqual(request.value(forHTTPHeaderField: "HEADER_TYPE"), "test") - XCTAssertEqual(request.value(forHTTPHeaderField: "HARD_CODED_HEADER"), "test2") - XCTAssertEqual(request.value(forHTTPHeaderField: "Keep-Alive"), "timeout=5, max=1000") - XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/x-www-form-urlencoded") + #expect(request.value(forHTTPHeaderField: "HEADER_TYPE") == "test") + #expect(request.value(forHTTPHeaderField: "HARD_CODED_HEADER") == "test2") + #expect(request.value(forHTTPHeaderField: "Keep-Alive") == "timeout=5, max=1000") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/x-www-form-urlencoded") - XCTAssertNotNil(request.httpBody) - XCTAssertTrue( - String(data: request.httpBody ?? Data(), encoding: .utf8)?.contains("string=test%3Aof%3A+thing%25asdf") ?? false - ) - XCTAssertFalse( - String(data: request.httpBody ?? Data(), encoding: .utf8)?.contains("optional_string") ?? true - ) - XCTAssertTrue( - String(data: request.httpBody ?? Data(), encoding: .utf8)?.contains("double=2.3&int=42&bool_true=true&bool_false=false&time_zone=America/Los_Angeles&hard_coded_form=true") ?? false + #expect(request.httpBody != nil) + let body = String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "" + + #expect(body.contains("string=test%3Aof%3A+thing%25asdf")) + #expect(!body.contains("optional_string")) + #expect( + body.contains("double=2.3&int=42&bool_true=true&bool_false=false&time_zone=America/Los_Angeles&hard_coded_form=true") ) } - func testInvalidParameter() { - XCTAssertThrowsError( + @Test + func invalidParameter() { + #expect(throws: EndpointError.self) { try InvalidEndpoint( parameterComponents: .init(nonEncodable: .value) - ).urlRequest(in: Environment.test) - ) { error in - XCTAssertTrue(error is EndpointError, "error is \(type(of: error)) and not an EndpointError") + ).urlRequest() } } - func testResponseSuccess() throws { - + @Test + func responseSuccess() throws { let successResponse = HTTPURLResponse(url: URL(fileURLWithPath: ""), statusCode: 200, httpVersion: nil, headerFields: nil) let jsonData = try JSONEncoder().encode(SimpleEndpoint.Response(response1: "testing")) let result = SimpleEndpoint.definition.response( @@ -169,15 +178,15 @@ class EndpointsTests: XCTestCase { ) guard case .success(let data) = result else { - XCTFail("Unexpected failure") + Issue.record("Unexpected failure") return } - XCTAssertEqual(data, jsonData) + #expect(data == jsonData) } - func testResponseNetworkError() throws { - + @Test + func responseNetworkError() throws { let jsonData = try JSONEncoder().encode(SimpleEndpoint.Response(response1: "testing")) let result = SimpleEndpoint.definition.response( data: jsonData, @@ -186,13 +195,13 @@ class EndpointsTests: XCTestCase { ) guard case .failure(let taskError) = result, case .internetConnectionOffline = taskError else { - XCTFail("Unexpected failure") + Issue.record("Unexpected failure") return } } - func testResponseURLLoadError() throws { - + @Test + func responseURLLoadError() throws { let jsonData = try JSONEncoder().encode(SimpleEndpoint.Response(response1: "testing")) let result = SimpleEndpoint.definition.response( data: jsonData, @@ -201,13 +210,13 @@ class EndpointsTests: XCTestCase { ) guard case .failure(let taskError) = result, case .urlLoadError = taskError else { - XCTFail("Unexpected failure") + Issue.record("Unexpected failure") return } } - func testResponseErrorParsing() throws { - + @Test + func responseErrorParsing() throws { let failureResponse = HTTPURLResponse(url: URL(fileURLWithPath: ""), statusCode: 404, httpVersion: nil, headerFields: nil) let errorResponse = SimpleEndpoint.ErrorResponse(errorDescription: "testing") let jsonData = try JSONEncoder().encode(errorResponse) @@ -218,16 +227,43 @@ class EndpointsTests: XCTestCase { ) guard case .failure(let error) = result else { - XCTFail("Unexpected failure") + Issue.record("Unexpected failure") return } guard case .errorResponse(let response, let decoded) = error else { - XCTFail("Unexpected error case") + Issue.record("Unexpected error case") return } - XCTAssertEqual(response.statusCode, 404) - XCTAssertEqual(decoded, errorResponse) + #expect(response.statusCode == 404) + #expect(decoded == errorResponse) + } + + @Test + @available(iOS 16.0, *) + func environmentsChange() throws { + let existing = TestServer.environment + + let endpoint = SimpleEndpoint( + pathComponents: .init(name: "zac", id: "42") + ) + + TestServer.environment = .local + #expect(try endpoint.urlRequest().url?.host() == "local-api.velosmobile.com") + + TestServer.environment = .staging + #expect(try endpoint.urlRequest().url?.host() == "staging-api.velosmobile.com") + + TestServer.environment = .production + #expect(try endpoint.urlRequest().url?.host() == "api.velosmobile.com") + + TestServer.environment = existing + } + + @Test + @available(iOS 16.0, *) + func defaultEnvironment() throws { + #expect(TestServer.defaultEnvironment == .production) } } diff --git a/Tests/EndpointsTests/MultipartFormEncoderTests.swift b/Tests/EndpointsTests/MultipartFormEncoderTests.swift index a783ca3..c7f94c6 100644 --- a/Tests/EndpointsTests/MultipartFormEncoderTests.swift +++ b/Tests/EndpointsTests/MultipartFormEncoderTests.swift @@ -1,9 +1,12 @@ -import XCTest +import Foundation +import Testing @testable import Endpoints -final class MultipartFormEncoderTests: XCTestCase { +@Suite +struct MultipartFormEncoderTests { - func testEncodesMixedValues() throws { + @Test + func multipartEncodesMixedValues() throws { struct Nested: Encodable { let flag: Bool let count: Int @@ -48,7 +51,7 @@ final class MultipartFormEncoderTests: XCTestCase { ) let data = try encoder.encode(payload) - let body = try XCTUnwrap(String(data: data, encoding: .utf8)) + let body = try #require(String(data: data, encoding: .utf8)) func part(named name: String) -> String? { let marker = "Content-Disposition: form-data; name=\"\(name)\"" @@ -61,43 +64,44 @@ final class MultipartFormEncoderTests: XCTestCase { return String(body[partStart...]) } - XCTAssertTrue(body.contains("--Boundary-123--"), "missing closing boundary") + #expect(body.contains("--Boundary-123--"), "missing closing boundary") - let titlePart = try XCTUnwrap(part(named: "title")) - XCTAssertTrue(titlePart.contains("Example"), "missing title value") + let titlePart = try #require(part(named: "title")) + #expect(titlePart.contains("Example"), "missing title value") - let nestedFlagPart = try XCTUnwrap(part(named: "nested[flag]")) - XCTAssertTrue(nestedFlagPart.contains("true"), "missing nested flag value") + let nestedFlagPart = try #require(part(named: "nested[flag]")) + #expect(nestedFlagPart.contains("true"), "missing nested flag value") - let nestedCountPart = try XCTUnwrap(part(named: "nested[count]")) - XCTAssertTrue(nestedCountPart.contains("7"), "missing nested count value") + let nestedCountPart = try #require(part(named: "nested[count]")) + #expect(nestedCountPart.contains("7"), "missing nested count value") - let list0Part = try XCTUnwrap(part(named: "list[0]")) - XCTAssertTrue(list0Part.contains("first"), "missing list[0] value") + let list0Part = try #require(part(named: "list[0]")) + #expect(list0Part.contains("first"), "missing list[0] value") - let list1Part = try XCTUnwrap(part(named: "list[1]")) - XCTAssertTrue(list1Part.contains("second"), "missing list[1] value") + let list1Part = try #require(part(named: "list[1]")) + #expect(list1Part.contains("second"), "missing list[1] value") - let filePart = try XCTUnwrap(part(named: "file")) - XCTAssertTrue(filePart.contains("filename=\"binary.dat\""), "missing file filename") - XCTAssertTrue(filePart.contains("Content-Type: application/octet-stream"), "missing file content type") - XCTAssertNotNil(data.range(of: Data([0x01, 0x02, 0x03])), "missing file payload") + let filePart = try #require(part(named: "file")) + #expect(filePart.contains("filename=\"binary.dat\""), "missing file filename") + #expect(filePart.contains("Content-Type: application/octet-stream"), "missing file content type") + #expect(data.range(of: Data([0x01, 0x02, 0x03])) != nil, "missing file payload") - let metadataPart = try XCTUnwrap(part(named: "metadata")) - XCTAssertFalse(metadataPart.contains("filename="), "metadata unexpectedly has filename") - XCTAssertTrue(metadataPart.contains("Content-Type: application/json"), "metadata missing content type") - XCTAssertTrue(metadataPart.contains("\"author\":\"zac\""), "metadata missing author") - XCTAssertTrue(metadataPart.contains("\"version\":2"), "metadata missing version") + let metadataPart = try #require(part(named: "metadata")) + #expect(!metadataPart.contains("filename="), "metadata unexpectedly has filename") + #expect(metadataPart.contains("Content-Type: application/json"), "metadata missing content type") + #expect(metadataPart.contains("\"author\":\"zac\""), "metadata missing author") + #expect(metadataPart.contains("\"version\":2"), "metadata missing version") - let configPart = try XCTUnwrap(part(named: "config")) - XCTAssertTrue(configPart.contains("filename=\"config.json\""), "config missing filename") - XCTAssertTrue(configPart.contains("Content-Type: application/json"), "config missing content type") - XCTAssertTrue(configPart.contains("\"mode\":\"debug\""), "config missing payload") + let configPart = try #require(part(named: "config")) + #expect(configPart.contains("filename=\"config.json\""), "config missing filename") + #expect(configPart.contains("Content-Type: application/json"), "config missing content type") + #expect(configPart.contains("\"mode\":\"debug\""), "config missing payload") } - func testContentTypeProvidesBoundary() { + @Test + func multipartContentTypeProvidesBoundary() { let encoder = MultipartFormEncoder(boundary: "Boundary-XYZ") - XCTAssertEqual(type(of: encoder).contentType, "multipart/form-data") - XCTAssertEqual(encoder.contentType, "multipart/form-data; boundary=Boundary-XYZ") + #expect(type(of: encoder).contentType == "multipart/form-data") + #expect(encoder.contentType == "multipart/form-data; boundary=Boundary-XYZ") } } diff --git a/Tests/EndpointsTests/PathTemplateTests.swift b/Tests/EndpointsTests/PathTemplateTests.swift index d0db458..981c1ce 100644 --- a/Tests/EndpointsTests/PathTemplateTests.swift +++ b/Tests/EndpointsTests/PathTemplateTests.swift @@ -6,7 +6,7 @@ // Copyright © 2019 Velos Mobile LLC. All rights reserved. // -import XCTest +import Testing @testable import Endpoints struct Test { @@ -19,45 +19,50 @@ struct TestOptional { let integer: Int? } -class PathTemplateTests: XCTestCase { +@Suite +struct PathTemplateTests { - func testStringInterpolation() { + @Test + func stringInterpolation() { let template1: PathTemplate = "testing/\(path: \.string)/\(path: \.integer)/other" let path = template1.path(with: Test(string: "first", integer: 2)) - XCTAssertEqual(path, "testing/first/2/other") + #expect(path == "testing/first/2/other") } - func testStringConcatenation() { + @Test + func stringConcatenation() { let template1: PathTemplate = "testing/" + \.string + \.integer let path1 = template1.path(with: Test(string: "first", integer: 2)) - XCTAssertEqual(path1, "testing/first/2") + #expect(path1 == "testing/first/2") let template2: PathTemplate = \.integer + "testing" let path2 = template2.path(with: Test(string: "first", integer: 2)) - XCTAssertEqual(path2, "2/testing") + #expect(path2 == "2/testing") let template3: PathTemplate = "testing" + 3 let path3 = template3.path(with: Test(string: "first", integer: 2)) - XCTAssertEqual(path3, "testing/3") + #expect(path3 == "testing/3") } - func testNoSlash() { + @Test + func noSlash() { let template1: PathTemplate = "testing/testPath(Thing='\(path: \.string, includesSlash: false)')" let path1 = template1.path(with: Test(string: "first", integer: 2)) - XCTAssertEqual(path1, "testing/testPath(Thing='first')") + #expect(path1 == "testing/testPath(Thing='first')") let template2: PathTemplate = "testing/testPath(Thing='\(path: \.string, includesSlash: false)')\(path: \.integer)" let path2 = template2.path(with: Test(string: "first", integer: 2)) - XCTAssertEqual(path2, "testing/testPath(Thing='first')/2") + #expect(path2 == "testing/testPath(Thing='first')/2") let template3: PathTemplate = "testing/testPath(Thing='\(path: \.string, includesSlash: false)')\(path: \.integer)" let path3 = template3.path(with: TestOptional(string: "first", integer: nil)) - XCTAssertEqual(path3, "testing/testPath(Thing='first')") + #expect(path3 == "testing/testPath(Thing='first')") } - func testStringLiteral() { + @Test + func stringLiteral() { let template: PathTemplate = "testing" let path = template.path(with: Test(string: "first", integer: 2)) - XCTAssertEqual(path, "testing") + #expect(path == "testing") } } diff --git a/Tests/EndpointsTests/URLSessionExtensionTests.swift b/Tests/EndpointsTests/URLSessionExtensionTests.swift index 98e82c5..e9ec30e 100644 --- a/Tests/EndpointsTests/URLSessionExtensionTests.swift +++ b/Tests/EndpointsTests/URLSessionExtensionTests.swift @@ -6,43 +6,39 @@ // Copyright © 2021 Velos Mobile LLC. All rights reserved. // -import XCTest -import Combine -@testable import Endpoints - -class URLSessionExtensionTests: XCTestCase { +import Testing +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif - var cancellables: Set = Set() +@testable import Endpoints - func testTaskCreationFailure() { - XCTAssertThrowsError( +@Suite +struct URLSessionExtensionTests { + @Test + func taskCreationFailure() { + #expect(throws: InvalidEndpoint.TaskError.self) { try URLSession.shared.endpointTask( - in: Environment.test, with: InvalidEndpoint(parameterComponents: .init(nonEncodable: .value)), completion: { _ in } ) - ) { error in - XCTAssertTrue(error is InvalidEndpoint.TaskError, "error is \(type(of: error)) and not an EndpointTaskError") } } - func testPublisherCreationFailure() { - let publisherExpectation = expectation(description: "publisher creation failure") - URLSession.shared.endpointPublisher( - in: Environment.test, + #if canImport(Combine) + @Test + @available(iOS 15.0, *) + func publisherCreationFailure() async { + let values = URLSession.shared.endpointPublisher( with: InvalidEndpoint(parameterComponents: .init(nonEncodable: .value)) - ) - .sink { completion in - guard case .failure(let error) = completion, case .endpointError = error else { - return - } + ).values - publisherExpectation.fulfill() - } receiveValue: { _ in - XCTFail() - } - .store(in: &cancellables) + await #expect(throws: EndpointTaskError.self) { + for try await _ in values { - waitForExpectations(timeout: 1) + } + } } + #endif }