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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// This source file is part of the Swift HTTP API Proposal open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift HTTP API Proposal project authors
// 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
Expand All @@ -14,47 +14,35 @@

// We are using exported imports so that developers don't have to
// import multiple modules just to execute an HTTP request
@_exported public import AsyncStreaming
@_exported public import HTTPTypes

/// A protocol that defines the interface for an HTTP client.
/// A protocol that defines the interface for a simple HTTP client.
///
/// ``HTTPClient`` provides asynchronous request execution with streaming request
/// ``HTTPClient`` provides asynchronous request execution with buffered request
/// and response bodies.
@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
public protocol HTTPClient<RequestOptions>: ~Copyable {
public protocol SimpleHTTPClient<RequestOptions>: ~Copyable {
associatedtype RequestOptions: HTTPClientCapability.RequestOptions

/// The type used to write request body data and trailers.
// TODO: Check if we should allow ~Escapable readers https://github.com/apple/swift-http-api-proposal/issues/13
associatedtype RequestWriter: AsyncWriter, ~Copyable, SendableMetatype
where RequestWriter.WriteElement == UInt8

/// The type used to read response body data and trailers.
// TODO: Check if we should allow ~Escapable writers https://github.com/apple/swift-http-api-proposal/issues/13
associatedtype ResponseConcludingReader: ConcludingAsyncReader, ~Copyable, SendableMetatype
where ResponseConcludingReader.Underlying.ReadElement == UInt8, ResponseConcludingReader.FinalElement == HTTPFields?

/// Performs an HTTP request and processes the response.
///
/// This method executes the HTTP request with the specified options, then invokes
/// the response handler when the response header is received. The request and
/// response bodies are streamed using the client's writer and reader types.
/// the response handler when the full response is received.
///
/// - Parameters:
/// - request: The HTTP request header to send.
/// - body: The optional request body to send. When `nil`, no body is sent.
/// - options: The options for this request.
/// - responseHandler: The closure to process the response. This closure is invoked
/// when the response header is received and can read the response body.
/// when the full response is received and can read the response body.
///
/// - Returns: The value returned by the response handler closure.
///
/// - Throws: An error if the request fails or if the response handler throws.
func perform<Return: ~Copyable>(
request: HTTPRequest,
body: consuming HTTPClientRequestBody<RequestWriter>?,
body: Span<UInt8>?,
options: RequestOptions,
responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return
responseHandler: (HTTPResponse, Span<UInt8>) async throws -> Return
) async throws -> Return
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
//===----------------------------------------------------------------------===//

@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
extension HTTPClient {
extension StreamingHTTPClient {
/// Performs an HTTP request and processes the response.
///
/// This convenience method provides default values for `body` and `options` arguments,
Expand All @@ -31,7 +31,7 @@ extension HTTPClient {
/// - Throws: An error if the request fails or if the response handler throws.
public func perform<Return: ~Copyable>(
request: HTTPRequest,
body: consuming HTTPClientRequestBody<RequestWriter>? = nil,
body: consuming StreamingHTTPClientRequestBody<RequestWriter>? = nil,
options: RequestOptions = .init(),
responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return,
) async throws -> Return {
Expand Down
127 changes: 127 additions & 0 deletions Sources/HTTPAPIs/Client/StreamingHTTPClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift HTTP API Proposal open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift HTTP API Proposal project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift HTTP API Proposal project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

// We are using exported imports so that developers don't have to
// import multiple modules just to execute an HTTP request
@_exported public import AsyncStreaming
@_exported public import HTTPTypes
#if canImport(FoundationEssentials)
internal import FoundationEssentials
#else
internal import Foundation
#endif

/// A protocol that defines the interface for a streaming HTTP client.
///
/// ``HTTPClient`` provides asynchronous request execution with streaming request
/// and response bodies.
@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
public protocol StreamingHTTPClient<RequestOptions>: ~Copyable, SimpleHTTPClient {

/// The type used to write request body data and trailers.
// TODO: Check if we should allow ~Escapable readers https://github.com/apple/swift-http-api-proposal/issues/13
associatedtype RequestWriter: AsyncWriter, ~Copyable, SendableMetatype
where RequestWriter.WriteElement == UInt8

/// The type used to read response body data and trailers.
// TODO: Check if we should allow ~Escapable writers https://github.com/apple/swift-http-api-proposal/issues/13
associatedtype ResponseConcludingReader: ConcludingAsyncReader, ~Copyable, SendableMetatype
where ResponseConcludingReader.Underlying.ReadElement == UInt8, ResponseConcludingReader.FinalElement == HTTPFields?

/// Performs an HTTP request and processes the response.
///
/// This method executes the HTTP request with the specified options, then invokes
/// the response handler when the response header is received. The request and
/// response bodies are streamed using the client's writer and reader types.
///
/// - Parameters:
/// - request: The HTTP request header to send.
/// - body: The optional request body to send. When `nil`, no body is sent.
/// - options: The options for this request.
/// - responseHandler: The closure to process the response. This closure is invoked
/// when the response header is received and can read the response body.
///
/// - Returns: The value returned by the response handler closure.
///
/// - Throws: An error if the request fails or if the response handler throws.
func perform<Return: ~Copyable>(
request: HTTPRequest,
body: consuming StreamingHTTPClientRequestBody<RequestWriter>?,
options: RequestOptions,
responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return
) async throws -> Return
}

@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
extension StreamingHTTPClient {
public func perform<Return: ~Copyable>(
request: HTTPRequest,
body: Span<UInt8>?,
options: RequestOptions,
responseHandler: (HTTPResponse, Span<UInt8>) async throws -> Return
) async throws -> Return {
let _body: StreamingHTTPClientRequestBody<RequestWriter>?
if let body {
_body = .span(body)
} else {
_body = nil
}
// TODO: Should be configured somewhere, possibly on the options.
let limit: Int = 10 * 1024 * 1024
return try await self.perform(
request: request,
body: _body,
options: options,
responseHandler: { response, reader in
let responseBody = try await collectBody(reader, upTo: limit)
return try await responseHandler(response, responseBody.span)
}
)
}

private func collectBody<Reader: ConcludingAsyncReader>(
_ body: consuming Reader,
upTo limit: Int
) async throws -> Data
where Reader: ~Copyable, Reader.Underlying.ReadElement == UInt8 {
try await body.collect(upTo: limit == .max ? .max : limit + 1) {
if $0.count > limit {
throw LengthLimitExceededError()
}
return unsafe $0.withUnsafeBytes { unsafe Data($0) }
}.0
}
}

private struct LengthLimitExceededError: Error {}

@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
extension StreamingHTTPClientRequestBody where Writer: ~Copyable {
/// Creates a seekable request body from a span.
///
/// - Parameter data: The bytes to send as the request body.
internal static func span(_ span: Span<UInt8>) -> Self {
var data = Data()
data.reserveCapacity(span.count)
for index in span.indices {
data.append(span[index])
}
let _data = data
return .seekable(knownLength: Int64(span.count)) { offset, writer in
var writer = writer
try await writer.write(_data.span.extracting(droppingFirst: Int(offset)))
return nil
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import AsyncStreaming
/// }
/// ```
@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
public struct HTTPClientRequestBody<Writer: AsyncWriter & ~Copyable>: Sendable
public struct StreamingHTTPClientRequestBody<Writer: AsyncWriter & ~Copyable>: Sendable
where Writer.WriteElement == UInt8, Writer: SendableMetatype {
/// The body can be asked to restart writing from an arbitrary offset.
public var isSeekable: Bool {
Expand Down Expand Up @@ -154,7 +154,7 @@ where Writer.WriteElement == UInt8, Writer: SendableMetatype {
}

package init<OtherWriter: ~Copyable>(
other: HTTPClientRequestBody<OtherWriter>,
other: StreamingHTTPClientRequestBody<OtherWriter>,
transform: @escaping @Sendable (consuming Writer) -> OtherWriter
) {
self.knownLength = other.knownLength
Expand Down
4 changes: 2 additions & 2 deletions Sources/HTTPClient/HTTP+Conveniences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ extension HTTP {
///
/// - Throws: An error if the request fails or if the response handler throws.
@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
public static func perform<Client: HTTPClient, Return: ~Copyable>(
public static func perform<Client: StreamingHTTPClient, Return: ~Copyable>(
request: HTTPRequest,
body: consuming HTTPClientRequestBody<Client.RequestWriter>? = nil,
body: consuming StreamingHTTPClientRequestBody<Client.RequestWriter>? = nil,
options: Client.RequestOptions = .init(),
on client: Client = HTTPConnectionPool.shared,
responseHandler: (HTTPResponse, consuming Client.ResponseConcludingReader) async throws -> Return,
Expand Down
6 changes: 3 additions & 3 deletions Sources/HTTPClient/HTTPConnectionPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public struct HTTPConnectionPoolConfiguration: Hashable, Sendable {
/// connections across multiple requests. It supports HTTP/1.1, HTTP/2, and HTTP/3 protocols,
/// automatically handling connection management, protocol negotiation, and resource cleanup.
@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
public final class HTTPConnectionPool: HTTPClient, Sendable {
public final class HTTPConnectionPool: StreamingHTTPClient, Sendable {
public struct RequestWriter: AsyncWriter, ~Copyable {
public mutating func write<Result, Failure>(
_ body: (inout OutputSpan<UInt8>) async throws(Failure) -> Result
Expand Down Expand Up @@ -122,13 +122,13 @@ public final class HTTPConnectionPool: HTTPClient, Sendable {

public func perform<Return: ~Copyable>(
request: HTTPRequest,
body: consuming HTTPClientRequestBody<RequestWriter>?,
body: consuming StreamingHTTPClientRequestBody<RequestWriter>?,
options: HTTPRequestOptions,
responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return
) async throws -> Return {
#if canImport(Darwin)
let body = body.map {
HTTPClientRequestBody<URLSessionHTTPClient.RequestWriter>(other: $0, transform: RequestWriter.init)
StreamingHTTPClientRequestBody<URLSessionHTTPClient.RequestWriter>(other: $0, transform: RequestWriter.init)
}
return try await self.client.perform(request: request, body: body, options: options) { response, body in
try await responseHandler(response, ResponseConcludingReader(actual: body))
Expand Down
4 changes: 2 additions & 2 deletions Sources/HTTPClient/URLSession/URLSessionHTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import NetworkTypes
import Synchronization

@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
final class URLSessionHTTPClient: HTTPClient, Sendable {
final class URLSessionHTTPClient: StreamingHTTPClient, Sendable {
typealias RequestWriter = URLSessionRequestStreamBridge
typealias ResponseConcludingReader = URLSessionTaskDelegateBridge

Expand Down Expand Up @@ -81,7 +81,7 @@ final class URLSessionHTTPClient: HTTPClient, Sendable {

func perform<Return: ~Copyable>(
request: HTTPRequest,
body: consuming HTTPClientRequestBody<RequestWriter>?,
body: consuming StreamingHTTPClientRequestBody<RequestWriter>?,
options: HTTPRequestOptions,
responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return
) async throws -> Return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ final class URLSessionTaskDelegateBridge: NSObject, Sendable, URLSessionDataDele
// limits.
private let stream: AsyncStream<Callback>
private let continuation: AsyncStream<Callback>.Continuation
private let requestBody: HTTPClientRequestBody<URLSessionRequestStreamBridge>?
private let requestBody: StreamingHTTPClientRequestBody<URLSessionRequestStreamBridge>?
// TODO: Can we get rid of this task and instead use on task group per client?
private let requestBodyTask: Mutex<Task<Void, Never>?> = .init(nil)

init(task: URLSessionTask, body: consuming HTTPClientRequestBody<URLSessionRequestStreamBridge>?) {
init(task: URLSessionTask, body: consuming StreamingHTTPClientRequestBody<URLSessionRequestStreamBridge>?) {
self.task = task
var continuation: AsyncStream<Callback>.Continuation?
self.stream = AsyncStream { continuation = $0 }
Expand Down
Loading