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
}