mirror of
https://github.com/swift-server/async-http-client.git
synced 2026-05-03 07:32:29 +00:00
Merge branch 'main' into ff-new-http-client-api
This commit is contained in:
@@ -38,3 +38,32 @@ jobs:
|
||||
release-builds:
|
||||
name: Release builds
|
||||
uses: apple/swift-nio/.github/workflows/release_builds.yml@main
|
||||
|
||||
construct-linkage-test-matrix:
|
||||
name: Construct linkage matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
linkage-test-matrix: '${{ steps.generate-matrix.outputs.linkage-test-matrix }}'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- id: generate-matrix
|
||||
run: echo "linkage-test-matrix=$(curl -s https://raw.githubusercontent.com/apple/swift-nio/main/scripts/generate_matrix.sh | bash)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
MATRIX_LINUX_SETUP_COMMAND: apt-get update -y && apt-get install -yq jq && git config --global --add safe.directory /async-http-client
|
||||
MATRIX_LINUX_COMMAND: ./scripts/run-linkage-test.sh
|
||||
MATRIX_LINUX_5_10_ENABLED: false
|
||||
MATRIX_LINUX_6_0_ENABLED: false
|
||||
MATRIX_LINUX_6_1_ENABLED: false
|
||||
MATRIX_LINUX_NIGHTLY_NEXT_ENABLED: false
|
||||
MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false
|
||||
|
||||
linkage-test:
|
||||
name: Linkage test
|
||||
needs: construct-linkage-test-matrix
|
||||
uses: apple/swift-nio/.github/workflows/swift_test_matrix.yml@main
|
||||
with:
|
||||
name: "Linkage test"
|
||||
matrix_string: '${{ needs.construct-linkage-test-matrix.outputs.linkage-test-matrix }}'
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
timeout-minutes: 1
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for Semantic Version label
|
||||
|
||||
+7
-2
@@ -56,7 +56,8 @@ let package = Package(
|
||||
.package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.2"),
|
||||
.package(url: "https://github.com/apple/swift-algorithms.git", from: "1.0.0"),
|
||||
.package(url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.3.0"),
|
||||
.package(url: "https://github.com/apple/swift-configuration.git", from: "1.0.0"),
|
||||
// Disable all traits to prevent linking Foundation
|
||||
.package(url: "https://github.com/apple/swift-configuration.git", from: "1.0.0", traits: []),
|
||||
.package(url: "https://github.com/apple/swift-service-context.git", from: "1.1.0"),
|
||||
.package(
|
||||
url: "https://github.com/apple/swift-http-api-proposal.git",
|
||||
@@ -84,7 +85,11 @@ let package = Package(
|
||||
.product(name: "NIOSSL", package: "swift-nio-ssl"),
|
||||
.product(name: "NIOHTTPCompression", package: "swift-nio-extras"),
|
||||
.product(name: "NIOSOCKS", package: "swift-nio-extras"),
|
||||
.product(name: "NIOTransportServices", package: "swift-nio-transport-services"),
|
||||
.product(
|
||||
name: "NIOTransportServices",
|
||||
package: "swift-nio-transport-services",
|
||||
condition: .when(platforms: [.macOS, .iOS, .tvOS, .watchOS, .macCatalyst, .visionOS])
|
||||
),
|
||||
.product(name: "Atomics", package: "swift-atomics"),
|
||||
.product(name: "Algorithms", package: "swift-algorithms"),
|
||||
.product(name: "Configuration", package: "swift-configuration"),
|
||||
|
||||
@@ -17,7 +17,11 @@ import NIOCore
|
||||
import NIOHTTP1
|
||||
import Tracing
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import struct FoundationEssentials.URL
|
||||
#else
|
||||
import struct Foundation.URL
|
||||
#endif
|
||||
|
||||
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
|
||||
extension HTTPClient {
|
||||
@@ -96,6 +100,7 @@ extension HTTPClient {
|
||||
try HTTPClientRequest.Prepared(
|
||||
currentRequest,
|
||||
dnsOverride: configuration.dnsOverride,
|
||||
localAddress: configuration.localAddress,
|
||||
tracing: self.configuration.tracing
|
||||
)
|
||||
let response = try await {
|
||||
|
||||
@@ -18,7 +18,11 @@ import NIOHTTP1
|
||||
import NIOSSL
|
||||
import ServiceContextModule
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import struct FoundationEssentials.URL
|
||||
#else
|
||||
import struct Foundation.URL
|
||||
#endif
|
||||
|
||||
#if canImport(HTTPAPIs)
|
||||
import HTTPAPIs
|
||||
@@ -58,6 +62,7 @@ extension HTTPClientRequest.Prepared {
|
||||
init(
|
||||
_ request: HTTPClientRequest,
|
||||
dnsOverride: [String: String] = [:],
|
||||
localAddress: String? = nil,
|
||||
tracing: HTTPClient.TracingConfiguration? = nil
|
||||
) throws {
|
||||
guard !request.url.isEmpty, let url = URL(string: request.url) else {
|
||||
@@ -81,7 +86,12 @@ extension HTTPClientRequest.Prepared {
|
||||
|
||||
self.init(
|
||||
url: url,
|
||||
poolKey: .init(url: deconstructedURL, tlsConfiguration: request.tlsConfiguration, dnsOverride: dnsOverride),
|
||||
poolKey: .init(
|
||||
url: deconstructedURL,
|
||||
tlsConfiguration: request.tlsConfiguration,
|
||||
dnsOverride: dnsOverride,
|
||||
localAddress: request.localAddress ?? localAddress
|
||||
),
|
||||
requestFramingMetadata: metadata,
|
||||
head: .init(
|
||||
version: .http1_1,
|
||||
@@ -156,6 +166,7 @@ extension HTTPClientRequest {
|
||||
newRequest.method = method
|
||||
newRequest.headers = headers
|
||||
newRequest.body = body
|
||||
newRequest.localAddress = self.localAddress
|
||||
return newRequest
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
//
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
|
||||
extension HTTPClientRequest {
|
||||
|
||||
@@ -57,12 +57,20 @@ public struct HTTPClientRequest: Sendable {
|
||||
/// Request-specific TLS configuration, defaults to no request-specific TLS configuration.
|
||||
public var tlsConfiguration: TLSConfiguration?
|
||||
|
||||
/// The local IP address to bind this request's connection to.
|
||||
///
|
||||
/// When set, overrides ``HTTPClient/Configuration/localAddress`` for this request.
|
||||
/// The value should be an IP address string (e.g. `"192.168.1.10"` or `"::1"`).
|
||||
/// Defaults to `nil` (use client configuration default).
|
||||
public var localAddress: String?
|
||||
|
||||
public init(url: String) {
|
||||
self.url = url
|
||||
self.method = .GET
|
||||
self.headers = .init()
|
||||
self.body = .none
|
||||
self.tlsConfiguration = nil
|
||||
self.localAddress = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
import NIOCore
|
||||
import NIOHTTP1
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import struct FoundationEssentials.URL
|
||||
#else
|
||||
import struct Foundation.URL
|
||||
#endif
|
||||
|
||||
/// A representation of an HTTP response for the Swift Concurrency HTTPClient API.
|
||||
///
|
||||
|
||||
@@ -12,9 +12,14 @@
|
||||
//
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
import NIOHTTP1
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
/// Generates base64 encoded username + password for http basic auth.
|
||||
///
|
||||
/// - Parameters:
|
||||
|
||||
@@ -49,17 +49,20 @@ enum ConnectionPool {
|
||||
var connectionTarget: ConnectionTarget
|
||||
private var tlsConfiguration: BestEffortHashableTLSConfiguration?
|
||||
var serverNameIndicatorOverride: String?
|
||||
var localAddress: String?
|
||||
|
||||
init(
|
||||
scheme: Scheme,
|
||||
connectionTarget: ConnectionTarget,
|
||||
tlsConfiguration: BestEffortHashableTLSConfiguration? = nil,
|
||||
serverNameIndicatorOverride: String?
|
||||
serverNameIndicatorOverride: String?,
|
||||
localAddress: String? = nil
|
||||
) {
|
||||
self.scheme = scheme
|
||||
self.connectionTarget = connectionTarget
|
||||
self.tlsConfiguration = tlsConfiguration
|
||||
self.serverNameIndicatorOverride = serverNameIndicatorOverride
|
||||
self.localAddress = localAddress
|
||||
}
|
||||
|
||||
var description: String {
|
||||
@@ -75,8 +78,12 @@ enum ConnectionPool {
|
||||
case .unixSocket(let socketPath):
|
||||
hostDescription = socketPath
|
||||
}
|
||||
return
|
||||
var result =
|
||||
"\(self.scheme)://\(hostDescription)\(self.serverNameIndicatorOverride.map { " SNI: \($0)" } ?? "") TLS-hash: \(hash)"
|
||||
if let addr = self.localAddress {
|
||||
result += " bind: \(addr)"
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,7 +104,12 @@ extension DeconstructedURL {
|
||||
}
|
||||
|
||||
extension ConnectionPool.Key {
|
||||
init(url: DeconstructedURL, tlsConfiguration: TLSConfiguration?, dnsOverride: [String: String]) {
|
||||
init(
|
||||
url: DeconstructedURL,
|
||||
tlsConfiguration: TLSConfiguration?,
|
||||
dnsOverride: [String: String],
|
||||
localAddress: String? = nil
|
||||
) {
|
||||
let (connectionTarget, serverNameIndicatorOverride) = url.applyDNSOverride(dnsOverride)
|
||||
self.init(
|
||||
scheme: url.scheme,
|
||||
@@ -105,15 +117,17 @@ extension ConnectionPool.Key {
|
||||
tlsConfiguration: tlsConfiguration.map {
|
||||
BestEffortHashableTLSConfiguration(wrapping: $0)
|
||||
},
|
||||
serverNameIndicatorOverride: serverNameIndicatorOverride
|
||||
serverNameIndicatorOverride: serverNameIndicatorOverride,
|
||||
localAddress: localAddress
|
||||
)
|
||||
}
|
||||
|
||||
init(_ request: HTTPClient.Request, dnsOverride: [String: String] = [:]) {
|
||||
init(_ request: HTTPClient.Request, dnsOverride: [String: String] = [:], localAddress: String? = nil) {
|
||||
self.init(
|
||||
url: request.deconstructedURL,
|
||||
tlsConfiguration: request.tlsConfiguration,
|
||||
dnsOverride: dnsOverride
|
||||
dnsOverride: dnsOverride,
|
||||
localAddress: localAddress
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,24 +243,32 @@ final class HTTP1ClientChannelHandler: ChannelDuplexHandler {
|
||||
context.writeAndFlush(self.wrapOutboundOut(.body(part)), promise: writePromise)
|
||||
|
||||
case .sendRequestEnd(let trailers, let writePromise, let finalAction):
|
||||
|
||||
let writePromise = writePromise ?? context.eventLoop.makePromise(of: Void.self)
|
||||
// We need to defer succeeding the old request to avoid ordering issues
|
||||
let writePromise = writePromise ?? context.eventLoop.makePromise(of: Void.self)
|
||||
// It is fine to bang the request here, as we have just verified with the state machine
|
||||
// that the request is still ongoing.
|
||||
// TODO: In the future, we should likely move the request into the state machine to
|
||||
// prevent diverging state.
|
||||
let oldRequest = self.request!
|
||||
|
||||
switch finalAction {
|
||||
case .none:
|
||||
// we must not nil out the request here, as we are still uploading the request
|
||||
// and therefore still need the reference to it.
|
||||
break
|
||||
case .informConnectionIsIdle:
|
||||
self.request = nil
|
||||
case .close:
|
||||
self.request = nil
|
||||
}
|
||||
|
||||
writePromise.futureResult.hop(to: context.eventLoop).assumeIsolated().whenComplete { result in
|
||||
guard let oldRequest = self.request else {
|
||||
// in the meantime an error might have happened, which is why this request is
|
||||
// not reference anymore.
|
||||
return
|
||||
}
|
||||
oldRequest.requestBodyStreamSent()
|
||||
switch result {
|
||||
case .success:
|
||||
// If our final action is not `none`, that means we've already received
|
||||
// the complete response. As a result, once we've uploaded all the body parts
|
||||
// we need to tell the pool that the connection is idle or, if we were asked to
|
||||
// close when we're done, send the close. Either way, we then succeed the request
|
||||
|
||||
switch finalAction {
|
||||
case .none:
|
||||
// we must not nil out the request here, as we are still uploading the request
|
||||
@@ -268,13 +276,12 @@ final class HTTP1ClientChannelHandler: ChannelDuplexHandler {
|
||||
break
|
||||
|
||||
case .informConnectionIsIdle:
|
||||
self.request = nil
|
||||
self.onConnectionIdle()
|
||||
|
||||
case .close:
|
||||
self.request = nil
|
||||
context.close(promise: nil)
|
||||
}
|
||||
oldRequest.requestBodyStreamSent()
|
||||
|
||||
case .failure(let error):
|
||||
context.close(promise: nil)
|
||||
|
||||
@@ -245,14 +245,19 @@ extension HTTPConnectionPool.ConnectionFactory {
|
||||
promise: EventLoopPromise<NegotiatedProtocol>
|
||||
) {
|
||||
precondition(!self.key.scheme.usesTLS, "Unexpected scheme")
|
||||
return self.makePlainBootstrap(
|
||||
requester: requester,
|
||||
connectionID: connectionID,
|
||||
deadline: deadline,
|
||||
eventLoop: eventLoop
|
||||
).connect(target: self.key.connectionTarget).map {
|
||||
.http1_1($0)
|
||||
}.cascade(to: promise)
|
||||
do {
|
||||
let bootstrap = try self.makePlainBootstrap(
|
||||
requester: requester,
|
||||
connectionID: connectionID,
|
||||
deadline: deadline,
|
||||
eventLoop: eventLoop
|
||||
)
|
||||
bootstrap.connect(target: self.key.connectionTarget).map {
|
||||
.http1_1($0)
|
||||
}.cascade(to: promise)
|
||||
} catch {
|
||||
promise.fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeHTTPProxyChannel<Requester: HTTPConnectionRequester>(
|
||||
@@ -267,12 +272,18 @@ extension HTTPConnectionPool.ConnectionFactory {
|
||||
// A proxy connection starts with a plain text connection to the proxy server. After
|
||||
// the connection has been established with the proxy server, the connection might be
|
||||
// upgraded to TLS before we send our first request.
|
||||
let bootstrap = self.makePlainBootstrap(
|
||||
requester: requester,
|
||||
connectionID: connectionID,
|
||||
deadline: deadline,
|
||||
eventLoop: eventLoop
|
||||
)
|
||||
let bootstrap: NIOClientTCPBootstrapProtocol
|
||||
do {
|
||||
bootstrap = try self.makePlainBootstrap(
|
||||
requester: requester,
|
||||
connectionID: connectionID,
|
||||
deadline: deadline,
|
||||
eventLoop: eventLoop
|
||||
)
|
||||
} catch {
|
||||
promise.fail(error)
|
||||
return
|
||||
}
|
||||
bootstrap.connect(host: proxy.host, port: proxy.port).whenComplete { result in
|
||||
switch result {
|
||||
case .success(let channel):
|
||||
@@ -321,12 +332,18 @@ extension HTTPConnectionPool.ConnectionFactory {
|
||||
// A proxy connection starts with a plain text connection to the proxy server. After
|
||||
// the connection has been established with the proxy server, the connection might be
|
||||
// upgraded to TLS before we send our first request.
|
||||
let bootstrap = self.makePlainBootstrap(
|
||||
requester: requester,
|
||||
connectionID: connectionID,
|
||||
deadline: deadline,
|
||||
eventLoop: eventLoop
|
||||
)
|
||||
let bootstrap: NIOClientTCPBootstrapProtocol
|
||||
do {
|
||||
bootstrap = try self.makePlainBootstrap(
|
||||
requester: requester,
|
||||
connectionID: connectionID,
|
||||
deadline: deadline,
|
||||
eventLoop: eventLoop
|
||||
)
|
||||
} catch {
|
||||
promise.fail(error)
|
||||
return
|
||||
}
|
||||
bootstrap.connect(host: proxy.host, port: proxy.port).whenComplete { result in
|
||||
switch result {
|
||||
case .success(let channel):
|
||||
@@ -421,12 +438,16 @@ extension HTTPConnectionPool.ConnectionFactory {
|
||||
connectionID: HTTPConnectionPool.Connection.ID,
|
||||
deadline: NIODeadline,
|
||||
eventLoop: EventLoop
|
||||
) -> NIOClientTCPBootstrapProtocol {
|
||||
) throws -> NIOClientTCPBootstrapProtocol {
|
||||
if let localAddress = self.key.localAddress, !localAddress.isIPAddress {
|
||||
throw HTTPClientError.invalidLocalAddress
|
||||
}
|
||||
|
||||
#if canImport(Network)
|
||||
if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *),
|
||||
let tsBootstrap = NIOTSConnectionBootstrap(validatingGroup: eventLoop)
|
||||
{
|
||||
return
|
||||
var bootstrap =
|
||||
tsBootstrap
|
||||
.channelOption(
|
||||
NIOTSChannelOptions.waitForActivity,
|
||||
@@ -448,14 +469,32 @@ extension HTTPConnectionPool.ConnectionFactory {
|
||||
return channel.eventLoop.makeFailedFuture(error)
|
||||
}
|
||||
}
|
||||
if let localAddress = self.key.localAddress {
|
||||
bootstrap = bootstrap.configureNWParameters { params in
|
||||
params.requiredLocalEndpoint = NWEndpoint.hostPort(
|
||||
host: NWEndpoint.Host(localAddress),
|
||||
port: .any
|
||||
)
|
||||
}
|
||||
}
|
||||
return bootstrap
|
||||
}
|
||||
#endif
|
||||
|
||||
if let nioBootstrap = ClientBootstrap(validatingGroup: eventLoop) {
|
||||
return
|
||||
var bootstrap =
|
||||
nioBootstrap
|
||||
.connectTimeout(deadline - NIODeadline.now())
|
||||
.enableMPTCP(clientConfiguration.enableMultipath)
|
||||
if let localAddress = self.key.localAddress {
|
||||
do {
|
||||
let socketAddress = try SocketAddress(ipAddress: localAddress, port: 0)
|
||||
bootstrap = bootstrap.bind(to: socketAddress)
|
||||
} catch {
|
||||
throw HTTPClientError.invalidLocalAddress
|
||||
}
|
||||
}
|
||||
return bootstrap
|
||||
}
|
||||
|
||||
preconditionFailure("No matching bootstrap found")
|
||||
@@ -523,6 +562,10 @@ extension HTTPConnectionPool.ConnectionFactory {
|
||||
eventLoop: EventLoop,
|
||||
logger: Logger
|
||||
) -> EventLoopFuture<NIOClientTCPBootstrapProtocol> {
|
||||
if let localAddress = self.key.localAddress, !localAddress.isIPAddress {
|
||||
return eventLoop.makeFailedFuture(HTTPClientError.invalidLocalAddress)
|
||||
}
|
||||
|
||||
var tlsConfig = self.tlsConfiguration
|
||||
switch self.clientConfiguration.httpVersion.configuration {
|
||||
case .automatic:
|
||||
@@ -538,13 +581,14 @@ extension HTTPConnectionPool.ConnectionFactory {
|
||||
#if canImport(Network)
|
||||
if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *), eventLoop is QoSEventLoop {
|
||||
// create NIOClientTCPBootstrap with NIOTS TLS provider
|
||||
let localAddr = self.key.localAddress
|
||||
let bootstrapFuture = tlsConfig.getNWProtocolTLSOptions(
|
||||
on: eventLoop,
|
||||
serverNameIndicatorOverride: key.serverNameIndicatorOverride
|
||||
).map {
|
||||
options -> NIOClientTCPBootstrapProtocol in
|
||||
|
||||
NIOTSConnectionBootstrap(group: eventLoop) // validated above
|
||||
var bootstrap = NIOTSConnectionBootstrap(group: eventLoop) // validated above
|
||||
.channelOption(
|
||||
NIOTSChannelOptions.waitForActivity,
|
||||
value: self.clientConfiguration.networkFrameworkWaitForConnectivity
|
||||
@@ -569,7 +613,16 @@ extension HTTPConnectionPool.ConnectionFactory {
|
||||
} catch {
|
||||
return channel.eventLoop.makeFailedFuture(error)
|
||||
}
|
||||
} as NIOClientTCPBootstrapProtocol
|
||||
}
|
||||
if let localAddress = localAddr {
|
||||
bootstrap = bootstrap.configureNWParameters { params in
|
||||
params.requiredLocalEndpoint = NWEndpoint.hostPort(
|
||||
host: NWEndpoint.Host(localAddress),
|
||||
port: .any
|
||||
)
|
||||
}
|
||||
}
|
||||
return bootstrap as NIOClientTCPBootstrapProtocol
|
||||
}
|
||||
return bootstrapFuture
|
||||
}
|
||||
@@ -581,10 +634,20 @@ extension HTTPConnectionPool.ConnectionFactory {
|
||||
logger: logger
|
||||
)
|
||||
|
||||
return eventLoop.submit {
|
||||
ClientBootstrap(group: eventLoop)
|
||||
return eventLoop.submit { [key] () throws -> NIOClientTCPBootstrapProtocol in
|
||||
var bootstrap = ClientBootstrap(group: eventLoop)
|
||||
.connectTimeout(deadline - NIODeadline.now())
|
||||
.enableMPTCP(clientConfiguration.enableMultipath)
|
||||
if let localAddress = key.localAddress {
|
||||
do {
|
||||
let socketAddress = try SocketAddress(ipAddress: localAddress, port: 0)
|
||||
bootstrap = bootstrap.bind(to: socketAddress)
|
||||
} catch {
|
||||
throw HTTPClientError.invalidLocalAddress
|
||||
}
|
||||
}
|
||||
return
|
||||
bootstrap
|
||||
.channelInitializer { channel in
|
||||
sslContextFuture.flatMap { sslContext -> EventLoopFuture<Void> in
|
||||
do {
|
||||
|
||||
@@ -21,15 +21,20 @@ struct RequestOptions {
|
||||
var idleWriteTimeout: TimeAmount?
|
||||
/// DNS overrides.
|
||||
var dnsOverride: [String: String]
|
||||
/// The local IP address to bind outgoing connections to. This is typically used on multi-NIC
|
||||
/// systems where we want to control where traffic goes.
|
||||
var localAddress: String?
|
||||
|
||||
init(
|
||||
idleReadTimeout: TimeAmount?,
|
||||
idleWriteTimeout: TimeAmount?,
|
||||
dnsOverride: [String: String]
|
||||
dnsOverride: [String: String],
|
||||
localAddress: String? = nil
|
||||
) {
|
||||
self.idleReadTimeout = idleReadTimeout
|
||||
self.idleWriteTimeout = idleWriteTimeout
|
||||
self.dnsOverride = dnsOverride
|
||||
self.localAddress = localAddress
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +43,8 @@ extension RequestOptions {
|
||||
RequestOptions(
|
||||
idleReadTimeout: configuration.timeout.read,
|
||||
idleWriteTimeout: configuration.timeout.write,
|
||||
dnsOverride: configuration.dnsOverride
|
||||
dnsOverride: configuration.dnsOverride,
|
||||
localAddress: configuration.localAddress
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
//
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import struct FoundationEssentials.URL
|
||||
#else
|
||||
import struct Foundation.URL
|
||||
#endif
|
||||
|
||||
struct DeconstructedURL {
|
||||
var scheme: Scheme
|
||||
|
||||
@@ -17,7 +17,11 @@ import NIOCore
|
||||
import NIOHTTP1
|
||||
import NIOPosix
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import struct FoundationEssentials.URL
|
||||
#else
|
||||
import struct Foundation.URL
|
||||
#endif
|
||||
|
||||
/// Handles a streaming download to a given file path, allowing headers and progress to be reported.
|
||||
public final class FileDownloadDelegate: HTTPClientResponseDelegate {
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
// Extensions which provide better ergonomics when using Foundation types,
|
||||
// or by using Foundation APIs.
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
extension HTTPClient.Cookie {
|
||||
/// The cookie's expiration date.
|
||||
@@ -73,3 +77,66 @@ extension HTTPClient.Body {
|
||||
self.bytes(data)
|
||||
}
|
||||
}
|
||||
|
||||
extension StringProtocol {
|
||||
func addingPercentEncodingAllowingURLHost() -> String {
|
||||
guard !self.isEmpty else { return String(self) }
|
||||
|
||||
let percent = UInt8(ascii: "%")
|
||||
let utf8Buffer = self.utf8
|
||||
let maxLength = utf8Buffer.count * 3
|
||||
return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: maxLength) { outputBuffer in
|
||||
var i = 0
|
||||
for byte in utf8Buffer {
|
||||
if byte.isURLHostAllowed {
|
||||
outputBuffer[i] = byte
|
||||
i += 1
|
||||
} else {
|
||||
outputBuffer[i] = percent
|
||||
outputBuffer[i + 1] = hexToAscii(byte >> 4)
|
||||
outputBuffer[i + 2] = hexToAscii(byte & 0xF)
|
||||
i += 3
|
||||
}
|
||||
}
|
||||
return String(decoding: outputBuffer[..<i], as: UTF8.self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func hexToAscii(_ hex: UInt8) -> UInt8 {
|
||||
switch hex {
|
||||
case 0x0: return UInt8(ascii: "0")
|
||||
case 0x1: return UInt8(ascii: "1")
|
||||
case 0x2: return UInt8(ascii: "2")
|
||||
case 0x3: return UInt8(ascii: "3")
|
||||
case 0x4: return UInt8(ascii: "4")
|
||||
case 0x5: return UInt8(ascii: "5")
|
||||
case 0x6: return UInt8(ascii: "6")
|
||||
case 0x7: return UInt8(ascii: "7")
|
||||
case 0x8: return UInt8(ascii: "8")
|
||||
case 0x9: return UInt8(ascii: "9")
|
||||
case 0xA: return UInt8(ascii: "A")
|
||||
case 0xB: return UInt8(ascii: "B")
|
||||
case 0xC: return UInt8(ascii: "C")
|
||||
case 0xD: return UInt8(ascii: "D")
|
||||
case 0xE: return UInt8(ascii: "E")
|
||||
case 0xF: return UInt8(ascii: "F")
|
||||
default: fatalError("Invalid hex digit: \(hex)")
|
||||
}
|
||||
}
|
||||
|
||||
extension UInt8 {
|
||||
fileprivate var isURLHostAllowed: Bool {
|
||||
switch self {
|
||||
case UInt8(ascii: "0")...UInt8(ascii: "9"),
|
||||
UInt8(ascii: "A")...UInt8(ascii: "Z"),
|
||||
UInt8(ascii: "a")...UInt8(ascii: "z"),
|
||||
UInt8(ascii: "!"), UInt8(ascii: "$"), UInt8(ascii: "&"), UInt8(ascii: "'"),
|
||||
UInt8(ascii: "("), UInt8(ascii: ")"), UInt8(ascii: "*"), UInt8(ascii: "+"),
|
||||
UInt8(ascii: ","), UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: ";"),
|
||||
UInt8(ascii: "="), UInt8(ascii: "_"), UInt8(ascii: "~"):
|
||||
return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Atomics
|
||||
import Foundation
|
||||
import Dispatch
|
||||
import Logging
|
||||
import NIOConcurrencyHelpers
|
||||
import NIOCore
|
||||
@@ -22,9 +22,18 @@ import NIOHTTPCompression
|
||||
import NIOPosix
|
||||
import NIOSSL
|
||||
import NIOTLS
|
||||
import NIOTransportServices
|
||||
import Tracing
|
||||
|
||||
#if canImport(Network)
|
||||
import NIOTransportServices
|
||||
#endif
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
extension Logger {
|
||||
private func requestInfo(_ request: HTTPClient.Request) -> Logger.Metadata.Value {
|
||||
"\(request.method) \(request.url)"
|
||||
@@ -895,6 +904,20 @@ public final class HTTPClient: Sendable {
|
||||
/// By default, don't use it
|
||||
public var enableMultipath: Bool
|
||||
|
||||
/// The local IP address to bind outgoing connections to.
|
||||
///
|
||||
/// When set, all outgoing connections will bind to this address before connecting.
|
||||
/// The value should be an IP address string (e.g. `"192.168.1.10"` or `"::1"`).
|
||||
/// Port 0 (OS-assigned ephemeral port) is always used.
|
||||
///
|
||||
/// This is most commonly used on multi-NIC systems where you want traffic to take a
|
||||
/// specific network path which is not the choice the routing table would make by
|
||||
/// default.
|
||||
///
|
||||
/// This can be overridden on a per-request basis using ``HTTPClientRequest/localAddress``.
|
||||
/// Defaults to `nil` (OS default interface selection).
|
||||
public var localAddress: String?
|
||||
|
||||
/// A method with access to the HTTP/1 connection channel that is called when creating the connection.
|
||||
public var http1_1ConnectionDebugInitializer: (@Sendable (Channel) -> EventLoopFuture<Void>)?
|
||||
|
||||
@@ -925,6 +948,7 @@ public final class HTTPClient: Sendable {
|
||||
self.httpVersion = .automatic
|
||||
self.networkFrameworkWaitForConnectivity = true
|
||||
self.enableMultipath = false
|
||||
self.localAddress = nil
|
||||
}
|
||||
|
||||
public init(
|
||||
@@ -1452,6 +1476,7 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
|
||||
case invalidRedirectConfiguration
|
||||
case invalidHTTPVersionConfiguration
|
||||
case invalidDNSOverridesConfiguration
|
||||
case invalidLocalAddress
|
||||
case internalStateFailure(file: String, line: UInt)
|
||||
}
|
||||
|
||||
@@ -1545,6 +1570,8 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
|
||||
case .invalidDNSOverridesConfiguration:
|
||||
return
|
||||
"The DNS overrides specified in the configuration are not valid. Please specify in the format hostname1:ip1,hostname2:ip2"
|
||||
case .invalidLocalAddress:
|
||||
return "Invalid local address"
|
||||
case .internalStateFailure(let file, let line):
|
||||
return
|
||||
"An internal state failure has occurred (File: \(file), line: \(line)). Please open an issue with a reproducer if possible"
|
||||
@@ -1648,6 +1675,9 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
|
||||
/// The DNS overrides specified in the configuration are not valid.
|
||||
public static let invalidDNSOverridesConfiguration = HTTPClientError(code: .invalidDNSOverridesConfiguration)
|
||||
|
||||
/// The local address specified is not a valid IP address.
|
||||
public static let invalidLocalAddress = HTTPClientError(code: .invalidLocalAddress)
|
||||
|
||||
/// A state machine has reached an unsupported state, that wasn't considered when implementing.
|
||||
public static func internalStateFailure(file: String = #fileID, line: UInt = #line) -> HTTPClientError {
|
||||
HTTPClientError(code: .internalStateFailure(file: file, line: line))
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Algorithms
|
||||
import Foundation
|
||||
import Logging
|
||||
import NIOConcurrencyHelpers
|
||||
import NIOCore
|
||||
@@ -22,6 +21,12 @@ import NIOPosix
|
||||
import NIOSSL
|
||||
import Tracing
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
extension HTTPClient {
|
||||
/// A request body.
|
||||
public struct Body: Sendable {
|
||||
@@ -880,7 +885,7 @@ extension URL {
|
||||
/// - socketPath: The path to the unix domain socket to connect to.
|
||||
/// - uri: The URI path and query that will be sent to the server.
|
||||
public init?(httpURLWithSocketPath socketPath: String, uri: String = "/") {
|
||||
guard let host = socketPath.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) else { return nil }
|
||||
let host = socketPath.addingPercentEncodingAllowingURLHost()
|
||||
var urlString: String
|
||||
if uri.hasPrefix("/") {
|
||||
urlString = "http+unix://\(host)\(uri)"
|
||||
@@ -895,7 +900,7 @@ extension URL {
|
||||
/// - socketPath: The path to the unix domain socket to connect to.
|
||||
/// - uri: The URI path and query that will be sent to the server.
|
||||
public init?(httpsURLWithSocketPath socketPath: String, uri: String = "/") {
|
||||
guard let host = socketPath.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) else { return nil }
|
||||
let host = socketPath.addingPercentEncodingAllowingURLHost()
|
||||
var urlString: String
|
||||
if uri.hasPrefix("/") {
|
||||
urlString = "https+unix://\(host)\(uri)"
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
|
||||
import NIOCore
|
||||
import NIOHTTP1
|
||||
import NIOTransportServices
|
||||
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
import NIOTransportServices
|
||||
#endif
|
||||
|
||||
extension HTTPClient {
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
|
||||
#if canImport(Network)
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import FoundationEssentials
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
import Dispatch
|
||||
import Network
|
||||
import NIOCore
|
||||
import NIOSSL
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
|
||||
import NIOHTTP1
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import struct FoundationEssentials.URL
|
||||
#else
|
||||
import struct Foundation.URL
|
||||
#endif
|
||||
|
||||
typealias RedirectMode = HTTPClient.Configuration.RedirectConfiguration.Mode
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
import NIOCore
|
||||
import NIOHTTP1
|
||||
|
||||
#if canImport(FoundationEssentials)
|
||||
import struct FoundationEssentials.URL
|
||||
#else
|
||||
import struct Foundation.URL
|
||||
#endif
|
||||
|
||||
extension HTTPClient {
|
||||
/// The maximum body size allowed, before a redirect response is cancelled. 3KB.
|
||||
|
||||
@@ -22,7 +22,7 @@ import Tracing
|
||||
extension RequestBag.LoopBoundState {
|
||||
|
||||
/// Starts the "overall" Span that encompases the beginning of a request until receipt of the head part of the response.
|
||||
mutating func startRequestSpan<T>(tracer: T?) {
|
||||
mutating func startRequestSpan(tracer: (any Sendable)?) {
|
||||
guard #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *),
|
||||
let tracer = tracer as! (any Tracer)?
|
||||
else {
|
||||
|
||||
@@ -96,7 +96,11 @@ final class RequestBag<Delegate: HTTPClientResponseDelegate & Sendable>: Sendabl
|
||||
requestOptions: RequestOptions,
|
||||
delegate: Delegate
|
||||
) throws {
|
||||
self.poolKey = .init(request, dnsOverride: requestOptions.dnsOverride)
|
||||
self.poolKey = .init(
|
||||
request,
|
||||
dnsOverride: requestOptions.dnsOverride,
|
||||
localAddress: requestOptions.localAddress
|
||||
)
|
||||
self.eventLoopPreference = eventLoopPreference
|
||||
self.task = task
|
||||
|
||||
|
||||
@@ -1108,6 +1108,136 @@ final class AsyncAwaitEndToEndTests: XCTestCase {
|
||||
XCTAssertEqual(responseGet.history[1].request.method, .GET, "Redirected request should remain GET")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Integration tests: local address binding
|
||||
|
||||
func testLocalAddressBinding_configLevel() async throws {
|
||||
// On Linux, 127.0.0.0/8 all route to loopback, so we can use a
|
||||
// non-default address to prove the bind actually happened.
|
||||
#if os(Linux)
|
||||
let localAddress = "127.0.0.127"
|
||||
#else
|
||||
let localAddress = "127.0.0.1"
|
||||
#endif
|
||||
|
||||
let bin = HTTPBin(.http1_1(ssl: false))
|
||||
defer { XCTAssertNoThrow(try bin.shutdown()) }
|
||||
|
||||
var config = HTTPClient.Configuration()
|
||||
.enableFastFailureModeForTesting()
|
||||
config.localAddress = localAddress
|
||||
|
||||
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
|
||||
defer { XCTAssertNoThrow(try client.syncShutdown()) }
|
||||
|
||||
let request = HTTPClientRequest(url: "http://127.0.0.1:\(bin.port)/echo-client-ip")
|
||||
let response = try await client.execute(request, deadline: .now() + .seconds(10))
|
||||
XCTAssertEqual(response.status, .ok)
|
||||
|
||||
var body = try await response.body.collect(upTo: 1024)
|
||||
let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes)
|
||||
XCTAssertEqual(requestInfo?.data, localAddress)
|
||||
}
|
||||
|
||||
func testLocalAddressBinding_perRequest() async throws {
|
||||
#if os(Linux)
|
||||
let localAddress = "127.0.0.127"
|
||||
#else
|
||||
let localAddress = "127.0.0.1"
|
||||
#endif
|
||||
|
||||
let bin = HTTPBin(.http1_1(ssl: false))
|
||||
defer { XCTAssertNoThrow(try bin.shutdown()) }
|
||||
|
||||
let config = HTTPClient.Configuration()
|
||||
.enableFastFailureModeForTesting()
|
||||
|
||||
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
|
||||
defer { XCTAssertNoThrow(try client.syncShutdown()) }
|
||||
|
||||
var request = HTTPClientRequest(url: "http://127.0.0.1:\(bin.port)/echo-client-ip")
|
||||
request.localAddress = localAddress
|
||||
|
||||
let response = try await client.execute(request, deadline: .now() + .seconds(10))
|
||||
XCTAssertEqual(response.status, .ok)
|
||||
|
||||
var body = try await response.body.collect(upTo: 1024)
|
||||
let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes)
|
||||
XCTAssertEqual(requestInfo?.data, localAddress)
|
||||
}
|
||||
|
||||
func testLocalAddressBinding_perRequestOverridesConfig() async throws {
|
||||
#if os(Linux)
|
||||
let localAddress = "127.0.0.127"
|
||||
#else
|
||||
let localAddress = "127.0.0.1"
|
||||
#endif
|
||||
|
||||
let bin = HTTPBin(.http1_1(ssl: false))
|
||||
defer { XCTAssertNoThrow(try bin.shutdown()) }
|
||||
|
||||
var config = HTTPClient.Configuration()
|
||||
.enableFastFailureModeForTesting()
|
||||
config.localAddress = "127.0.0.1"
|
||||
|
||||
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
|
||||
defer { XCTAssertNoThrow(try client.syncShutdown()) }
|
||||
|
||||
var request = HTTPClientRequest(url: "http://127.0.0.1:\(bin.port)/echo-client-ip")
|
||||
request.localAddress = localAddress
|
||||
|
||||
let response = try await client.execute(request, deadline: .now() + .seconds(10))
|
||||
XCTAssertEqual(response.status, .ok)
|
||||
|
||||
var body = try await response.body.collect(upTo: 1024)
|
||||
let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes)
|
||||
XCTAssertEqual(requestInfo?.data, localAddress)
|
||||
}
|
||||
|
||||
func testLocalAddressBinding_invalidAddress() async throws {
|
||||
var config = HTTPClient.Configuration()
|
||||
.enableFastFailureModeForTesting()
|
||||
config.localAddress = "not-a-valid-ip"
|
||||
|
||||
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
|
||||
defer { XCTAssertNoThrow(try client.syncShutdown()) }
|
||||
|
||||
let request = HTTPClientRequest(url: "http://127.0.0.1/ok")
|
||||
do {
|
||||
_ = try await client.execute(request, deadline: .now() + .seconds(10))
|
||||
XCTFail("Expected error to be thrown")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? HTTPClientError, .invalidLocalAddress)
|
||||
}
|
||||
}
|
||||
|
||||
func testLocalAddressBinding_withTLS() async throws {
|
||||
#if os(Linux)
|
||||
let localAddress = "127.0.0.127"
|
||||
#else
|
||||
let localAddress = "127.0.0.1"
|
||||
#endif
|
||||
|
||||
let bin = HTTPBin(.http2(compress: false))
|
||||
defer { XCTAssertNoThrow(try bin.shutdown()) }
|
||||
|
||||
var config = HTTPClient.Configuration()
|
||||
.enableFastFailureModeForTesting()
|
||||
config.tlsConfiguration = .clientDefault
|
||||
config.tlsConfiguration?.certificateVerification = .none
|
||||
config.localAddress = localAddress
|
||||
|
||||
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
|
||||
defer { XCTAssertNoThrow(try client.syncShutdown()) }
|
||||
|
||||
let request = HTTPClientRequest(url: "https://127.0.0.1:\(bin.port)/echo-client-ip")
|
||||
let response = try await client.execute(request, deadline: .now() + .seconds(10))
|
||||
XCTAssertEqual(response.status, .ok)
|
||||
|
||||
var body = try await response.body.collect(upTo: 1024)
|
||||
let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes)
|
||||
XCTAssertEqual(requestInfo?.data, localAddress)
|
||||
}
|
||||
}
|
||||
|
||||
struct AnySendableSequence<Element>: @unchecked Sendable {
|
||||
|
||||
@@ -23,11 +23,11 @@ import NIOHTTPCompression
|
||||
import NIOPosix
|
||||
import NIOSSL
|
||||
import NIOTestUtils
|
||||
import NIOTransportServices
|
||||
import XCTest
|
||||
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
import NIOTransportServices
|
||||
#endif
|
||||
|
||||
final class ConnectionPoolSizeConfigValueIsRespectedTests: XCTestCaseHTTPClientTestsBaseClass {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
//
|
||||
// This source file is part of the AsyncHTTPClient open source project
|
||||
//
|
||||
// Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors
|
||||
// Licensed under Apache License v2.0
|
||||
//
|
||||
// See LICENSE.txt for license information
|
||||
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import AsyncHTTPClient
|
||||
|
||||
@Suite
|
||||
struct FoundationExtensionTests {
|
||||
@Test(arguments: [
|
||||
// Format: (input, expected)
|
||||
("localhost", "localhost"), // Alphanumerics (No encoding needed)
|
||||
("example.com", "example.com"), // Domain with unreserved dot (No encoding needed)
|
||||
("user@email.com", "user%40email.com"), // '@' is not allowed in host (Should be encoded to %40)
|
||||
("!$&'()*+,;=[]", "!$&\'()*+,;=%5B%5D"), // Sub-delimiters and brackets (Allowed in host, NO encoding)
|
||||
("~_.-", "~_.-"), // Unreserved punctuation (Allowed in host, NO encoding)
|
||||
("café", "caf%C3%A9"), // Non-ASCII character (Should be encoded)
|
||||
("👨💻 swift", "%F0%9F%91%A8%E2%80%8D%F0%9F%92%BB%20swift"), // Emoji and space (Space to %20, Emoji encoded)
|
||||
("", ""), // Empty string
|
||||
("100% coverage", "100%25%20coverage"), // '%' symbol itself (Must be encoded to %25)
|
||||
("sub.domain_test~1.com", "sub.domain_test~1.com"), // Mix of allowed characters
|
||||
// Invalid host chars like '/', '?', and '#' (Should be encoded), '=' is a valid sub-delimiter
|
||||
("path/to/api?query=1#frag", "path%2Fto%2Fapi%3Fquery=1%23frag"),
|
||||
])
|
||||
func addingPercentEncodingAllowingURLHost(input: String, expected: String) {
|
||||
let foundationResult = input.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
|
||||
let customResult = input.addingPercentEncodingAllowingURLHost()
|
||||
|
||||
#expect(
|
||||
customResult == expected,
|
||||
"Encoding mismatch for input: '\(input)'. Expected '\(String(describing: expected))', got '\(String(describing: customResult))'"
|
||||
)
|
||||
|
||||
#expect(
|
||||
customResult == foundationResult,
|
||||
"Result did not match Foundation: '\(input)'. Expected '\(String(describing: foundationResult))', got '\(String(describing: customResult))'"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -947,6 +947,121 @@ class HTTP1ClientChannelHandlerTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testDemandResponseBodyStreamAfterEarlyResponseDoesNotCrash() async throws {
|
||||
// This test reproduces a crash where `demandMoreResponseBodyParts()` was called on the
|
||||
// state machine after it had already transitioned to `.idle`. The scenario is:
|
||||
//
|
||||
// 1. A streaming POST request is in progress
|
||||
// 2. The full response (head + end) arrives before the request body is finished
|
||||
// 3. The response end is forwarded with `finalAction: .none` (body still uploading)
|
||||
// 4. The request body stream finishes -> `requestStreamFinished` -> state machine
|
||||
// transitions to `.idle` and returns `.sendRequestEnd(.informConnectionIsIdle)`
|
||||
// 5. The `.sendRequestEnd` handler writes `.end` to the channel and registers a
|
||||
// callback on the write promise
|
||||
// 6. Before the write callback fires (write hasn't completed yet),
|
||||
// `demandResponseBodyStream` is called (from a delegate on another event loop)
|
||||
// 7. Without the fix, `self.request` was still set (only nilled in the write
|
||||
// callback), so the guard passed and `demandMoreResponseBodyParts()` hit
|
||||
// `fatalError("Invalid state: idle")`
|
||||
//
|
||||
// The fix nils out `self.request` synchronously in `.sendRequestEnd` (before the
|
||||
// write callback), so the guard in `demandResponseBodyStream0` fails and returns early.
|
||||
|
||||
final class DelayEndHandler: ChannelOutboundHandler {
|
||||
typealias OutboundIn = HTTPClientRequestPart
|
||||
typealias OutboundOut = HTTPClientRequestPart
|
||||
|
||||
private(set) var endPromise: EventLoopPromise<Void>?
|
||||
|
||||
func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) {
|
||||
if case .end = self.unwrapOutboundIn(data) {
|
||||
self.endPromise = promise
|
||||
context.write(data, promise: nil)
|
||||
} else {
|
||||
context.write(data, promise: promise)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let eventLoop = EmbeddedEventLoop()
|
||||
let delayEndHandler = DelayEndHandler()
|
||||
let handler = HTTP1ClientChannelHandler(
|
||||
eventLoop: eventLoop,
|
||||
backgroundLogger: Logger(label: "no-op", factory: SwiftLogNoOpLogHandler.init),
|
||||
connectionIdLoggerMetadata: "test connection"
|
||||
)
|
||||
var connectionIsIdle = false
|
||||
handler.onConnectionIdle = { connectionIsIdle = true }
|
||||
let channel = EmbeddedChannel(handlers: [delayEndHandler, handler], loop: eventLoop)
|
||||
XCTAssertNoThrow(try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 80)).wait())
|
||||
|
||||
let request = MockHTTPExecutableRequest(
|
||||
head: .init(version: .http1_1, method: .POST, uri: "http://localhost/"),
|
||||
framingMetadata: RequestFramingMetadata(connectionClose: false, body: .stream),
|
||||
raiseErrorIfUnimplementedMethodIsCalled: false
|
||||
)
|
||||
|
||||
let executor = handler.requestExecutor
|
||||
|
||||
// When the body stream is resumed, write one part but do NOT finish the stream yet.
|
||||
request.resumeRequestBodyStreamCallback = {
|
||||
executor.writeRequestBodyPart(.byteBuffer(.init(string: "Hello")), request: request, promise: nil)
|
||||
}
|
||||
|
||||
// Start the request
|
||||
channel.write(request, promise: nil)
|
||||
|
||||
// Verify request head was sent
|
||||
XCTAssertEqual(try channel.readOutbound(as: HTTPClientRequestPart.self), .head(request.requestHead))
|
||||
// Verify body part was sent
|
||||
XCTAssertEqual(
|
||||
try channel.readOutbound(as: HTTPClientRequestPart.self),
|
||||
.body(.byteBuffer(.init(string: "Hello")))
|
||||
)
|
||||
|
||||
// Now send the full response while the request body stream is still open.
|
||||
// This causes forwardResponseEnd with finalAction: .none (body not done yet).
|
||||
XCTAssertNoThrow(try channel.writeInbound(HTTPClientResponsePart.head(.init(version: .http1_1, status: .ok))))
|
||||
// Issue a read to advance the response stream state so it accepts the end properly.
|
||||
channel.read()
|
||||
XCTAssertNoThrow(try channel.writeInbound(HTTPClientResponsePart.end(nil)))
|
||||
|
||||
// Finish the request body stream. This transitions the state machine to `.idle`
|
||||
// and writes `.end` to the channel. The DelayEndHandler intercepts the `.end`
|
||||
// write and holds the promise, preventing the write callback from firing.
|
||||
executor.finishRequestBodyStream(trailers: nil, request: request, promise: nil)
|
||||
|
||||
// Verify the .end was written through to the channel
|
||||
XCTAssertEqual(try channel.readOutbound(as: HTTPClientRequestPart.self), .end(nil))
|
||||
|
||||
// At this point:
|
||||
// - The state machine has transitioned to `.idle`
|
||||
// - The write promise has NOT been fulfilled (held by DelayEndHandler)
|
||||
// - In old code: self.request is still set (only nilled in the write callback)
|
||||
// - In fixed code: self.request is already nil (nilled synchronously)
|
||||
|
||||
// Now call demandResponseBodyStream, simulating a delegate on a different event
|
||||
// loop calling it after receiving the response end but before the write completes.
|
||||
// Without the fix, self.request is still set, the guard passes, and
|
||||
// state.demandMoreResponseBodyParts() crashes with "Invalid state: idle".
|
||||
// With the fix, self.request was already nilled, the guard fails, and this is a no-op.
|
||||
executor.demandResponseBodyStream(request)
|
||||
|
||||
// Complete the delayed write to clean up properly.
|
||||
delayEndHandler.endPromise?.succeed(())
|
||||
eventLoop.run()
|
||||
|
||||
XCTAssertTrue(connectionIsIdle)
|
||||
|
||||
XCTAssertEqual(
|
||||
request.events.map(\.kind),
|
||||
[
|
||||
.willExecuteRequest, .requestHeadSent, .resumeRequestBodyStream,
|
||||
.receiveResponseHead, .receiveResponseEnd, .requestBodySent,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testDefaultMaxBufferSize() {
|
||||
if MemoryLayout<Int>.size == 8 {
|
||||
XCTAssertEqual(ResponseAccumulator.maxByteBufferSize, Int(UInt32.max))
|
||||
|
||||
@@ -24,11 +24,11 @@ import NIOHTTPCompression
|
||||
import NIOPosix
|
||||
import NIOSSL
|
||||
import NIOTestUtils
|
||||
import NIOTransportServices
|
||||
import XCTest
|
||||
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
import NIOTransportServices
|
||||
#endif
|
||||
|
||||
class XCTestCaseHTTPClientTestsBaseClass: XCTestCase {
|
||||
|
||||
@@ -16,13 +16,13 @@ import NIOConcurrencyHelpers
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
import NIOSSL
|
||||
import NIOTransportServices
|
||||
import XCTest
|
||||
|
||||
@testable import AsyncHTTPClient
|
||||
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
import NIOTransportServices
|
||||
#endif
|
||||
|
||||
class HTTPClientNIOTSTests: XCTestCase {
|
||||
|
||||
@@ -27,11 +27,15 @@ import NIOHTTPCompression
|
||||
import NIOPosix
|
||||
import NIOSSL
|
||||
import NIOTLS
|
||||
import NIOTransportServices
|
||||
import XCTest
|
||||
|
||||
@testable import AsyncHTTPClient
|
||||
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
import NIOTransportServices
|
||||
#endif
|
||||
|
||||
#if canImport(xlocale)
|
||||
import xlocale
|
||||
#elseif canImport(locale_h)
|
||||
@@ -1047,6 +1051,13 @@ internal final class HTTPBinHandler: ChannelInboundHandler {
|
||||
}
|
||||
self.resps.append(HTTPResponseBuilder(status: .ok))
|
||||
return
|
||||
case "/echo-client-ip":
|
||||
var builder = HTTPResponseBuilder(status: .ok)
|
||||
let clientIP = context.channel.remoteAddress?.ipAddress ?? "unknown"
|
||||
let buf = context.channel.allocator.buffer(string: clientIP)
|
||||
builder.add(buf)
|
||||
self.resps.append(builder)
|
||||
return
|
||||
case "/echohostheader":
|
||||
var builder = HTTPResponseBuilder(status: .ok)
|
||||
let hostValue = req.headers["Host"].first ?? ""
|
||||
|
||||
@@ -26,11 +26,11 @@ import NIOHTTPCompression
|
||||
import NIOPosix
|
||||
import NIOSSL
|
||||
import NIOTestUtils
|
||||
import NIOTransportServices
|
||||
import XCTest
|
||||
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
import NIOTransportServices
|
||||
#endif
|
||||
|
||||
final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass {
|
||||
@@ -4644,6 +4644,33 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testLocalAddressBinding_configLevel() throws {
|
||||
// On Linux, 127.0.0.0/8 all route to loopback, so we can use a
|
||||
// non-default address to prove the bind actually happened.
|
||||
#if os(Linux)
|
||||
let localAddress = "127.0.0.127"
|
||||
#else
|
||||
let localAddress = "127.0.0.1"
|
||||
#endif
|
||||
|
||||
let bin = HTTPBin(.http1_1(ssl: false))
|
||||
defer { XCTAssertNoThrow(try bin.shutdown()) }
|
||||
|
||||
var config = HTTPClient.Configuration()
|
||||
.enableFastFailureModeForTesting()
|
||||
config.localAddress = localAddress
|
||||
|
||||
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
|
||||
defer { XCTAssertNoThrow(try client.syncShutdown()) }
|
||||
|
||||
let response = try client.get(url: "http://127.0.0.1:\(bin.port)/echo-client-ip").wait()
|
||||
XCTAssertEqual(response.status, .ok)
|
||||
|
||||
let bytes = response.body.flatMap { $0.getData(at: 0, length: $0.readableBytes) }
|
||||
let data = try JSONDecoder().decode(RequestInfo.self, from: bytes!)
|
||||
XCTAssertEqual(data.data, localAddress)
|
||||
}
|
||||
}
|
||||
|
||||
final class CountingDebugInitializerUtil: Sendable {
|
||||
|
||||
@@ -24,7 +24,6 @@ import NIOHTTPCompression
|
||||
import NIOPosix
|
||||
import NIOSSL
|
||||
import NIOTestUtils
|
||||
import NIOTransportServices
|
||||
import Tracing
|
||||
import XCTest
|
||||
|
||||
@@ -32,6 +31,7 @@ import XCTest
|
||||
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
import NIOTransportServices
|
||||
#endif
|
||||
|
||||
private func makeTracedHTTPClient(tracer: InMemoryTracer) -> HTTPClient {
|
||||
|
||||
@@ -25,12 +25,12 @@ import NIOHTTPCompression
|
||||
import NIOPosix
|
||||
import NIOSSL
|
||||
import NIOTestUtils
|
||||
import NIOTransportServices
|
||||
import Tracing
|
||||
import XCTest
|
||||
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
import NIOTransportServices
|
||||
#endif
|
||||
|
||||
private func makeTracedHTTPClient(tracer: InMemoryTracer) -> HTTPClient {
|
||||
|
||||
@@ -23,11 +23,11 @@ import NIOHTTPCompression
|
||||
import NIOPosix
|
||||
import NIOSSL
|
||||
import NIOTestUtils
|
||||
import NIOTransportServices
|
||||
import XCTest
|
||||
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
import NIOTransportServices
|
||||
#endif
|
||||
|
||||
final class TestIdleTimeoutNoReuse: XCTestCaseHTTPClientTestsBaseClass {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
//
|
||||
// This source file is part of the AsyncHTTPClient open source project
|
||||
//
|
||||
// Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors
|
||||
// Licensed under Apache License v2.0
|
||||
//
|
||||
// See LICENSE.txt for license information
|
||||
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import NIOHTTP1
|
||||
import Testing
|
||||
|
||||
import struct Foundation.URL
|
||||
|
||||
@testable import AsyncHTTPClient
|
||||
|
||||
struct LocalAddressOverrideTests {
|
||||
// MARK: - Pool Key with localAddress
|
||||
|
||||
@Test func poolKeysWithDifferentLocalAddressesAreNotEqual() {
|
||||
let key1 = ConnectionPool.Key(
|
||||
scheme: .https,
|
||||
connectionTarget: .domain(name: "example.com", port: 443),
|
||||
serverNameIndicatorOverride: nil,
|
||||
localAddress: "192.168.1.10"
|
||||
)
|
||||
let key2 = ConnectionPool.Key(
|
||||
scheme: .https,
|
||||
connectionTarget: .domain(name: "example.com", port: 443),
|
||||
serverNameIndicatorOverride: nil,
|
||||
localAddress: "10.0.0.1"
|
||||
)
|
||||
let keyNil = ConnectionPool.Key(
|
||||
scheme: .https,
|
||||
connectionTarget: .domain(name: "example.com", port: 443),
|
||||
serverNameIndicatorOverride: nil,
|
||||
localAddress: nil
|
||||
)
|
||||
#expect(key1 != key2)
|
||||
#expect(key1 != keyNil)
|
||||
#expect(key2 != keyNil)
|
||||
}
|
||||
|
||||
@Test func poolKeysWithSameLocalAddressAreEqual() {
|
||||
let key1 = ConnectionPool.Key(
|
||||
scheme: .https,
|
||||
connectionTarget: .domain(name: "example.com", port: 443),
|
||||
serverNameIndicatorOverride: nil,
|
||||
localAddress: "192.168.1.10"
|
||||
)
|
||||
let key2 = ConnectionPool.Key(
|
||||
scheme: .https,
|
||||
connectionTarget: .domain(name: "example.com", port: 443),
|
||||
serverNameIndicatorOverride: nil,
|
||||
localAddress: "192.168.1.10"
|
||||
)
|
||||
#expect(key1 == key2)
|
||||
}
|
||||
|
||||
@Test func poolKeyWithNilLocalAddressMatchesDefault() {
|
||||
let key1 = ConnectionPool.Key(
|
||||
scheme: .https,
|
||||
connectionTarget: .domain(name: "example.com", port: 443),
|
||||
serverNameIndicatorOverride: nil
|
||||
)
|
||||
let key2 = ConnectionPool.Key(
|
||||
scheme: .https,
|
||||
connectionTarget: .domain(name: "example.com", port: 443),
|
||||
serverNameIndicatorOverride: nil,
|
||||
localAddress: nil
|
||||
)
|
||||
#expect(key1 == key2)
|
||||
}
|
||||
|
||||
// MARK: - Per-request localAddress override
|
||||
|
||||
@Test func perRequestLocalAddressOverridesConfig() throws {
|
||||
var request = HTTPClientRequest(url: "https://example.com/get")
|
||||
request.localAddress = "10.0.0.1"
|
||||
|
||||
let prepared = try HTTPClientRequest.Prepared(
|
||||
request,
|
||||
localAddress: "192.168.1.10"
|
||||
)
|
||||
|
||||
#expect(prepared.poolKey.localAddress == "10.0.0.1")
|
||||
}
|
||||
|
||||
@Test func configLocalAddressUsedWhenRequestHasNone() throws {
|
||||
let request = HTTPClientRequest(url: "https://example.com/get")
|
||||
|
||||
let prepared = try HTTPClientRequest.Prepared(
|
||||
request,
|
||||
localAddress: "192.168.1.10"
|
||||
)
|
||||
|
||||
#expect(prepared.poolKey.localAddress == "192.168.1.10")
|
||||
}
|
||||
|
||||
@Test func noLocalAddressWhenNeitherSet() throws {
|
||||
let request = HTTPClientRequest(url: "https://example.com/get")
|
||||
|
||||
let prepared = try HTTPClientRequest.Prepared(request)
|
||||
|
||||
#expect(prepared.poolKey.localAddress == nil)
|
||||
}
|
||||
|
||||
// MARK: - Redirect preserves localAddress
|
||||
|
||||
@Test func redirectPreservesLocalAddress() {
|
||||
var request = HTTPClientRequest(url: "https://example.com/redirect/301")
|
||||
request.localAddress = "192.168.1.10"
|
||||
|
||||
let redirected = request.followingRedirect(
|
||||
from: URL(string: "https://example.com/redirect/301")!,
|
||||
to: URL(string: "https://other.com/ok")!,
|
||||
status: .movedPermanently,
|
||||
config: .init(
|
||||
max: 5,
|
||||
allowCycles: false,
|
||||
retainHTTPMethodAndBodyOn301: false,
|
||||
retainHTTPMethodAndBodyOn302: false
|
||||
)
|
||||
)
|
||||
|
||||
#expect(redirected.localAddress == "192.168.1.10")
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,11 @@ import NIOHTTPCompression
|
||||
import NIOPosix
|
||||
import NIOSSL
|
||||
import NIOTestUtils
|
||||
import NIOTransportServices
|
||||
import XCTest
|
||||
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
import NIOTransportServices
|
||||
#endif
|
||||
|
||||
final class NoBytesSentOverBodyLimitTests: XCTestCaseHTTPClientTestsBaseClass {
|
||||
|
||||
@@ -23,11 +23,11 @@ import NIOHTTPCompression
|
||||
import NIOPosix
|
||||
import NIOSSL
|
||||
import NIOTestUtils
|
||||
import NIOTransportServices
|
||||
import XCTest
|
||||
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
import NIOTransportServices
|
||||
#endif
|
||||
|
||||
final class RacePoolIdleConnectionsAndGetTests: XCTestCaseHTTPClientTestsBaseClass {
|
||||
|
||||
@@ -1013,6 +1013,40 @@ final class RequestBagTests: XCTestCase {
|
||||
}
|
||||
XCTAssertTrue(isKnownUniquelyReferenced(&leakDetector))
|
||||
}
|
||||
|
||||
func testRequestBagPassesLocalAddressToPoolKey() throws {
|
||||
let request = try HTTPClient.Request(url: "https://example.com/get")
|
||||
let eventLoop = EmbeddedEventLoop()
|
||||
defer { XCTAssertNoThrow(try eventLoop.syncShutdownGracefully()) }
|
||||
let task = HTTPClient.Task<HTTPClient.Response>(eventLoop: eventLoop, logger: .init(label: "test"))
|
||||
let requestBag = try RequestBag(
|
||||
request: request,
|
||||
eventLoopPreference: .indifferent,
|
||||
task: task,
|
||||
redirectHandler: nil,
|
||||
connectionDeadline: .distantFuture,
|
||||
requestOptions: .forTests(localAddress: "10.0.0.1"),
|
||||
delegate: ResponseAccumulator(request: request)
|
||||
)
|
||||
XCTAssertEqual(requestBag.poolKey.localAddress, "10.0.0.1")
|
||||
}
|
||||
|
||||
func testRequestBagPoolKeyNilLocalAddressByDefault() throws {
|
||||
let request = try HTTPClient.Request(url: "https://example.com/get")
|
||||
let eventLoop = EmbeddedEventLoop()
|
||||
defer { XCTAssertNoThrow(try eventLoop.syncShutdownGracefully()) }
|
||||
let task = HTTPClient.Task<HTTPClient.Response>(eventLoop: eventLoop, logger: .init(label: "test"))
|
||||
let requestBag = try RequestBag(
|
||||
request: request,
|
||||
eventLoopPreference: .indifferent,
|
||||
task: task,
|
||||
redirectHandler: nil,
|
||||
connectionDeadline: .distantFuture,
|
||||
requestOptions: .forTests(),
|
||||
delegate: ResponseAccumulator(request: request)
|
||||
)
|
||||
XCTAssertNil(requestBag.poolKey.localAddress)
|
||||
}
|
||||
}
|
||||
|
||||
extension HTTPClient.Task {
|
||||
@@ -1137,12 +1171,14 @@ extension RequestOptions {
|
||||
static func forTests(
|
||||
idleReadTimeout: TimeAmount? = nil,
|
||||
idleWriteTimeout: TimeAmount? = nil,
|
||||
dnsOverride: [String: String] = [:]
|
||||
dnsOverride: [String: String] = [:],
|
||||
localAddress: String? = nil
|
||||
) -> Self {
|
||||
RequestOptions(
|
||||
idleReadTimeout: idleReadTimeout,
|
||||
idleWriteTimeout: idleWriteTimeout,
|
||||
dnsOverride: dnsOverride
|
||||
dnsOverride: dnsOverride,
|
||||
localAddress: localAddress
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBwTCCAUigAwIBAgIUX7f9BABxGdAqG5EvLpQScFt9lOkwCgYIKoZIzj0EAwMw
|
||||
MIIBwTCCAUigAwIBAgIUPvvxS7euko8ZalJR2qIMlbfuA5MwCgYIKoZIzj0EAwMw
|
||||
KjEUMBIGA1UECgwLU2VsZiBTaWduZWQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0y
|
||||
NTA0MDExNDMwMTFaFw0yNjA0MDExNDMwMTFaMCoxFDASBgNVBAoMC1NlbGYgU2ln
|
||||
bmVkMRIwEAYDVQQDDAlsb2NhbGhvc3QwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQW
|
||||
szfO5HCWIWgKUqyXUU0pFpYgaq01RRL69XZz1CkV6XTrxMfIvvwez2886EQDL8QX
|
||||
i5NpKg3qvPgWuDjVHaj4WEJe5XMNqcujxcTufBlmaQ6o4vtoK7CIHDIDldF/HRij
|
||||
NjA0MDIxNjMzMjJaFw0yNzA0MDIxNjMzMjJaMCoxFDASBgNVBAoMC1NlbGYgU2ln
|
||||
bmVkMRIwEAYDVQQDDAlsb2NhbGhvc3QwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT5
|
||||
l93hAf9RYmRKfAE3JpbJIgsr1pHpjnUtzT99HTTLJFQpOSFqreZ7mPMzUGcrh/gW
|
||||
0C59HpW2759OpbqxkC1zUcaQLiazLDGsprjXfHpFpJ5D033eefew/PysOQ5dFKej
|
||||
LzAtMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMBMGA1UdJQQMMAoGCCsGAQUFBwMB
|
||||
MAoGCCqGSM49BAMDA2cAMGQCMBJ8Dxg0qX2bEZ3r6dI3UCGAUYxJDVk+XhiIY1Fm
|
||||
5FJeQqhaVayCRPrPXXGZUJGY/wIwXej70FwkxHKLq+XxfHTC5CzmoOK469C9Rk9Y
|
||||
ucddXM83ebFxVNgRCWetH9tDdXJ9
|
||||
-----END CERTIFICATE-----
|
||||
MAoGCCqGSM49BAMDA2cAMGQCMFRSS0Ti9ndTwIt8Fg0ys9RyfuUj2JxDF4bIOF5g
|
||||
hLGt1ZwPOukALbwGE5riOCk7gAIwQdOdW8y8UxnODjeWAoFAUeFDgFplhrpvJAnp
|
||||
0sjx8oJH4Vd15Hvhoy7ZqeCh0O8P
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDD9v51MTOcgFIbiHbok
|
||||
U+QOubosGF1u1q+D3fEUb1U2cgjCofKmPHekXTz0xu9MJi2hZANiAAQWszfO5HCW
|
||||
IWgKUqyXUU0pFpYgaq01RRL69XZz1CkV6XTrxMfIvvwez2886EQDL8QXi5NpKg3q
|
||||
vPgWuDjVHaj4WEJe5XMNqcujxcTufBlmaQ6o4vtoK7CIHDIDldF/HRg=
|
||||
-----END PRIVATE KEY-----
|
||||
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDA/iiVlk1ZaSDe6Tu7N
|
||||
4Dxwa0AKR6U5OFy1U/VfvRbXs/IUJGi860oiquYLgny/eTehZANiAAT5l93hAf9R
|
||||
YmRKfAE3JpbJIgsr1pHpjnUtzT99HTTLJFQpOSFqreZ7mPMzUGcrh/gW0C59HpW2
|
||||
759OpbqxkC1zUcaQLiazLDGsprjXfHpFpJ5D033eefew/PysOQ5dFKc=
|
||||
-----END PRIVATE KEY-----
|
||||
|
||||
@@ -23,11 +23,11 @@ import NIOHTTPCompression
|
||||
import NIOPosix
|
||||
import NIOSSL
|
||||
import NIOTestUtils
|
||||
import NIOTransportServices
|
||||
import XCTest
|
||||
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
import NIOTransportServices
|
||||
#endif
|
||||
|
||||
final class ResponseDelayGetTests: XCTestCaseHTTPClientTestsBaseClass {
|
||||
|
||||
@@ -23,11 +23,11 @@ import NIOHTTPCompression
|
||||
import NIOPosix
|
||||
import NIOSSL
|
||||
import NIOTestUtils
|
||||
import NIOTransportServices
|
||||
import XCTest
|
||||
|
||||
#if canImport(Network)
|
||||
import Network
|
||||
import NIOTransportServices
|
||||
#endif
|
||||
|
||||
final class StressGetHttpsTests: XCTestCaseHTTPClientTestsBaseClass {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// swift-tools-version: 6.2
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "linkage-test",
|
||||
dependencies: [
|
||||
.package(name: "async-http-client", path: "../..")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "linkageTest",
|
||||
dependencies: [
|
||||
.product(name: "AsyncHTTPClient", package: "async-http-client")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
//
|
||||
// This source file is part of the AsyncHTTPClient open source project
|
||||
//
|
||||
// Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors
|
||||
// Licensed under Apache License v2.0
|
||||
//
|
||||
// See LICENSE.txt for license information
|
||||
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import AsyncHTTPClient
|
||||
|
||||
print("\(HTTPClient.shared)")
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
##===----------------------------------------------------------------------===##
|
||||
##
|
||||
## This source file is part of the AsyncHTTPClient open source project
|
||||
##
|
||||
## Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors
|
||||
## Licensed under Apache License v2.0
|
||||
##
|
||||
## See LICENSE.txt for license information
|
||||
## See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
|
||||
##
|
||||
## SPDX-License-Identifier: Apache-2.0
|
||||
##
|
||||
##===----------------------------------------------------------------------===##
|
||||
set -eu
|
||||
|
||||
# Validate that we're running on Linux
|
||||
if [[ "$(uname -s)" != "Linux" ]]; then
|
||||
echo "Error: This script must be run on Linux. Current OS: $(uname -s)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running on Linux - proceeding with linkage test..."
|
||||
|
||||
# Build the linkage test package
|
||||
echo "Building linkage test package..."
|
||||
swift build --package-path Tests/LinkageTest
|
||||
|
||||
# Construct build path
|
||||
build_path=$(swift build --package-path Tests/LinkageTest --show-bin-path)
|
||||
binary_path=$build_path/linkageTest
|
||||
|
||||
# Verify the binary exists
|
||||
if [[ ! -f "$binary_path" ]]; then
|
||||
echo "Error: Built binary not found at $binary_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Checking linkage for binary: $binary_path"
|
||||
|
||||
# Run ldd and check if libFoundation.so is linked
|
||||
ldd_output=$(ldd "$binary_path")
|
||||
echo "LDD output:"
|
||||
echo "$ldd_output"
|
||||
|
||||
if echo "$ldd_output" | grep -q "libFoundation.so"; then
|
||||
echo "Error: Binary is linked against libFoundation.so - this indicates incorrect linkage. Ensure the full Foundation is not linked on Linux when default traits are disabled." >&2
|
||||
exit 1
|
||||
else
|
||||
echo "Success: Binary is not linked against libFoundation.so - linkage test passed."
|
||||
fi
|
||||
Reference in New Issue
Block a user