From fe93530365edb5733c7b3f6444af34fd72afd30b Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 24 Feb 2026 18:56:48 -0300 Subject: [PATCH] feat: migrate to Swift 6.0 with sending/consuming annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Breaking Changes:** - Minimum Swift version: 5.10 → 6.0 - Minimum iOS: 13.0 → 16.0 - Minimum macOS: 10.15 → 13.0 - Minimum watchOS: 6.0 → 9.0 - Minimum tvOS: 13.0 → 16.0 - Minimum macCatalyst: 13.0 → 16.0 **Swift 6 Concurrency:** - Fixed all strict concurrency checking errors - Made MultipartFormData non-Sendable (builder pattern, no isolation transfer needed) - Added SendableBox helper for thread-safe test state - Refactored test log handlers to use actors **Ownership Annotations:** - Added `sending` to HTTPBody parameters in ClientTransport.send() - Added `sending` to HTTPBody parameters in ClientMiddleware.intercept() - Added `sending` to HTTPBody in Client.send() - Added `consuming` to MultipartFormData.encode() and writeEncodedData() - HTTPBody ownership transfer is now explicit and compiler-enforced **Benefits:** - Type-safe ownership semantics prevent accidental body reuse - Self-documenting API with clear resource lifetimes - Compiler optimizations enabled by explicit ownership - All unit tests pass with Swift 6 strict concurrency Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.md | 12 +- Package.swift | 12 +- .../Interface/Client+MultipartFormData.swift | 2 +- .../Interface/ClientTransport.swift | 6 +- .../Interface/MultipartFormData.swift | 40 ++--- .../Middlewares/LoggingMiddleware.swift | 4 +- Tests/HTTPClientTests/ClientTests.swift | 49 ++--- Tests/HTTPClientTests/ErrorTests.swift | 16 +- .../HTTPBodyProgressTests.swift | 114 ++++++------ Tests/HTTPClientTests/IntegrationTests.swift | 12 +- .../LoggingMiddlewareTests.swift | 170 ++++++++++-------- Tests/HTTPClientTests/MiddlewareTests.swift | 119 ++++++------ Tests/HTTPClientTests/TestHelpers.swift | 27 +++ 13 files changed, 319 insertions(+), 264 deletions(-) create mode 100644 Tests/HTTPClientTests/TestHelpers.swift diff --git a/CLAUDE.md b/CLAUDE.md index 8d2e581..62ba30c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,12 +41,12 @@ The library is structured as a single module with organized subdirectories: ## Platform Support -- iOS 13.0+ -- macOS 10.15+ -- macCatalyst 13.0+ -- watchOS 6.0+ -- tvOS 13.0+ -- Swift 5.10+ +- iOS 16.0+ +- macOS 13.0+ +- macCatalyst 16.0+ +- watchOS 9.0+ +- tvOS 16.0+ +- Swift 6.0+ ## Development Commands diff --git a/Package.swift b/Package.swift index e5ca67e..809c36e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.10 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,11 +6,11 @@ import PackageDescription let package = Package( name: "swift-http-client", platforms: [ - .iOS(.v13), - .macCatalyst(.v13), - .macOS(.v10_15), - .watchOS(.v6), - .tvOS(.v13), + .iOS(.v16), + .macCatalyst(.v16), + .macOS(.v13), + .watchOS(.v9), + .tvOS(.v16), ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. diff --git a/Sources/HTTPClient/Interface/Client+MultipartFormData.swift b/Sources/HTTPClient/Interface/Client+MultipartFormData.swift index c66615e..b0b7580 100644 --- a/Sources/HTTPClient/Interface/Client+MultipartFormData.swift +++ b/Sources/HTTPClient/Interface/Client+MultipartFormData.swift @@ -126,7 +126,7 @@ extension Client { /// /// - SeeAlso: ``MultipartFormData`` public func send( - multipartFormData: MultipartFormData, + multipartFormData: sending MultipartFormData, with request: HTTPRequest, usingThreshold encodingMemoryThreshold: UInt64 = MultipartFormData.encodingMemoryThreshold ) async throws -> (HTTPResponse, HTTPBody?) { diff --git a/Sources/HTTPClient/Interface/ClientTransport.swift b/Sources/HTTPClient/Interface/ClientTransport.swift index 0a1d7c8..100bd39 100644 --- a/Sources/HTTPClient/Interface/ClientTransport.swift +++ b/Sources/HTTPClient/Interface/ClientTransport.swift @@ -103,7 +103,7 @@ public protocol ClientTransport: Sendable { /// - Throws: An error if sending the request and receiving the response fails. func send( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL ) async throws -> (HTTPResponse, HTTPBody?) } @@ -207,8 +207,8 @@ public protocol ClientMiddleware: Sendable { /// - Throws: An error if interception of the request and response fails. func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: @Sendable (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) } diff --git a/Sources/HTTPClient/Interface/MultipartFormData.swift b/Sources/HTTPClient/Interface/MultipartFormData.swift index 812ca7c..a219e95 100644 --- a/Sources/HTTPClient/Interface/MultipartFormData.swift +++ b/Sources/HTTPClient/Interface/MultipartFormData.swift @@ -31,7 +31,7 @@ import HTTPTypes /// - https://www.w3.org/TR/html401/interact/forms.html#h-17.13 /// /// > Note: This implementation is adapted from Alamofire's MultipartFormData implementation. -public final class MultipartFormData: Sendable { +public final class MultipartFormData { // MARK: - Helper Types enum EncodingCharacters { @@ -65,7 +65,7 @@ public final class MultipartFormData: Sendable { } } - final class BodyPart: @unchecked Sendable { + final class BodyPart { let headers: HTTPFields let bodyStream: InputStream let bodyContentLength: UInt64 @@ -85,7 +85,7 @@ public final class MultipartFormData: Sendable { public let reason: Reason /// The detailed reason for the encoding failure. - public enum Reason { + public enum Reason: Sendable { case bodyPartURLInvalid(url: URL) case bodyPartFilenameInvalid(in: URL) case bodyPartFileNotReachable(at: URL) @@ -159,14 +159,14 @@ public final class MultipartFormData: Sendable { /// The content length of all body parts used to generate the `multipart/form-data` not including the boundaries. public var contentLength: UInt64 { - lock.withLock { bodyParts.reduce(0) { $0 + $1.bodyContentLength } } + bodyParts.reduce(0) { $0 + $1.bodyContentLength } } /// The boundary used to separate the body parts in the encoded form data. public let boundary: String private let fileManager: FileManager - private let lock = NSLock() + // private let lock = NSLock() private var bodyParts: [BodyPart] = [] private var bodyPartError: EncodingError? private let streamBufferSize: Int @@ -377,9 +377,7 @@ public final class MultipartFormData: Sendable { /// - headers: `HTTPFields` for the body part. public func append(_ stream: InputStream, withLength length: UInt64, headers: HTTPFields) { let bodyPart = BodyPart(headers: headers, bodyStream: stream, bodyContentLength: length) - lock.withLock { - bodyParts.append(bodyPart) - } + bodyParts.append(bodyPart) } // MARK: - Data Encoding @@ -392,19 +390,17 @@ public final class MultipartFormData: Sendable { /// /// - Returns: The encoded `Data`, if encoding is successful. /// - Throws: An `EncodingError` if encoding encounters an error. - public func encode() throws -> Data { + public consuming func encode() throws -> Data { if let bodyPartError { throw bodyPartError } var encoded = Data() - lock.withLock { - bodyParts.first?.hasInitialBoundary = true - bodyParts.last?.hasFinalBoundary = true - } + bodyParts.first?.hasInitialBoundary = true + bodyParts.last?.hasFinalBoundary = true - let parts = lock.withLock { bodyParts } + let parts = bodyParts for bodyPart in parts { let encodedData = try encode(bodyPart) encoded.append(encodedData) @@ -420,7 +416,7 @@ public final class MultipartFormData: Sendable { /// /// - Parameter fileURL: File `URL` to which to write the form data. /// - Throws: An `EncodingError` if encoding encounters an error. - public func writeEncodedData(to fileURL: URL) throws { + public consuming func writeEncodedData(to fileURL: URL) throws { if let bodyPartError { throw bodyPartError } @@ -438,12 +434,10 @@ public final class MultipartFormData: Sendable { outputStream.open() defer { outputStream.close() } - lock.withLock { - bodyParts.first?.hasInitialBoundary = true - bodyParts.last?.hasFinalBoundary = true - } + bodyParts.first?.hasInitialBoundary = true + bodyParts.last?.hasFinalBoundary = true - let parts = lock.withLock { bodyParts } + let parts = bodyParts for bodyPart in parts { try write(bodyPart, to: outputStream) } @@ -634,10 +628,8 @@ public final class MultipartFormData: Sendable { // MARK: - Private - Errors private func setBodyPartError(withReason reason: EncodingError.Reason) { - lock.withLock { - guard bodyPartError == nil else { return } - bodyPartError = EncodingError(reason: reason) - } + guard bodyPartError == nil else { return } + bodyPartError = EncodingError(reason: reason) } } diff --git a/Sources/HTTPClient/Middlewares/LoggingMiddleware.swift b/Sources/HTTPClient/Middlewares/LoggingMiddleware.swift index 54f10bf..f5a8ba5 100644 --- a/Sources/HTTPClient/Middlewares/LoggingMiddleware.swift +++ b/Sources/HTTPClient/Middlewares/LoggingMiddleware.swift @@ -19,9 +19,9 @@ public struct LoggingMiddleware: ClientMiddleware { public func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { var logger = logger if includeMetadata, logger[metadataKey: "request-id"] == nil { diff --git a/Tests/HTTPClientTests/ClientTests.swift b/Tests/HTTPClientTests/ClientTests.swift index 54f9bd9..d9a6167 100644 --- a/Tests/HTTPClientTests/ClientTests.swift +++ b/Tests/HTTPClientTests/ClientTests.swift @@ -13,12 +13,12 @@ struct ClientTests { struct MockTransport: ClientTransport { let responseStatus: HTTPResponse.Status let responseBody: String? - var onSend: ((HTTPRequest, HTTPBody?, URL) -> Void)? + var onSend: (@Sendable (HTTPRequest, HTTPBody?, URL) -> Void)? init( responseStatus: HTTPResponse.Status = .ok, responseBody: String? = nil, - onSend: ((HTTPRequest, HTTPBody?, URL) -> Void)? = nil + onSend: (@Sendable (HTTPRequest, HTTPBody?, URL) -> Void)? = nil ) { self.responseStatus = responseStatus self.responseBody = responseBody @@ -27,7 +27,7 @@ struct ClientTests { func send( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL ) async throws -> (HTTPResponse, HTTPBody?) { onSend?(request, body, baseURL) @@ -44,7 +44,7 @@ struct ClientTests { func send( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL ) async throws -> (HTTPResponse, HTTPBody?) { throw TransportFailure() @@ -54,7 +54,7 @@ struct ClientTests { /// A mock middleware for testing struct MockMiddleware: ClientMiddleware { let id: String - var onIntercept: ((HTTPRequest, HTTPBody?, URL) -> Void)? + var onIntercept: (@Sendable (HTTPRequest, HTTPBody?, URL) -> Void)? var shouldModifyRequest: Bool = false var shouldFail: Bool = false @@ -62,9 +62,9 @@ struct ClientTests { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { onIntercept?(request, body, baseURL) @@ -125,14 +125,15 @@ struct ClientTests { @Test func sendRequestWithBody() async throws { let serverURL = URL(string: "https://api.example.com")! - var capturedBody: String? + let capturedBody = SendableBox(nil) let transport = MockTransport( responseStatus: .created, onSend: { _, body, _ in Task { if let body = body { - capturedBody = try? await String(collecting: body, upTo: 1024) + let value = try? await String(collecting: body, upTo: 1024) + capturedBody.withLock { $0 = value } } } } @@ -151,11 +152,11 @@ struct ClientTests { @Test func clientPassesBaseURLToTransport() async throws { let serverURL = URL(string: "https://api.example.com")! - var capturedBaseURL: URL? + let capturedBaseURL = SendableBox(nil) let transport = MockTransport( onSend: { _, _, baseURL in - capturedBaseURL = baseURL + capturedBaseURL.withLock { $0 = baseURL } } ) @@ -164,32 +165,32 @@ struct ClientTests { let request = HTTPRequest(method: .get, url: serverURL.appending(path: "test")) _ = try await client.send(request) - #expect(capturedBaseURL == serverURL) + #expect(capturedBaseURL.value == serverURL) } // MARK: - Middleware Tests @Test func middlewareExecutionOrder() async throws { let serverURL = URL(string: "https://api.example.com")! - var executionOrder: [String] = [] + let executionOrder = SendableBox<[String]>([]) let middleware1 = MockMiddleware( id: "m1", onIntercept: { _, _, _ in - executionOrder.append("m1") + executionOrder.withLock { $0.append("m1") } } ) let middleware2 = MockMiddleware( id: "m2", onIntercept: { _, _, _ in - executionOrder.append("m2") + executionOrder.withLock { $0.append("m2") } } ) let transport = MockTransport( onSend: { _, _, _ in - executionOrder.append("transport") + executionOrder.withLock { $0.append("transport") } } ) @@ -203,17 +204,17 @@ struct ClientTests { _ = try await client.send(request) // Middlewares should execute in order, then transport - #expect(executionOrder == ["m1", "m2", "transport"]) + #expect(executionOrder.value == ["m1", "m2", "transport"]) } @Test func middlewareCanModifyRequest() async throws { let serverURL = URL(string: "https://api.example.com")! - var capturedRequest: HTTPRequest? + let capturedRequest = SendableBox(nil) let middleware = MockMiddleware(id: "test", shouldModifyRequest: true) let transport = MockTransport( onSend: { request, _, _ in - capturedRequest = request + capturedRequest.withLock { $0 = request } } ) @@ -226,19 +227,19 @@ struct ClientTests { let request = HTTPRequest(method: .get, url: serverURL.appending(path: "test")) _ = try await client.send(request) - #expect(capturedRequest?.headerFields[.init("X-Middleware")!] == "test") + #expect(capturedRequest.value?.headerFields[.init("X-Middleware")!] == "test") } @Test func multipleMiddlewaresCanChainModifications() async throws { let serverURL = URL(string: "https://api.example.com")! - var capturedRequest: HTTPRequest? + let capturedRequest = SendableBox(nil) let middleware1 = MockMiddleware(id: "first", shouldModifyRequest: true) let middleware2 = MockMiddleware(id: "second", shouldModifyRequest: true) let transport = MockTransport( onSend: { request, _, _ in - capturedRequest = request + capturedRequest.withLock { $0 = request } } ) @@ -253,7 +254,7 @@ struct ClientTests { // Both middlewares should have modified the request // Note: The test middleware adds the same header, so we only see the last one - #expect(capturedRequest?.headerFields[.init("X-Middleware")!] != nil) + #expect(capturedRequest.value?.headerFields[.init("X-Middleware")!] != nil) } // MARK: - Error Handling Tests @@ -395,6 +396,6 @@ struct ClientTests { } // If we got here without crashing, concurrent access works - #expect(true) + #expect(Bool(true)) } } diff --git a/Tests/HTTPClientTests/ErrorTests.swift b/Tests/HTTPClientTests/ErrorTests.swift index 2209b4a..854cb94 100644 --- a/Tests/HTTPClientTests/ErrorTests.swift +++ b/Tests/HTTPClientTests/ErrorTests.swift @@ -103,9 +103,9 @@ struct ErrorTests { struct TestMiddleware: ClientMiddleware { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { try await next(request, body, baseURL) } @@ -146,9 +146,9 @@ struct ErrorTests { struct DummyMiddleware: ClientMiddleware { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { try await next(request, body, baseURL) } @@ -216,9 +216,9 @@ struct ErrorTests { struct FailingMiddleware: ClientMiddleware { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { throw MiddlewareError() } @@ -262,9 +262,9 @@ struct ErrorTests { struct ContextPreservingMiddleware: ClientMiddleware { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { // Throw a pre-constructed ClientError let existingError = ClientError( diff --git a/Tests/HTTPClientTests/HTTPBodyProgressTests.swift b/Tests/HTTPClientTests/HTTPBodyProgressTests.swift index 8541e00..8a2cff8 100644 --- a/Tests/HTTPClientTests/HTTPBodyProgressTests.swift +++ b/Tests/HTTPClientTests/HTTPBodyProgressTests.swift @@ -75,18 +75,19 @@ struct HTTPBodyProgressTests { let data = Data("Hello, World!".utf8) let body = HTTPBody(data) - var progressUpdates: [Progress] = [] + let progressUpdates = SendableBox<[Progress]>([]) let trackedBody = body.trackingProgress { progress in - progressUpdates.append(progress) + progressUpdates.withLock { $0.append(progress) } } // Consume the body for try await _ in trackedBody {} - #expect(progressUpdates.count == 1) - #expect(progressUpdates[0].completed == 13) - #expect(progressUpdates[0].total == 13) - #expect(progressUpdates[0].fractionCompleted == 1.0) + let updates = progressUpdates.value + #expect(updates.count == 1) + #expect(updates[0].completed == 13) + #expect(updates[0].total == 13) + #expect(updates[0].fractionCompleted == 1.0) } @Test func trackingProgressWithMultipleChunks() async throws { @@ -97,30 +98,31 @@ struct HTTPBodyProgressTests { ] let body = HTTPBody(chunks, length: .known(13), iterationBehavior: .multiple) - var progressUpdates: [Progress] = [] + let progressUpdates = SendableBox<[Progress]>([]) let trackedBody = body.trackingProgress { progress in - progressUpdates.append(progress) + progressUpdates.withLock { $0.append(progress) } } // Consume the body for try await _ in trackedBody {} - #expect(progressUpdates.count == 3) + let updates = progressUpdates.value + #expect(updates.count == 3) // First chunk: "Hello" (5 bytes) - #expect(progressUpdates[0].completed == 5) - #expect(progressUpdates[0].total == 13) - #expect(progressUpdates[0].fractionCompleted == 5.0 / 13.0) + #expect(updates[0].completed == 5) + #expect(updates[0].total == 13) + #expect(updates[0].fractionCompleted == 5.0 / 13.0) // Second chunk: ", " (2 bytes, cumulative 7) - #expect(progressUpdates[1].completed == 7) - #expect(progressUpdates[1].total == 13) - #expect(progressUpdates[1].fractionCompleted == 7.0 / 13.0) + #expect(updates[1].completed == 7) + #expect(updates[1].total == 13) + #expect(updates[1].fractionCompleted == 7.0 / 13.0) // Third chunk: "World!" (6 bytes, cumulative 13) - #expect(progressUpdates[2].completed == 13) - #expect(progressUpdates[2].total == 13) - #expect(progressUpdates[2].fractionCompleted == 1.0) + #expect(updates[2].completed == 13) + #expect(updates[2].total == 13) + #expect(updates[2].fractionCompleted == 1.0) } @Test func trackingProgressWithUnknownLength() async throws { @@ -130,40 +132,41 @@ struct HTTPBodyProgressTests { ] let body = HTTPBody(chunks, length: .unknown, iterationBehavior: .multiple) - var progressUpdates: [Progress] = [] + let progressUpdates = SendableBox<[Progress]>([]) let trackedBody = body.trackingProgress { progress in - progressUpdates.append(progress) + progressUpdates.withLock { $0.append(progress) } } // Consume the body for try await _ in trackedBody {} - #expect(progressUpdates.count == 2) + let updates = progressUpdates.value + #expect(updates.count == 2) // First chunk - #expect(progressUpdates[0].completed == 5) - #expect(progressUpdates[0].total == nil) - #expect(progressUpdates[0].fractionCompleted == nil) + #expect(updates[0].completed == 5) + #expect(updates[0].total == nil) + #expect(updates[0].fractionCompleted == nil) // Second chunk - #expect(progressUpdates[1].completed == 10) - #expect(progressUpdates[1].total == nil) - #expect(progressUpdates[1].fractionCompleted == nil) + #expect(updates[1].completed == 10) + #expect(updates[1].total == nil) + #expect(updates[1].fractionCompleted == nil) } @Test func trackingProgressWithEmptyBody() async throws { let body = HTTPBody() - var progressUpdates: [Progress] = [] + let progressUpdates = SendableBox<[Progress]>([]) let trackedBody = body.trackingProgress { progress in - progressUpdates.append(progress) + progressUpdates.withLock { $0.append(progress) } } // Consume the body for try await _ in trackedBody {} // No chunks, so no progress updates - #expect(progressUpdates.isEmpty) + #expect(progressUpdates.value.isEmpty) } @Test func trackingProgressPreservesBodyLength() { @@ -202,18 +205,19 @@ struct HTTPBodyProgressTests { let totalSize = Int64(chunkSize * numChunks) let body = HTTPBody(chunks, length: .known(totalSize), iterationBehavior: .multiple) - var progressUpdates: [Progress] = [] + let progressUpdates = SendableBox<[Progress]>([]) let trackedBody = body.trackingProgress { progress in - progressUpdates.append(progress) + progressUpdates.withLock { $0.append(progress) } } // Consume the body for try await _ in trackedBody {} - #expect(progressUpdates.count == numChunks) + let updates = progressUpdates.value + #expect(updates.count == numChunks) // Verify cumulative progress - for (index, progress) in progressUpdates.enumerated() { + for (index, progress) in updates.enumerated() { let expectedCompleted = Int64(chunkSize * (index + 1)) #expect(progress.completed == expectedCompleted) #expect(progress.total == totalSize) @@ -223,7 +227,7 @@ struct HTTPBodyProgressTests { } // Last update should be 100% complete - #expect(progressUpdates.last?.fractionCompleted == 1.0) + #expect(updates.last?.fractionCompleted == 1.0) } @Test func trackingProgressWithAsyncStream() async throws { @@ -239,16 +243,17 @@ struct HTTPBodyProgressTests { let totalLength = chunks.joined().count let body = HTTPBody(stream, length: .known(Int64(totalLength))) - var progressUpdates: [Progress] = [] + let progressUpdates = SendableBox<[Progress]>([]) let trackedBody = body.trackingProgress { progress in - progressUpdates.append(progress) + progressUpdates.withLock { $0.append(progress) } } // Consume the body for try await _ in trackedBody {} - #expect(progressUpdates.count == 3) - #expect(progressUpdates.last?.completed == Int64(totalLength)) + let updates = progressUpdates.value + #expect(updates.count == 3) + #expect(updates.last?.completed == Int64(totalLength)) } @Test func trackingProgressHandlerCalledInOrder() async throws { @@ -259,16 +264,16 @@ struct HTTPBodyProgressTests { ] let body = HTTPBody(chunks, length: .known(3), iterationBehavior: .multiple) - var completedValues: [Int64] = [] + let completedValues = SendableBox<[Int64]>([]) let trackedBody = body.trackingProgress { progress in - completedValues.append(progress.completed) + completedValues.withLock { $0.append(progress.completed) } } // Consume the body for try await _ in trackedBody {} // Verify progress is monotonically increasing - #expect(completedValues == [1, 2, 3]) + #expect(completedValues.value == [1, 2, 3]) } @Test func trackingProgressDoesNotModifyChunks() async throws { @@ -290,27 +295,27 @@ struct HTTPBodyProgressTests { ] let body = HTTPBody(chunks, length: .known(4), iterationBehavior: .multiple) - var firstIterationUpdates: [Progress] = [] + let firstUpdates = SendableBox<[Progress]>([]) let trackedBody = body.trackingProgress { progress in - firstIterationUpdates.append(progress) + firstUpdates.withLock { $0.append(progress) } } // First iteration for try await _ in trackedBody {} - #expect(firstIterationUpdates.count == 2) + #expect(firstUpdates.value.count == 2) // Note: Second iteration would create a new tracked body // as trackingProgress returns a new HTTPBody instance - var secondIterationUpdates: [Progress] = [] + let secondUpdates = SendableBox<[Progress]>([]) let trackedBody2 = body.trackingProgress { progress in - secondIterationUpdates.append(progress) + secondUpdates.withLock { $0.append(progress) } } // Second iteration for try await _ in trackedBody2 {} - #expect(secondIterationUpdates.count == 2) + #expect(secondUpdates.value.count == 2) } @Test func progressFractionCompletedEdgeCases() { @@ -331,18 +336,19 @@ struct HTTPBodyProgressTests { ] let body = HTTPBody(chunks, length: .known(5), iterationBehavior: .multiple) - var progressUpdates: [Progress] = [] + let progressUpdates = SendableBox<[Progress]>([]) let trackedBody = body.trackingProgress { progress in - progressUpdates.append(progress) + progressUpdates.withLock { $0.append(progress) } } // Consume the body for try await _ in trackedBody {} // Should have 3 updates (one for each chunk, even zero-sized ones) - #expect(progressUpdates.count == 3) - #expect(progressUpdates[0].completed == 0) // Empty chunk - #expect(progressUpdates[1].completed == 5) // "Hello" - #expect(progressUpdates[2].completed == 5) // Empty chunk (no change) + let updates = progressUpdates.value + #expect(updates.count == 3) + #expect(updates[0].completed == 0) // Empty chunk + #expect(updates[1].completed == 5) // "Hello" + #expect(updates[2].completed == 5) // Empty chunk (no change) } } diff --git a/Tests/HTTPClientTests/IntegrationTests.swift b/Tests/HTTPClientTests/IntegrationTests.swift index 2713351..55e84ba 100644 --- a/Tests/HTTPClientTests/IntegrationTests.swift +++ b/Tests/HTTPClientTests/IntegrationTests.swift @@ -383,9 +383,9 @@ struct IntegrationTests { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { var request = request request.headerFields[.authorization] = "Bearer \(token)" @@ -428,9 +428,9 @@ struct IntegrationTests { struct UserAgentMiddleware: ClientMiddleware { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { var request = request request.headerFields[.userAgent] = "SwiftHTTPClient/Integration" @@ -441,9 +441,9 @@ struct IntegrationTests { struct AcceptMiddleware: ClientMiddleware { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { var request = request request.headerFields[.accept] = "application/json" diff --git a/Tests/HTTPClientTests/LoggingMiddlewareTests.swift b/Tests/HTTPClientTests/LoggingMiddlewareTests.swift index fa09842..d035727 100644 --- a/Tests/HTTPClientTests/LoggingMiddlewareTests.swift +++ b/Tests/HTTPClientTests/LoggingMiddlewareTests.swift @@ -14,13 +14,29 @@ struct LoggingMiddlewareTests { /// A test log handler that captures log messages struct TestLogHandler: LogHandler { - struct LogEntry { + struct LogEntry: Sendable { let level: Logger.Level - let message: Logger.Message + let message: String let metadata: Logger.Metadata? } - let entries: UnsafeMutablePointer<[LogEntry]> + actor LogCollector { + private var entries: [LogEntry] = [] + + func append(_ entry: LogEntry) { + entries.append(entry) + } + + func getEntries() -> [LogEntry] { + entries + } + + func clear() { + entries.removeAll() + } + } + + let collector: LogCollector var metadata: Logger.Metadata = [:] var logLevel: Logger.Level = .trace @@ -39,8 +55,10 @@ struct LoggingMiddlewareTests { function: String, line: UInt ) { - let entry = LogEntry(level: level, message: message, metadata: metadata) - entries.pointee.append(entry) + let entry = LogEntry(level: level, message: "\(message)", metadata: metadata) + Task { + await collector.append(entry) + } } } @@ -62,11 +80,8 @@ struct LoggingMiddlewareTests { // MARK: - Basic Logging Tests @Test func loggingMiddlewareLogsRequest() async throws { - let entriesPtr = UnsafeMutablePointer<[TestLogHandler.LogEntry]>.allocate(capacity: 1) - entriesPtr.initialize(to: []) - defer { entriesPtr.deallocate() } - - let handler = TestLogHandler(entries: entriesPtr) + let collector = TestLogHandler.LogCollector() + let handler = TestLogHandler(collector: collector) var logger = Logger(label: "test") logger.handler = handler @@ -82,27 +97,29 @@ struct LoggingMiddlewareTests { let request = HTTPRequest(method: .get, url: serverURL.appending(path: "test")) _ = try await client.send(request) + // Wait a bit for async logging to complete + try await Task.sleep(for: .milliseconds(100)) + + let entries = await collector.getEntries() + // Should have logged both request and response - #expect(entriesPtr.pointee.count == 2) + #expect(entries.count == 2) // First log should be the request (with ⬆️) - let requestLog = entriesPtr.pointee[0] + let requestLog = entries[0] #expect(requestLog.level == .trace) - #expect(requestLog.message.description.contains("⬆️")) - #expect(requestLog.message.description.contains("GET")) + #expect(requestLog.message.contains("⬆️")) + #expect(requestLog.message.contains("GET")) // Second log should be the response (with ⬇️) - let responseLog = entriesPtr.pointee[1] + let responseLog = entries[1] #expect(responseLog.level == .trace) - #expect(responseLog.message.description.contains("⬇️")) + #expect(responseLog.message.contains("⬇️")) } @Test func loggingMiddlewareLogsResponse() async throws { - let entriesPtr = UnsafeMutablePointer<[TestLogHandler.LogEntry]>.allocate(capacity: 1) - entriesPtr.initialize(to: []) - defer { entriesPtr.deallocate() } - - let handler = TestLogHandler(entries: entriesPtr) + let collector = TestLogHandler.LogCollector() + let handler = TestLogHandler(collector: collector) var logger = Logger(label: "test") logger.handler = handler @@ -118,18 +135,18 @@ struct LoggingMiddlewareTests { let request = HTTPRequest(method: .post, url: serverURL.appending(path: "users")) _ = try await client.send(request) - #expect(entriesPtr.pointee.count == 2) + try await Task.sleep(for: .milliseconds(100)) + + let entries = await collector.getEntries() + #expect(entries.count == 2) - let responseLog = entriesPtr.pointee[1] - #expect(responseLog.message.description.contains("201")) + let responseLog = entries[1] + #expect(responseLog.message.contains("201")) } @Test func loggingMiddlewareAddsRequestID() async throws { - let entriesPtr = UnsafeMutablePointer<[TestLogHandler.LogEntry]>.allocate(capacity: 1) - entriesPtr.initialize(to: []) - defer { entriesPtr.deallocate() } - - let handler = TestLogHandler(entries: entriesPtr) + let collector = TestLogHandler.LogCollector() + let handler = TestLogHandler(collector: collector) var logger = Logger(label: "test") logger.handler = handler @@ -145,7 +162,10 @@ struct LoggingMiddlewareTests { let request = HTTPRequest(method: .get, url: serverURL.appending(path: "test")) _ = try await client.send(request) - #expect(entriesPtr.pointee.count == 2) + try await Task.sleep(for: .milliseconds(100)) + + let entries = await collector.getEntries() + #expect(entries.count == 2) // Both logs should have metadata (inherited from logger) // The request-id should be set @@ -153,11 +173,8 @@ struct LoggingMiddlewareTests { } @Test func loggingMiddlewareWithoutMetadata() async throws { - let entriesPtr = UnsafeMutablePointer<[TestLogHandler.LogEntry]>.allocate(capacity: 1) - entriesPtr.initialize(to: []) - defer { entriesPtr.deallocate() } - - let handler = TestLogHandler(entries: entriesPtr) + let collector = TestLogHandler.LogCollector() + let handler = TestLogHandler(collector: collector) var logger = Logger(label: "test") logger.handler = handler @@ -173,15 +190,15 @@ struct LoggingMiddlewareTests { let request = HTTPRequest(method: .get, url: serverURL.appending(path: "test")) _ = try await client.send(request) - #expect(entriesPtr.pointee.count == 2) + try await Task.sleep(for: .milliseconds(100)) + + let entries = await collector.getEntries() + #expect(entries.count == 2) } @Test func loggingMiddlewarePreservesExistingRequestID() async throws { - let entriesPtr = UnsafeMutablePointer<[TestLogHandler.LogEntry]>.allocate(capacity: 1) - entriesPtr.initialize(to: []) - defer { entriesPtr.deallocate() } - - let handler = TestLogHandler(entries: entriesPtr) + let collector = TestLogHandler.LogCollector() + let handler = TestLogHandler(collector: collector) var logger = Logger(label: "test") logger.handler = handler logger[metadataKey: "request-id"] = "existing-id" @@ -198,16 +215,16 @@ struct LoggingMiddlewareTests { let request = HTTPRequest(method: .get, url: serverURL.appending(path: "test")) _ = try await client.send(request) + try await Task.sleep(for: .milliseconds(100)) + // Logs should be created - #expect(entriesPtr.pointee.count == 2) + let entries = await collector.getEntries() + #expect(entries.count == 2) } @Test func loggingMiddlewareLogsDifferentMethods() async throws { - let entriesPtr = UnsafeMutablePointer<[TestLogHandler.LogEntry]>.allocate(capacity: 1) - entriesPtr.initialize(to: []) - defer { entriesPtr.deallocate() } - - let handler = TestLogHandler(entries: entriesPtr) + let collector = TestLogHandler.LogCollector() + let handler = TestLogHandler(collector: collector) var logger = Logger(label: "test") logger.handler = handler @@ -223,15 +240,18 @@ struct LoggingMiddlewareTests { let methods: [HTTPRequest.Method] = [.get, .post, .put, .delete, .patch] for method in methods { - entriesPtr.pointee.removeAll() + await collector.clear() let request = HTTPRequest(method: method, url: serverURL.appending(path: "test")) _ = try await client.send(request) - #expect(entriesPtr.pointee.count == 2) + try await Task.sleep(for: .milliseconds(100)) + + let entries = await collector.getEntries() + #expect(entries.count == 2) - let requestLog = entriesPtr.pointee[0] - #expect(requestLog.message.description.contains(method.rawValue)) + let requestLog = entries[0] + #expect(requestLog.message.contains(method.rawValue)) } } @@ -241,11 +261,8 @@ struct LoggingMiddlewareTests { ] for status in statuses { - let entriesPtr = UnsafeMutablePointer<[TestLogHandler.LogEntry]>.allocate(capacity: 1) - entriesPtr.initialize(to: []) - defer { entriesPtr.deallocate() } - - let handler = TestLogHandler(entries: entriesPtr) + let collector = TestLogHandler.LogCollector() + let handler = TestLogHandler(collector: collector) var logger = Logger(label: "test") logger.handler = handler @@ -261,28 +278,28 @@ struct LoggingMiddlewareTests { let request = HTTPRequest(method: .get, url: serverURL.appending(path: "test")) _ = try await client.send(request) - #expect(entriesPtr.pointee.count == 2) + try await Task.sleep(for: .milliseconds(100)) + + let entries = await collector.getEntries() + #expect(entries.count == 2) - let responseLog = entriesPtr.pointee[1] - #expect(responseLog.message.description.contains(String(status.code))) + let responseLog = entries[1] + #expect(responseLog.message.contains(String(status.code))) } } @Test func loggingMiddlewareWorksWithOtherMiddlewares() async throws { - let entriesPtr = UnsafeMutablePointer<[TestLogHandler.LogEntry]>.allocate(capacity: 1) - entriesPtr.initialize(to: []) - defer { entriesPtr.deallocate() } - - let handler = TestLogHandler(entries: entriesPtr) + let collector = TestLogHandler.LogCollector() + let handler = TestLogHandler(collector: collector) var logger = Logger(label: "test") logger.handler = handler struct TestMiddleware: ClientMiddleware { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { var request = request request.headerFields[.init("X-Test")!] = "test" @@ -303,16 +320,16 @@ struct LoggingMiddlewareTests { let request = HTTPRequest(method: .get, url: serverURL.appending(path: "test")) _ = try await client.send(request) + try await Task.sleep(for: .milliseconds(100)) + // Logging middleware should have logged - #expect(entriesPtr.pointee.count == 2) + let entries = await collector.getEntries() + #expect(entries.count == 2) } @Test func loggingMiddlewareLogsErrors() async throws { - let entriesPtr = UnsafeMutablePointer<[TestLogHandler.LogEntry]>.allocate(capacity: 1) - entriesPtr.initialize(to: []) - defer { entriesPtr.deallocate() } - - let handler = TestLogHandler(entries: entriesPtr) + let collector = TestLogHandler.LogCollector() + let handler = TestLogHandler(collector: collector) var logger = Logger(label: "test") logger.handler = handler @@ -346,9 +363,12 @@ struct LoggingMiddlewareTests { // Expected to throw } + try await Task.sleep(for: .milliseconds(100)) + // Request should still be logged - #expect(entriesPtr.pointee.count >= 1) - let requestLog = entriesPtr.pointee[0] - #expect(requestLog.message.description.contains("⬆️")) + let entries = await collector.getEntries() + #expect(entries.count >= 1) + let requestLog = entries[0] + #expect(requestLog.message.contains("⬆️")) } } diff --git a/Tests/HTTPClientTests/MiddlewareTests.swift b/Tests/HTTPClientTests/MiddlewareTests.swift index 3217d47..b259198 100644 --- a/Tests/HTTPClientTests/MiddlewareTests.swift +++ b/Tests/HTTPClientTests/MiddlewareTests.swift @@ -15,9 +15,9 @@ struct MiddlewareTests { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { var request = request request.headerFields[.authorization] = "Bearer \(token)" @@ -31,9 +31,9 @@ struct MiddlewareTests { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { var request = request request.headerFields[.userAgent] = userAgent @@ -47,9 +47,9 @@ struct MiddlewareTests { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { var lastError: Error? @@ -71,13 +71,13 @@ struct MiddlewareTests { /// Response interceptor middleware struct ResponseInterceptorMiddleware: ClientMiddleware { - var onResponse: ((HTTPResponse, HTTPBody?) -> Void)? + var onResponse: (@Sendable (HTTPResponse, HTTPBody?) -> Void)? func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { let (response, responseBody) = try await next(request, body, baseURL) onResponse?(response, responseBody) @@ -91,9 +91,9 @@ struct MiddlewareTests { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { var request = request for (key, value) in headers { @@ -110,7 +110,7 @@ struct MiddlewareTests { struct MockTransport: ClientTransport { let responseStatus: HTTPResponse.Status let responseBody: String? - var onSend: ((HTTPRequest) -> Void)? + var onSend: (@Sendable (HTTPRequest) -> Void)? func send( _ request: HTTPRequest, @@ -141,13 +141,13 @@ struct MiddlewareTests { @Test func authMiddlewareAddsToken() async throws { let serverURL = URL(string: "https://api.example.com")! - var capturedRequest: HTTPRequest? + let capturedRequest = SendableBox(nil) let transport = MockTransport( responseStatus: .ok, responseBody: nil, onSend: { request in - capturedRequest = request + capturedRequest.withLock { $0 = request } } ) @@ -161,20 +161,20 @@ struct MiddlewareTests { let request = HTTPRequest(method: .get, url: serverURL.appending(path: "protected")) _ = try await client.send(request) - #expect(capturedRequest?.headerFields[.authorization] == "Bearer secret-token-123") + #expect(capturedRequest.value?.headerFields[.authorization] == "Bearer secret-token-123") } // MARK: - User-Agent Middleware Tests @Test func userAgentMiddlewareAddsHeader() async throws { let serverURL = URL(string: "https://api.example.com")! - var capturedRequest: HTTPRequest? + let capturedRequest = SendableBox(nil) let transport = MockTransport( responseStatus: .ok, responseBody: nil, onSend: { request in - capturedRequest = request + capturedRequest.withLock { $0 = request } } ) @@ -188,20 +188,20 @@ struct MiddlewareTests { let request = HTTPRequest(method: .get, url: serverURL.appending(path: "test")) _ = try await client.send(request) - #expect(capturedRequest?.headerFields[.userAgent] == "MyApp/1.0") + #expect(capturedRequest.value?.headerFields[.userAgent] == "MyApp/1.0") } // MARK: - Multiple Middleware Tests @Test func multipleMiddlewaresStackCorrectly() async throws { let serverURL = URL(string: "https://api.example.com")! - var capturedRequest: HTTPRequest? + let capturedRequest = SendableBox(nil) let transport = MockTransport( responseStatus: .ok, responseBody: nil, onSend: { request in - capturedRequest = request + capturedRequest.withLock { $0 = request } } ) @@ -217,21 +217,21 @@ struct MiddlewareTests { let request = HTTPRequest(method: .get, url: serverURL.appending(path: "test")) _ = try await client.send(request) - #expect(capturedRequest?.headerFields[.authorization] == "Bearer token") - #expect(capturedRequest?.headerFields[.userAgent] == "MyApp/1.0") + #expect(capturedRequest.value?.headerFields[.authorization] == "Bearer token") + #expect(capturedRequest.value?.headerFields[.userAgent] == "MyApp/1.0") } // MARK: - Response Interceptor Tests @Test func responseInterceptorReceivesResponse() async throws { let serverURL = URL(string: "https://api.example.com")! - var interceptedStatus: HTTPResponse.Status? + let interceptedStatus = SendableBox(nil) let transport = MockTransport(responseStatus: .created, responseBody: "Created") let interceptor = ResponseInterceptorMiddleware( onResponse: { response, _ in - interceptedStatus = response.status + interceptedStatus.withLock { $0 = response.status } } ) @@ -244,20 +244,20 @@ struct MiddlewareTests { let request = HTTPRequest(method: .post, url: serverURL.appending(path: "test")) _ = try await client.send(request) - #expect(interceptedStatus == .created) + #expect(interceptedStatus.value == .created) } // MARK: - Custom Headers Tests @Test func customHeaderMiddleware() async throws { let serverURL = URL(string: "https://api.example.com")! - var capturedRequest: HTTPRequest? + let capturedRequest = SendableBox(nil) let transport = MockTransport( responseStatus: .ok, responseBody: nil, onSend: { request in - capturedRequest = request + capturedRequest.withLock { $0 = request } } ) @@ -276,41 +276,50 @@ struct MiddlewareTests { let request = HTTPRequest(method: .get, url: serverURL.appending(path: "test")) _ = try await client.send(request) - #expect(capturedRequest?.headerFields[.init("X-API-Version")!] == "v1") - #expect(capturedRequest?.headerFields[.init("X-Client-ID")!] == "client-123") + #expect(capturedRequest.value?.headerFields[.init("X-API-Version")!] == "v1") + #expect(capturedRequest.value?.headerFields[.init("X-Client-ID")!] == "client-123") } // MARK: - Middleware Order Tests @Test func middlewareExecutionOrder() async throws { let serverURL = URL(string: "https://api.example.com")! - var executionOrder: [String] = [] + + actor ExecutionOrderTracker { + private var order: [String] = [] + + func append(_ item: String) { + order.append(item) + } + + func getOrder() -> [String] { + order + } + } struct OrderTrackingMiddleware: ClientMiddleware { let name: String - let executionOrder: UnsafeMutablePointer<[String]> + let tracker: ExecutionOrderTracker func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { - executionOrder.pointee.append("\(name)-before") + await tracker.append("\(name)-before") let result = try await next(request, body, baseURL) - executionOrder.pointee.append("\(name)-after") + await tracker.append("\(name)-after") return result } } let transport = MockTransport(responseStatus: .ok, responseBody: nil) - let orderPtr = UnsafeMutablePointer<[String]>.allocate(capacity: 1) - orderPtr.initialize(to: []) - defer { orderPtr.deallocate() } + let tracker = ExecutionOrderTracker() - let middleware1 = OrderTrackingMiddleware(name: "first", executionOrder: orderPtr) - let middleware2 = OrderTrackingMiddleware(name: "second", executionOrder: orderPtr) + let middleware1 = OrderTrackingMiddleware(name: "first", tracker: tracker) + let middleware2 = OrderTrackingMiddleware(name: "second", tracker: tracker) let client = Client( serverURL: serverURL, @@ -322,7 +331,8 @@ struct MiddlewareTests { _ = try await client.send(request) // Middlewares execute in order before, then reverse order after - #expect(orderPtr.pointee == ["first-before", "second-before", "second-after", "first-after"]) + let executionOrder = await tracker.getOrder() + #expect(executionOrder == ["first-before", "second-before", "second-after", "first-after"]) } // MARK: - Middleware Error Handling @@ -335,9 +345,9 @@ struct MiddlewareTests { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { do { return try await next(request, body, baseURL) @@ -369,14 +379,13 @@ struct MiddlewareTests { @Test func middlewareCanModifyRequestBody() async throws { let serverURL = URL(string: "https://api.example.com")! - var capturedBodyContent: String? struct BodyModifyingMiddleware: ClientMiddleware { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { // Wrap the body with additional content let modifiedBody = HTTPBody("modified-") @@ -405,21 +414,21 @@ struct MiddlewareTests { _ = try await client.send(request, body: originalBody) // The middleware modified the body (verification would require async body capture) - #expect(true) + #expect(Bool(true)) } // MARK: - Conditional Middleware @Test func conditionalMiddleware() async throws { let serverURL = URL(string: "https://api.example.com")! - var headerWasAdded = false + let headerWasAdded = SendableBox(false) struct ConditionalMiddleware: ClientMiddleware { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { var request = request @@ -436,7 +445,7 @@ struct MiddlewareTests { responseStatus: .ok, responseBody: nil, onSend: { request in - headerWasAdded = request.headerFields[.init("X-POST-Only")!] != nil + headerWasAdded.withLock { $0 = request.headerFields[.init("X-POST-Only")!] != nil } } ) @@ -450,12 +459,12 @@ struct MiddlewareTests { // Test GET request - header should not be added let getRequest = HTTPRequest(method: .get, url: serverURL.appending(path: "test")) _ = try await client.send(getRequest) - #expect(headerWasAdded == false) + #expect(headerWasAdded.value == false) // Test POST request - header should be added let postRequest = HTTPRequest(method: .post, url: serverURL.appending(path: "test")) _ = try await client.send(postRequest) - #expect(headerWasAdded == true) + #expect(headerWasAdded.value == true) } // MARK: - State Tracking Middleware @@ -480,9 +489,9 @@ struct MiddlewareTests { func intercept( _ request: HTTPRequest, - body: HTTPBody?, + body: sending HTTPBody?, baseURL: URL, - next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + next: (HTTPRequest, sending HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) ) async throws -> (HTTPResponse, HTTPBody?) { await counter.increment() return try await next(request, body, baseURL) diff --git a/Tests/HTTPClientTests/TestHelpers.swift b/Tests/HTTPClientTests/TestHelpers.swift new file mode 100644 index 0000000..7263490 --- /dev/null +++ b/Tests/HTTPClientTests/TestHelpers.swift @@ -0,0 +1,27 @@ +import Foundation + +/// A thread-safe box for collecting values from @Sendable closures in tests. +/// +/// This type wraps a mutable value with an `NSLock` to allow safe mutation +/// from `@Sendable` closures, which is commonly needed in tests that capture +/// state from transport or middleware callbacks. +final class SendableBox: @unchecked Sendable { + private let lock = NSLock() + private var _value: T + + init(_ value: T) { + self._value = value + } + + var value: T { + lock.lock() + defer { lock.unlock() } + return _value + } + + func withLock(_ body: (inout T) -> R) -> R { + lock.lock() + defer { lock.unlock() } + return body(&_value) + } +}