From 3f953a7cd926a15617d4b2453d037f51aa0a8017 Mon Sep 17 00:00:00 2001 From: Guoye Zhang Date: Wed, 13 May 2026 12:58:23 -0700 Subject: [PATCH 1/2] Switch HTTPClient response handler from a closure to a protocol --- .../ExampleMiddlewareClient.swift | 14 ++++-- Sources/AHCHTTPClient/AHC+HTTPClient.swift | 15 ++++-- Sources/FetchHTTPClient/FetchHTTPClient.swift | 21 +++++---- .../Client/HTTPClient+Conveniences.swift | 9 +++- Sources/HTTPAPIs/Client/HTTPClient.swift | 11 +++-- .../HTTPClientClosureResponseHandler.swift | 34 ++++++++++++++ .../Client/HTTPClientResponseHandler.swift | 26 +++++++++++ ...HTTPClientTransformedResponseHandler.swift | 46 +++++++++++++++++++ Sources/HTTPClient/DefaultHTTPClient.swift | 16 +++++-- Sources/HTTPClient/HTTP+Conveniences.swift | 9 +++- .../URLSessionHTTPClient.swift | 15 ++++-- .../Helpers/HTTPClientAndServerTests.swift | 19 +++++--- 12 files changed, 193 insertions(+), 42 deletions(-) create mode 100644 Sources/HTTPAPIs/Client/HTTPClientClosureResponseHandler.swift create mode 100644 Sources/HTTPAPIs/Client/HTTPClientResponseHandler.swift create mode 100644 Sources/HTTPAPIs/Client/HTTPClientTransformedResponseHandler.swift diff --git a/Examples/MiddlewareClient/ExampleMiddlewareClient.swift b/Examples/MiddlewareClient/ExampleMiddlewareClient.swift index e287ab6..e8bff92 100644 --- a/Examples/MiddlewareClient/ExampleMiddlewareClient.swift +++ b/Examples/MiddlewareClient/ExampleMiddlewareClient.swift @@ -36,13 +36,19 @@ struct ExampleMiddlewareClient()) } - mutating func perform( + mutating func perform( request: HTTPRequest, body: consuming HTTPClientRequestBody?, options: RequestOptions, - responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return - ) async throws -> Return { + responseHandler: consuming ResponseHandler + ) async throws -> Return + where + ResponseHandler.ResponseConcludingReader: ~Copyable, + ResponseHandler.ResponseConcludingReader == ResponseConcludingReader, + ResponseHandler.Return == Return + { var body = Optional(body) + var responseHandler = Optional(responseHandler) return try await self.middleware.intercept( input: request ) { request in @@ -50,7 +56,7 @@ struct ExampleMiddlewareClient( + public func perform( request: HTTPRequest, - body: consuming HTTPClientRequestBody?, + body: consuming HTTPClientRequestBody?, options: RequestOptions, - responseHandler: (HTTPResponse, consuming ResponseReader) async throws -> Return - ) async throws -> Return { + responseHandler: consuming ResponseHandler + ) async throws -> Return + where + ResponseHandler.ResponseConcludingReader: ~Copyable, + ResponseHandler.ResponseConcludingReader == ResponseConcludingReader, + ResponseHandler.Return == Return + { guard let url = request.url else { fatalError() } @@ -253,7 +258,7 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient { headerFields: responseFields ) - result = .success(try await responseHandler(response, .init(underlying: ahcResponse.body))) + result = .success(try await responseHandler.handle(response: response, responseBodyAndTrailers: .init(underlying: ahcResponse.body))) } catch { result = .failure(error) } diff --git a/Sources/FetchHTTPClient/FetchHTTPClient.swift b/Sources/FetchHTTPClient/FetchHTTPClient.swift index 33bc7bf..4119385 100644 --- a/Sources/FetchHTTPClient/FetchHTTPClient.swift +++ b/Sources/FetchHTTPClient/FetchHTTPClient.swift @@ -49,12 +49,17 @@ public final class FetchHTTPClient: HTTPAPIs.HTTPClient { public init() {} - public func perform( - request: HTTPTypes.HTTPRequest, - body: consuming HTTPAPIs.HTTPClientRequestBody?, + public func perform( + request: HTTPRequest, + body: consuming HTTPClientRequestBody?, options: RequestOptions, - responseHandler: nonisolated(nonsending) (HTTPTypes.HTTPResponse, consuming ResponseReader) async throws -> Return - ) async throws -> Return where Return: ~Copyable { + responseHandler: consuming ResponseHandler + ) async throws -> Return + where + ResponseHandler.ResponseConcludingReader: ~Copyable, + ResponseHandler.ResponseConcludingReader == ResponseConcludingReader, + ResponseHandler.Return == Return + { guard let url = request.url else { throw FetchError.BadURL } @@ -118,9 +123,9 @@ public final class FetchHTTPClient: HTTPAPIs.HTTPClient { responseHeaders.append(.init(name: name, isoLatin1Value: entry[1])) } - return try await responseHandler( - HTTPResponse(status: .init(code: responseStatus, reasonPhrase: responseStatusText), headerFields: responseHeaders), - ResponseReader(reader: reader) + return try await responseHandler.handle( + response: HTTPResponse(status: .init(code: responseStatus, reasonPhrase: responseStatusText), headerFields: responseHeaders), + responseBodyAndTrailers: ResponseReader(reader: reader) ) } diff --git a/Sources/HTTPAPIs/Client/HTTPClient+Conveniences.swift b/Sources/HTTPAPIs/Client/HTTPClient+Conveniences.swift index 394b429..836cbb3 100644 --- a/Sources/HTTPAPIs/Client/HTTPClient+Conveniences.swift +++ b/Sources/HTTPAPIs/Client/HTTPClient+Conveniences.swift @@ -49,10 +49,15 @@ where request: HTTPRequest, body: consuming HTTPClientRequestBody? = nil, options: RequestOptions? = nil, - responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return, + responseHandler: @escaping (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return, ) async throws -> Return { let options = options ?? self.defaultRequestOptions - return try await self.perform(request: request, body: body, options: options, responseHandler: responseHandler) + return try await self.perform( + request: request, + body: body, + options: options, + responseHandler: HTTPClientClosureResponseHandler(handler: responseHandler) + ) } /// Performs an HTTP GET request and collects the response body. diff --git a/Sources/HTTPAPIs/Client/HTTPClient.swift b/Sources/HTTPAPIs/Client/HTTPClient.swift index 61d0196..72e20ff 100644 --- a/Sources/HTTPAPIs/Client/HTTPClient.swift +++ b/Sources/HTTPAPIs/Client/HTTPClient.swift @@ -45,8 +45,7 @@ public protocol HTTPClient: Sendable, ~Copyable, ~Escapable { /// - request: The HTTP request header to send. /// - body: The optional request body to send. When `nil`, sends no body. /// - options: The options for this request. - /// - responseHandler: A closure that processes the response. The method invokes this - /// closure when it receives the response header, providing access to the response body. + /// - responseHandler: A handler that processes the response from the server. /// /// - Returns: The value returned by the response handler closure. /// @@ -54,10 +53,14 @@ public protocol HTTPClient: Sendable, ~Copyable, ~Escapable { #if compiler(<6.3) @_lifetime(&self) #endif - mutating func perform( + mutating func perform( request: HTTPRequest, body: consuming HTTPClientRequestBody?, options: RequestOptions, - responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return + responseHandler: consuming ResponseHandler ) async throws -> Return + where + ResponseHandler.ResponseConcludingReader: ~Copyable, + ResponseHandler.ResponseConcludingReader == ResponseConcludingReader, + ResponseHandler.Return == Return } diff --git a/Sources/HTTPAPIs/Client/HTTPClientClosureResponseHandler.swift b/Sources/HTTPAPIs/Client/HTTPClientClosureResponseHandler.swift new file mode 100644 index 0000000..03b92b4 --- /dev/null +++ b/Sources/HTTPAPIs/Client/HTTPClientClosureResponseHandler.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public struct HTTPClientClosureResponseHandler: HTTPClientResponseHandler, ~Copyable +where + ResponseConcludingReader: ConcludingAsyncReader & ~Copyable & SendableMetatype, + ResponseConcludingReader.Underlying: ~Copyable, + ResponseConcludingReader.Underlying.ReadElement == UInt8, + ResponseConcludingReader.FinalElement == HTTPFields? +{ + private let handler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return + + public init(handler: @escaping (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return) { + self.handler = handler + } + + public func handleInformational(response: HTTPResponse) async throws { + } + + public func handle(response: HTTPResponse, responseBodyAndTrailers: consuming ResponseConcludingReader) async throws -> Return { + try await self.handler(response, responseBodyAndTrailers) + } +} diff --git a/Sources/HTTPAPIs/Client/HTTPClientResponseHandler.swift b/Sources/HTTPAPIs/Client/HTTPClientResponseHandler.swift new file mode 100644 index 0000000..c4b5fc8 --- /dev/null +++ b/Sources/HTTPAPIs/Client/HTTPClientResponseHandler.swift @@ -0,0 +1,26 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public protocol HTTPClientResponseHandler: ~Copyable { + associatedtype ResponseConcludingReader: ConcludingAsyncReader, ~Copyable, SendableMetatype + where + ResponseConcludingReader.Underlying: ~Copyable, + ResponseConcludingReader.Underlying.ReadElement == UInt8, + ResponseConcludingReader.FinalElement == HTTPFields? + associatedtype Return: ~Copyable + + func handleInformational(response: HTTPResponse) async throws + + func handle(response: HTTPResponse, responseBodyAndTrailers: consuming ResponseConcludingReader) async throws -> Return +} diff --git a/Sources/HTTPAPIs/Client/HTTPClientTransformedResponseHandler.swift b/Sources/HTTPAPIs/Client/HTTPClientTransformedResponseHandler.swift new file mode 100644 index 0000000..5884f53 --- /dev/null +++ b/Sources/HTTPAPIs/Client/HTTPClientTransformedResponseHandler.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +package struct HTTPClientTransformedResponseHandler: + HTTPClientResponseHandler, ~Copyable +where + OtherHandler.ResponseConcludingReader: ~Copyable, + OtherHandler.ResponseConcludingReader.Underlying: ~Copyable, + OtherHandler.Return: ~Copyable, + ResponseConcludingReader: ConcludingAsyncReader & ~Copyable & SendableMetatype, + ResponseConcludingReader.Underlying: ~Copyable, + ResponseConcludingReader.Underlying.ReadElement == UInt8, + ResponseConcludingReader.FinalElement == HTTPFields? +{ + package typealias Return = OtherHandler.Return + + private let other: OtherHandler + private let transform: @Sendable (consuming ResponseConcludingReader) -> OtherHandler.ResponseConcludingReader + + package init( + other: consuming OtherHandler, + transform: @escaping @Sendable (consuming ResponseConcludingReader) -> OtherHandler.ResponseConcludingReader + ) { + self.other = other + self.transform = transform + } + + package func handleInformational(response: HTTPResponse) async throws { + try await self.other.handleInformational(response: response) + } + + package func handle(response: HTTPResponse, responseBodyAndTrailers: consuming ResponseConcludingReader) async throws -> Return { + try await self.other.handle(response: response, responseBodyAndTrailers: self.transform(responseBodyAndTrailers)) + } +} diff --git a/Sources/HTTPClient/DefaultHTTPClient.swift b/Sources/HTTPClient/DefaultHTTPClient.swift index 29bceec..4387e6b 100644 --- a/Sources/HTTPClient/DefaultHTTPClient.swift +++ b/Sources/HTTPClient/DefaultHTTPClient.swift @@ -131,20 +131,26 @@ public final class DefaultHTTPClient: HTTPAPIs.HTTPClient { .init() } - public func perform( + public func perform( request: HTTPRequest, body: consuming HTTPClientRequestBody?, options: HTTPRequestOptions, - responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return - ) async throws -> Return { + responseHandler: consuming ResponseHandler + ) async throws -> Return + where + ResponseHandler.ResponseConcludingReader: ~Copyable, + ResponseHandler.ResponseConcludingReader == ResponseConcludingReader, + ResponseHandler.Return == Return + { // TODO: translate request options let options = self.client.defaultRequestOptions let body = body.map { HTTPClientRequestBody(other: $0) { RequestWriter(actual: $0) } } - return try await self.client.perform(request: request, body: body, options: options) { response, body in - try await responseHandler(response, ResponseConcludingReader(actual: body)) + let responseHandler = HTTPClientTransformedResponseHandler(other: responseHandler) { + ResponseConcludingReader(actual: $0) } + return try await self.client.perform(request: request, body: body, options: options, responseHandler: responseHandler) } } diff --git a/Sources/HTTPClient/HTTP+Conveniences.swift b/Sources/HTTPClient/HTTP+Conveniences.swift index e01a8c8..dd82885 100644 --- a/Sources/HTTPClient/HTTP+Conveniences.swift +++ b/Sources/HTTPClient/HTTP+Conveniences.swift @@ -42,9 +42,14 @@ extension HTTP { body: consuming HTTPClientRequestBody? = nil, options: HTTPRequestOptions = .init(), on client: DefaultHTTPClient = .shared, - responseHandler: (HTTPResponse, consuming DefaultHTTPClient.ResponseConcludingReader) async throws -> Return, + responseHandler: @escaping (HTTPResponse, consuming DefaultHTTPClient.ResponseConcludingReader) async throws -> Return, ) async throws -> Return { - try await client.perform(request: request, body: body, options: options, responseHandler: responseHandler) + try await client.perform( + request: request, + body: body, + options: options, + responseHandler: HTTPClientClosureResponseHandler(handler: responseHandler) + ) } /// Performs an HTTP GET request and collects the response body. diff --git a/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift b/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift index 499e462..259801a 100644 --- a/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift +++ b/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift @@ -296,12 +296,17 @@ public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { .init() } - public func perform( + public func perform( request: HTTPRequest, body: consuming HTTPClientRequestBody?, - options: URLSessionRequestOptions, - responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return - ) async throws -> Return { + options: RequestOptions, + responseHandler: consuming ResponseHandler + ) async throws -> Return + where + ResponseHandler.ResponseConcludingReader: ~Copyable, + ResponseHandler.ResponseConcludingReader == ResponseConcludingReader, + ResponseHandler.Return == Return + { guard request.schemeSupported else { throw HTTPTypeConversionError.unsupportedScheme } @@ -329,7 +334,7 @@ public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { guard let response = (response as? HTTPURLResponse)?.httpResponse else { throw HTTPTypeConversionError.failedToConvertURLTypeToHTTPTypes } - result = .success(try await responseHandler(response, .init(actual: delegateBridge))) + result = .success(try await responseHandler.handle(response: response, responseBodyAndTrailers: .init(actual: delegateBridge))) } catch { result = .failure(error) } diff --git a/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift b/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift index bd0e98b..8451291 100644 --- a/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift +++ b/Tests/HTTPAPIsTests/Helpers/HTTPClientAndServerTests.swift @@ -136,12 +136,17 @@ final class TestClientAndServer: HTTPClient, HTTPServer { .init() } - func perform( + func perform( request: HTTPRequest, - body: consuming HTTPClientRequestBody?, + body: consuming HTTPClientRequestBody?, options: RequestOptions, - responseHandler: (HTTPResponse, consuming AsyncChannelConcludingAsyncReader) async throws -> Return - ) async throws -> Return { + responseHandler: consuming ResponseHandler + ) async throws -> Return + where + ResponseHandler.ResponseConcludingReader: ~Copyable, + ResponseHandler.ResponseConcludingReader == ResponseConcludingReader, + ResponseHandler.Return == Return + { let response = try await withCheckedThrowingContinuation { continuation in self.requests.withLock { requests in requests.append( @@ -156,10 +161,10 @@ final class TestClientAndServer: HTTPClient, HTTPServer { self.continuation.yield() } - return try await responseHandler( - response.response, + return try await responseHandler.handle( + response: response.response, // Needed since we are lacking call-once closures - response.takeResponseReader() + responseBodyAndTrailers: response.takeResponseReader() ) } From 6ccc30c2a8bcbbfa4a958a513422adc311066ac8 Mon Sep 17 00:00:00 2001 From: Guoye Zhang Date: Wed, 13 May 2026 14:59:54 -0700 Subject: [PATCH 2/2] Add informational response support to URLSession --- .../URLSessionHTTPClient.swift | 2 +- .../URLSessionTaskDelegateBridge.swift | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift b/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift index 259801a..269971d 100644 --- a/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift +++ b/Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift @@ -330,7 +330,7 @@ public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { var result: Result? = nil try await withTaskCancellationHandler { do { - let response = try await delegateBridge.processDelegateCallbacksBeforeResponse(options) + let response = try await delegateBridge.processDelegateCallbacksBeforeResponse(options, responseHandler) guard let response = (response as? HTTPURLResponse)?.httpResponse else { throw HTTPTypeConversionError.failedToConvertURLTypeToHTTPTypes } diff --git a/Sources/URLSessionHTTPClient/URLSessionTaskDelegateBridge.swift b/Sources/URLSessionHTTPClient/URLSessionTaskDelegateBridge.swift index a52f824..a08a15f 100644 --- a/Sources/URLSessionHTTPClient/URLSessionTaskDelegateBridge.swift +++ b/Sources/URLSessionHTTPClient/URLSessionTaskDelegateBridge.swift @@ -20,6 +20,7 @@ import Synchronization @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) final class URLSessionTaskDelegateBridge: NSObject, Sendable, URLSessionDataDelegate { private enum Callback: Sendable { + case informationalResponse(HTTPURLResponse) case response(URLResponse) case redirection( response: HTTPURLResponse, @@ -307,6 +308,10 @@ final class URLSessionTaskDelegateBridge: NSObject, Sendable, URLSessionDataDele // MARK: - Events + func urlSession(_ session: URLSession, task: URLSessionTask, didReceiveInformationalResponse response: HTTPURLResponse) { + self.continuation.yield(.informationalResponse(response)) + } + func urlSession( _ session: URLSession, task: URLSessionTask, @@ -346,9 +351,14 @@ final class URLSessionTaskDelegateBridge: NSObject, Sendable, URLSessionDataDele self.continuation.yield(.error(error)) } - func processDelegateCallbacksBeforeResponse(_ options: URLSessionRequestOptions) async throws -> URLResponse { + func processDelegateCallbacksBeforeResponse(_ options: URLSessionRequestOptions, _ responseHandler: borrowing some HTTPClientResponseHandler & ~Copyable) async throws -> URLResponse { for await callback in self.stream { switch callback { + case .informationalResponse(let response): + guard let httpResponse = response.httpResponse else { + throw HTTPTypeConversionError.failedToConvertURLTypeToHTTPTypes + } + try await responseHandler.handleInformational(response: httpResponse) case .response(let response): return response case .redirection(let response, let request, let completionHandler): @@ -439,7 +449,7 @@ final class URLSessionTaskDelegateBridge: NSObject, Sendable, URLSessionDataDele for await callback in self.stream { switch callback { - case .response: + case .informationalResponse, .response: break case .redirection(_, _, let completionHandler): completionHandler(nil)