Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions Examples/MiddlewareClient/ExampleMiddlewareClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,27 @@ struct ExampleMiddlewareClient<Client: HTTPClient & ~Copyable, ClientMiddleware:
self.middleware = middlewareBuilder(RequestMiddleware<Client>())
}

mutating func perform<Return: ~Copyable>(
mutating func perform<ResponseHandler: HTTPClientResponseHandler & ~Copyable, Return: ~Copyable>(
request: HTTPRequest,
body: consuming HTTPClientRequestBody<RequestWriter>?,
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
try await self.client.perform(
request: request,
body: body.take()!,
options: options,
responseHandler: responseHandler
responseHandler: responseHandler.take()!
)
}
}
Expand Down
15 changes: 10 additions & 5 deletions Sources/AHCHTTPClient/AHC+HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,17 @@ extension AsyncHTTPClient.HTTPClient: HTTPAPIs.HTTPClient {
RequestOptions()
}

public func perform<Return: ~Copyable>(
public func perform<ResponseHandler: HTTPClientResponseHandler & ~Copyable, Return: ~Copyable>(
request: HTTPRequest,
body: consuming HTTPClientRequestBody<RequestBodyWriter>?,
body: consuming HTTPClientRequestBody<RequestWriter>?,
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()
}
Expand Down Expand Up @@ -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)
}
Expand Down
21 changes: 13 additions & 8 deletions Sources/FetchHTTPClient/FetchHTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,17 @@ public final class FetchHTTPClient: HTTPAPIs.HTTPClient {

public init() {}

public func perform<Return>(
request: HTTPTypes.HTTPRequest,
body: consuming HTTPAPIs.HTTPClientRequestBody<RequestBodyWriter>?,
public func perform<ResponseHandler: HTTPClientResponseHandler & ~Copyable, Return: ~Copyable>(
request: HTTPRequest,
body: consuming HTTPClientRequestBody<RequestWriter>?,
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
}
Expand Down Expand Up @@ -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)
)
}

Expand Down
9 changes: 7 additions & 2 deletions Sources/HTTPAPIs/Client/HTTPClient+Conveniences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,15 @@ where
request: HTTPRequest,
body: consuming HTTPClientRequestBody<RequestWriter>? = 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.
Expand Down
11 changes: 7 additions & 4 deletions Sources/HTTPAPIs/Client/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,22 @@ public protocol HTTPClient<RequestOptions>: 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.
///
/// - Throws: An error if the request fails or if the response handler throws.
#if compiler(<6.3)
@_lifetime(&self)
#endif
mutating func perform<Return: ~Copyable>(
mutating func perform<ResponseHandler: HTTPClientResponseHandler & ~Copyable, Return: ~Copyable>(
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was not able to get it to work with primary associated types due to SuppressedAssociatedTypesWithDefaults requiring ~Copyable for ResponseHandler.ResponseConcludingReader

request: HTTPRequest,
body: consuming HTTPClientRequestBody<RequestWriter>?,
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
}
34 changes: 34 additions & 0 deletions Sources/HTTPAPIs/Client/HTTPClientClosureResponseHandler.swift
Original file line number Diff line number Diff line change
@@ -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<ResponseConcludingReader, Return: ~Copyable>: 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)
}
}
26 changes: 26 additions & 0 deletions Sources/HTTPAPIs/Client/HTTPClientResponseHandler.swift
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do we expect a user to do in here? Is this just for them to observe it? Can they take any action on an informational response?


func handle(response: HTTPResponse, responseBodyAndTrailers: consuming ResponseConcludingReader) async throws -> Return
}
46 changes: 46 additions & 0 deletions Sources/HTTPAPIs/Client/HTTPClientTransformedResponseHandler.swift
Original file line number Diff line number Diff line change
@@ -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<OtherHandler: HTTPClientResponseHandler & ~Copyable, ResponseConcludingReader>:
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))
}
}
16 changes: 11 additions & 5 deletions Sources/HTTPClient/DefaultHTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,20 +131,26 @@ public final class DefaultHTTPClient: HTTPAPIs.HTTPClient {
.init()
}

public func perform<Return: ~Copyable>(
public func perform<ResponseHandler: HTTPClientResponseHandler & ~Copyable, Return: ~Copyable>(
request: HTTPRequest,
body: consuming HTTPClientRequestBody<RequestWriter>?,
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<ActualHTTPClient.RequestWriter>(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)
}
}

Expand Down
9 changes: 7 additions & 2 deletions Sources/HTTPClient/HTTP+Conveniences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,14 @@ extension HTTP {
body: consuming HTTPClientRequestBody<DefaultHTTPClient.RequestWriter>? = 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.
Expand Down
17 changes: 11 additions & 6 deletions Sources/URLSessionHTTPClient/URLSessionHTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -296,12 +296,17 @@ public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider {
.init()
}

public func perform<Return: ~Copyable>(
public func perform<ResponseHandler: HTTPClientResponseHandler & ~Copyable, Return: ~Copyable>(
request: HTTPRequest,
body: consuming HTTPClientRequestBody<RequestWriter>?,
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
}
Expand All @@ -325,11 +330,11 @@ public final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider {
var result: Result<Return, any Error>? = 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
}
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)
}
Expand Down
14 changes: 12 additions & 2 deletions Sources/URLSessionHTTPClient/URLSessionTaskDelegateBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading