diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 5167181e9..ef9ca1326 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -17,8 +17,6 @@ jobs: name: Unit tests uses: apple/swift-nio/.github/workflows/unit_tests.yml@main with: - linux_5_10_arguments_override: "--explicit-target-dependency-import-check error -Xswiftc -warnings-as-errors" - linux_6_0_arguments_override: "--explicit-target-dependency-import-check error -Xswiftc -warnings-as-errors" linux_6_1_arguments_override: "--explicit-target-dependency-import-check error -Xswiftc -warnings-as-errors" linux_6_2_arguments_override: "--explicit-target-dependency-import-check error -Xswiftc -warnings-as-errors" linux_nightly_next_arguments_override: "--explicit-target-dependency-import-check error" @@ -27,8 +25,6 @@ jobs: cxx-interop: name: Cxx interop uses: apple/swift-nio/.github/workflows/cxx_interop.yml@main - with: - linux_5_9_enabled: false static-sdk: name: Static SDK @@ -38,3 +34,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 }}' diff --git a/Package.swift b/Package.swift index a41a5d1ed..460b0dc0f 100644 --- a/Package.swift +++ b/Package.swift @@ -45,7 +45,7 @@ let package = Package( .library(name: "AsyncHTTPClient", targets: ["AsyncHTTPClient"]) ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.100.0"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.30.0"), .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.36.0"), .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.26.0"), @@ -54,7 +54,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"), ], targets: [ @@ -78,7 +79,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"), diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift deleted file mode 100644 index aad0c1c53..000000000 --- a/Package@swift-6.0.swift +++ /dev/null @@ -1,124 +0,0 @@ -// swift-tools-version:6.0 -//===----------------------------------------------------------------------===// -// -// This source file is part of the AsyncHTTPClient open source project -// -// Copyright (c) 2018-2019 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 PackageDescription - -let strictConcurrencyDevelopment = false - -let strictConcurrencySettings: [SwiftSetting] = { - var initialSettings: [SwiftSetting] = [] - - if strictConcurrencyDevelopment { - // -warnings-as-errors here is a workaround so that IDE-based development can - // get tripped up on -require-explicit-sendable. - initialSettings.append(.unsafeFlags(["-Xfrontend", "-require-explicit-sendable", "-warnings-as-errors"])) - } - - return initialSettings -}() - -let package = Package( - name: "async-http-client", - products: [ - .library(name: "AsyncHTTPClient", targets: ["AsyncHTTPClient"]) - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"), - .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.30.0"), - .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.36.0"), - .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.26.0"), - .package(url: "https://github.com/apple/swift-nio-transport-services.git", from: "1.24.0"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.7.1"), - .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"), - ], - targets: [ - .target( - name: "CAsyncHTTPClient", - cSettings: [ - .define("_GNU_SOURCE") - ] - ), - .target( - name: "AsyncHTTPClient", - dependencies: [ - .target(name: "CAsyncHTTPClient"), - .product(name: "NIO", package: "swift-nio"), - .product(name: "NIOTLS", package: "swift-nio"), - .product(name: "NIOCore", package: "swift-nio"), - .product(name: "NIOPosix", package: "swift-nio"), - .product(name: "NIOHTTP1", package: "swift-nio"), - .product(name: "NIOConcurrencyHelpers", package: "swift-nio"), - .product(name: "NIOHTTP2", package: "swift-nio-http2"), - .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: "Atomics", package: "swift-atomics"), - .product(name: "Algorithms", package: "swift-algorithms"), - // Observability support - .product(name: "Logging", package: "swift-log"), - .product(name: "Tracing", package: "swift-distributed-tracing"), - ], - swiftSettings: strictConcurrencySettings - ), - .testTarget( - name: "AsyncHTTPClientTests", - dependencies: [ - .target(name: "AsyncHTTPClient"), - .product(name: "NIOTLS", package: "swift-nio"), - .product(name: "NIOCore", package: "swift-nio"), - .product(name: "NIOConcurrencyHelpers", package: "swift-nio"), - .product(name: "NIOEmbedded", package: "swift-nio"), - .product(name: "NIOFoundationCompat", package: "swift-nio"), - .product(name: "NIOTestUtils", package: "swift-nio"), - .product(name: "NIOSSL", package: "swift-nio-ssl"), - .product(name: "NIOHTTP2", package: "swift-nio-http2"), - .product(name: "NIOSOCKS", package: "swift-nio-extras"), - .product(name: "Atomics", package: "swift-atomics"), - .product(name: "Algorithms", package: "swift-algorithms"), - // Observability support - .product(name: "Logging", package: "swift-log"), - .product(name: "InMemoryLogging", package: "swift-log"), - .product(name: "Tracing", package: "swift-distributed-tracing"), - .product(name: "InMemoryTracing", package: "swift-distributed-tracing"), - ], - resources: [ - .copy("Resources/self_signed_cert.pem"), - .copy("Resources/self_signed_key.pem"), - .copy("Resources/example.com.cert.pem"), - .copy("Resources/example.com.private-key.pem"), - ], - swiftSettings: strictConcurrencySettings - ), - ] -) - -// --- STANDARD CROSS-REPO SETTINGS DO NOT EDIT --- // -for target in package.targets { - switch target.type { - case .regular, .test, .executable: - var settings = target.swiftSettings ?? [] - // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0444-member-import-visibility.md - settings.append(.enableUpcomingFeature("MemberImportVisibility")) - target.swiftSettings = settings - case .macro, .plugin, .system, .binary: - () // not applicable - @unknown default: - () // we don't know what to do here, do nothing - } -} -// --- END: STANDARD CROSS-REPO SETTINGS DO NOT EDIT --- // diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift index aad0c1c53..eb896f89b 100644 --- a/Package@swift-6.1.swift +++ b/Package@swift-6.1.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.0 +// swift-tools-version:6.1 //===----------------------------------------------------------------------===// // // This source file is part of the AsyncHTTPClient open source project @@ -35,7 +35,7 @@ let package = Package( .library(name: "AsyncHTTPClient", targets: ["AsyncHTTPClient"]) ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.100.0"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.30.0"), .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.36.0"), .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.26.0"), diff --git a/README.md b/README.md index b557e58fa..eab0173f7 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,7 @@ Please have a look at [SECURITY.md](SECURITY.md) for AsyncHTTPClient's security ## Supported Versions -The most recent versions of AsyncHTTPClient support Swift 6.0 and newer. The minimum Swift version supported by AsyncHTTPClient releases are detailed below: +The most recent versions of AsyncHTTPClient support Swift 6.1 and newer. The minimum Swift version supported by AsyncHTTPClient releases are detailed below: AsyncHTTPClient | Minimum Swift Version --------------------|---------------------- @@ -319,4 +319,5 @@ AsyncHTTPClient | Minimum Swift Version `1.21.0 ..< 1.26.0` | 5.8 `1.26.0 ..< 1.27.0` | 5.9 `1.27.0 ..< 1.30.0` | 5.10 -`1.30.0 ...` | 6.0 +`1.30.0 ..< 1.34.0` | 6.0 +`1.34.0 ...` | 6.1 diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift index b77cb9527..a1047fa85 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift @@ -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 { diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift index df2bfa54b..c67b60f74 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift @@ -18,7 +18,11 @@ import NIOHTTP1 import NIOSSL import ServiceContextModule +#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 HTTPClientRequest { @@ -52,6 +56,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 { @@ -75,7 +80,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, @@ -104,8 +114,8 @@ extension HTTPClientRequest.Prepared.Body { case .byteBuffer(let byteBuffer): self = .byteBuffer(byteBuffer) - case .httpClientRequestBody(let lenght, let requestBody): - self = .httpClientRequestBody(lenght, requestBody) + case .httpClientRequestBody(let length, let requestBody): + self = .httpClientRequestBody(length, requestBody) } } } @@ -147,6 +157,7 @@ extension HTTPClientRequest { newRequest.method = method newRequest.headers = headers newRequest.body = body + newRequest.localAddress = self.localAddress return newRequest } } diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+auth.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+auth.swift index 106a8f76b..ca9aba356 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+auth.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+auth.swift @@ -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 { diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift index feb98baf2..4574519e1 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift @@ -17,10 +17,6 @@ import NIOCore import NIOHTTP1 import NIOSSL -#if canImport(HTTPAPIs) -import HTTPAPIs -#endif - @usableFromInline let bagOfBytesToByteBufferConversionChunkSize = 1024 * 1024 * 4 @@ -57,12 +53,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 } } @@ -114,7 +118,12 @@ extension HTTPClientRequest { @_spi(ExperimentalHTTPAPIsSupport) public init(length: Int64?, startUpload: AsyncStream.Continuation) { let length = length.map { RequestBodyLength.known($0) } ?? .unknown - self.init(.httpClientRequestBody(length: length, startUpload: RequestWriterContinuation(continuation: startUpload))) + self.init( + .httpClientRequestBody( + length: length, + startUpload: RequestWriterContinuation(continuation: startUpload) + ) + ) } @usableFromInline @@ -431,10 +440,8 @@ extension HTTPClientRequest.Body: AsyncSequence { return .init(storage: .byteBuffer(makeCompleteBody(AsyncIterator.allocator))) case .byteBuffer(let byteBuffer): return .init(storage: .byteBuffer(byteBuffer)) - #if canImport(HTTPAPIs) case .httpClientRequestBody: fatalError("Unimplemented") - #endif } } } diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientResponse.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientResponse.swift index fdee33cf1..514e22fb2 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientResponse.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientResponse.swift @@ -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. /// @@ -174,7 +178,7 @@ extension HTTPClientResponse { switch self.storage { case .transaction(_, let transaction, _): return transaction.trailers - + case .anyAsyncSequence: return nil } diff --git a/Sources/AsyncHTTPClient/AsyncAwait/Transaction.swift b/Sources/AsyncHTTPClient/AsyncAwait/Transaction.swift index d78d50671..6f87fa03d 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/Transaction.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/Transaction.swift @@ -235,10 +235,8 @@ extension Transaction: HTTPExecutableRequest { let byteBuffer = create(allocator) self.writeOnceAndOneTimeOnly(byteBuffer: byteBuffer) - #if canImport(HTTPAPIs) case .httpClientRequestBody(_, let continuation): continuation.continuation.yield(HTTPClientRequest.Body.RequestWriter(transaction: self)) - #endif case .none: break @@ -414,4 +412,3 @@ extension Transaction: NIOAsyncSequenceProducerDelegate { self.httpResponseStreamTerminated() } } - diff --git a/Sources/AsyncHTTPClient/BasicAuth.swift b/Sources/AsyncHTTPClient/BasicAuth.swift index 3e69f8277..2a0260eb7 100644 --- a/Sources/AsyncHTTPClient/BasicAuth.swift +++ b/Sources/AsyncHTTPClient/BasicAuth.swift @@ -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: diff --git a/Sources/AsyncHTTPClient/ConnectionPool.swift b/Sources/AsyncHTTPClient/ConnectionPool.swift index 3b45eca05..a659df27b 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool.swift @@ -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 ) } } diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTP1/HTTP1ClientChannelHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTP1/HTTP1ClientChannelHandler.swift index 9ca82f9a9..e59f0ab7e 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTP1/HTTP1ClientChannelHandler.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTP1/HTTP1ClientChannelHandler.swift @@ -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) diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index 3dc47c5ae..402651909 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -245,14 +245,19 @@ extension HTTPConnectionPool.ConnectionFactory { promise: EventLoopPromise ) { 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( @@ -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,38 @@ 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) + switch clientConfiguration.dnsResolver.backing { + case .system: + break + case .randomized: + bootstrap = bootstrap.resolver(NIORandomizedDNSResolver(loop: eventLoop)) + } + 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 +568,10 @@ extension HTTPConnectionPool.ConnectionFactory { eventLoop: EventLoop, logger: Logger ) -> EventLoopFuture { + 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 +587,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 +619,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 +640,26 @@ 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) + switch clientConfiguration.dnsResolver.backing { + case .system: + break + case .randomized: + bootstrap = bootstrap.resolver(NIORandomizedDNSResolver(loop: eventLoop)) + } + 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 in do { diff --git a/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift b/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift index 903f962e5..bf33a95bd 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift @@ -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 ) } } diff --git a/Sources/AsyncHTTPClient/DeconstructedURL.swift b/Sources/AsyncHTTPClient/DeconstructedURL.swift index f7d0b1977..96a16d87d 100644 --- a/Sources/AsyncHTTPClient/DeconstructedURL.swift +++ b/Sources/AsyncHTTPClient/DeconstructedURL.swift @@ -12,7 +12,11 @@ // //===----------------------------------------------------------------------===// +#if canImport(FoundationEssentials) +import struct FoundationEssentials.URL +#else import struct Foundation.URL +#endif struct DeconstructedURL { var scheme: Scheme diff --git a/Sources/AsyncHTTPClient/FileDownloadDelegate.swift b/Sources/AsyncHTTPClient/FileDownloadDelegate.swift index 33a4d3cb2..4076d9559 100644 --- a/Sources/AsyncHTTPClient/FileDownloadDelegate.swift +++ b/Sources/AsyncHTTPClient/FileDownloadDelegate.swift @@ -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 { diff --git a/Sources/AsyncHTTPClient/FoundationExtensions.swift b/Sources/AsyncHTTPClient/FoundationExtensions.swift index 452cb7b13..eeff2ad7e 100644 --- a/Sources/AsyncHTTPClient/FoundationExtensions.swift +++ b/Sources/AsyncHTTPClient/FoundationExtensions.swift @@ -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[.. 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 + } + } +} diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 1aa37fb7e..863874001 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -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)" @@ -838,6 +847,21 @@ public final class HTTPClient: Sendable { /// ``HTTPClient`` will still request certificates from the server for `example.com` and validate them as if we would connect to `example.com`. public var dnsOverride: [String: String] = [:] + /// Controls which DNS resolver is used for hostname resolution. + /// + /// By default, the system resolver (`getaddrinfo`) is used, which returns addresses + /// in the order produced by the platform's `getaddrinfo` (typically following + /// RFC 6724 destination-address selection). Set to ``DNSResolver/randomized`` to + /// shuffle addresses for DNS-based load balancing with services that have multiple + /// A/AAAA records (e.g. Kubernetes headless services). + /// + /// - Note: This setting has no effect when connections run on an `NIOTSEventLoopGroup`, + /// which is the default on Apple platforms (macOS 10.14+, iOS/tvOS 12+, watchOS 6+). + /// Network.framework performs its own DNS resolution and does not expose a resolver hook. + /// To use the randomized resolver there, pass a `MultiThreadedEventLoopGroup` via + /// ``HTTPClient/EventLoopGroupProvider/shared(_:)``. + public var dnsResolver: DNSResolver = .system + /// Enables following 3xx redirects automatically. /// /// Following redirects are supported: @@ -895,6 +919,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)? @@ -925,6 +963,7 @@ public final class HTTPClient: Sendable { self.httpVersion = .automatic self.networkFrameworkWaitForConnectivity = true self.enableMultipath = false + self.localAddress = nil } public init( @@ -1394,6 +1433,30 @@ extension HTTPClient.Configuration { } } + /// Controls which DNS resolver is used for hostname resolution. + public struct DNSResolver: Sendable, Hashable { + enum Backing: Sendable, Hashable { + case system + case randomized + } + + let backing: Backing + + private init(backing: Backing) { + self.backing = backing + } + + /// Use the system's default DNS resolver (`getaddrinfo`). + /// Addresses are returned in the order produced by the platform's `getaddrinfo`, + /// typically following RFC 6724 destination-address selection. + public static let system: Self = .init(backing: .system) + + /// Use a randomized DNS resolver that shuffles the addresses + /// returned by `getaddrinfo`. This enables DNS-based load balancing + /// for services with multiple A/AAAA records (e.g. Kubernetes headless services). + public static let randomized: Self = .init(backing: .randomized) + } + public struct HTTPVersion: Sendable, Hashable { enum Configuration: String { case http1Only @@ -1452,6 +1515,8 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { case invalidRedirectConfiguration case invalidHTTPVersionConfiguration case invalidDNSOverridesConfiguration + case invalidLocalAddress + case invalidProxyConfiguration case internalStateFailure(file: String, line: UInt) } @@ -1545,6 +1610,10 @@ 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 .invalidProxyConfiguration: + return "The proxy configuration is not valid" 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 +1717,12 @@ 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) + + /// The proxy configuration is not valid. + public static let invalidProxyConfiguration = HTTPClientError(code: .invalidProxyConfiguration) + /// 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)) diff --git a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift index 3015dfadd..02c9726ff 100644 --- a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift +++ b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift @@ -25,11 +25,13 @@ extension HTTPClient.Configuration { /// - `redirect` (scoped): Redirect handling configuration read by ``RedirectConfiguration/init(configReader:)``. /// - `timeout` (scoped): Timeout configuration read by ``Timeout/init(configReader:)``. /// - `connectionPool` (scoped): Connection pool configuration read by ``ConnectionPool/init(configReader:)``. + /// - `proxy` (scoped, optional): Proxy configuration read by ``Proxy/init(configReader:)``. Only applied if `proxy.enabled` is `true`. /// - `httpVersion` (string, optional, default: automatic): HTTP version to use ( "automatic" or "http1Only"). /// - `maximumUsesPerConnection` (int, optional, default: nil, no limit): Maximum uses per connection. /// /// - Throws: `HTTPClientError.invalidRedirectConfiguration` if redirect mode is invalid. /// - Throws: `HTTPClientError.invalidHTTPVersionConfiguration` if httpVersion is specified but invalid. + /// - Throws: `HTTPClientError.invalidProxyConfiguration` if proxy configuration is invalid. public init(configReader: ConfigReader) throws { self.init() @@ -51,6 +53,9 @@ extension HTTPClient.Configuration { self.redirectConfiguration = try .init(configReader: configReader.scoped(to: "redirect")) self.timeout = .init(configReader: configReader.scoped(to: "timeout")) self.connectionPool = .init(configReader: configReader.scoped(to: "connectionPool")) + if let proxy = try Proxy(configReader: configReader.scoped(to: "proxy")) { + self.proxy = proxy + } if let version = try HTTPVersion(configReader: configReader) { self.httpVersion = version } @@ -148,4 +153,79 @@ extension HTTPClient.Configuration.HTTPVersion { self = .init(configuration: base) } } + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension HTTPClient.Configuration.Proxy { + /// Initializes proxy configuration from a ConfigReader. + /// + /// ## Configuration keys: + /// - `enabled` (bool, optional, default: false): Whether proxy support is enabled. If `false`, the initializer returns `nil`. + /// - `host` (string): Proxy server host. Required when `enabled` is `true`. + /// - `port` (int): Proxy server port. Required for `http` proxies; defaults to `1080` for `socks` proxies. + /// - `type` (string, optional, default: "http"): Proxy type ("http" or "socks"). + /// - `authorization` (scoped, optional): Authorization configuration read by ``HTTPClient/Authorization/init(configReader:)``. + /// Only supported for `http` proxies. + /// + /// - Throws: `HTTPClientError.invalidProxyConfiguration` if `enabled` is `true` but `host` is missing, `type` is unknown, + /// `port` is missing for an HTTP proxy, or `authorization` is specified for a SOCKS proxy, or `authorization` is invalid (see ``HTTPClient/Authorization/init(configReader:)``) + public init?(configReader: ConfigReader) throws { + guard configReader.bool(forKey: "enabled", default: false) else { + return nil + } + let host = try configReader.requiredString(forKey: "host") + let type = configReader.string(forKey: "type", default: "http") + let authorization = try HTTPClient.Authorization(configReader: configReader.scoped(to: "authorization")) + switch type { + case "http": + let port = try configReader.requiredInt(forKey: "port") + self = .server(host: host, port: port, authorization: authorization) + case "socks": + if authorization != nil { + throw HTTPClientError.invalidProxyConfiguration + } + let port = configReader.int(forKey: "port", default: 1080) + self = .socksServer(host: host, port: port) + default: + throw HTTPClientError.invalidProxyConfiguration + } + } +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension HTTPClient.Authorization { + /// Initializes HTTP authorization from a ConfigReader. + /// + /// ## Configuration keys: + /// - `scheme` (string, optional): Authorization scheme ("basic" or "bearer"). If absent, the initializer returns `nil`. + /// - `username` (string): Username for basic authentication. Required (alongside `password`) when `scheme` is "basic" and `credentials` is not set. + /// - `password` (string): Password for basic authentication. Required (alongside `username`) when `scheme` is "basic" and `credentials` is not set. + /// - `credentials` (string): Pre-encoded basic credentials. Used when `scheme` is "basic" and `username`/`password` are not both provided. + /// - `token` (string): Bearer token. Required when `scheme` is "bearer". + /// + /// - Throws: `HTTPClientError.invalidProxyConfiguration` if `scheme` is unknown or required keys are missing. + public init?(configReader: ConfigReader) throws { + guard let scheme = configReader.string(forKey: "scheme") else { + return nil + } + switch scheme { + case "basic": + if let username = configReader.string(forKey: "username"), + let password = configReader.string(forKey: "password") + { + self = .basic(username: username, password: password) + } else if let credentials = configReader.string(forKey: "credentials") { + self = .basic(credentials: credentials) + } else { + throw HTTPClientError.invalidProxyConfiguration + } + case "bearer": + guard let token = configReader.string(forKey: "token") else { + throw HTTPClientError.invalidProxyConfiguration + } + self = .bearer(tokens: token) + default: + throw HTTPClientError.invalidProxyConfiguration + } + } +} #endif diff --git a/Sources/AsyncHTTPClient/HTTPHandler.swift b/Sources/AsyncHTTPClient/HTTPHandler.swift index 9c7becb0b..4c4cc6f9a 100644 --- a/Sources/AsyncHTTPClient/HTTPHandler.swift +++ b/Sources/AsyncHTTPClient/HTTPHandler.swift @@ -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)" diff --git a/Sources/AsyncHTTPClient/NIOTransportServices/NWErrorHandler.swift b/Sources/AsyncHTTPClient/NIOTransportServices/NWErrorHandler.swift index 148b4a4c4..704329b89 100644 --- a/Sources/AsyncHTTPClient/NIOTransportServices/NWErrorHandler.swift +++ b/Sources/AsyncHTTPClient/NIOTransportServices/NWErrorHandler.swift @@ -14,10 +14,10 @@ import NIOCore import NIOHTTP1 -import NIOTransportServices #if canImport(Network) import Network +import NIOTransportServices #endif extension HTTPClient { diff --git a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift index e8278e095..ad3e65074 100644 --- a/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift +++ b/Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift @@ -14,7 +14,12 @@ #if canImport(Network) +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif +import Dispatch import Network import NIOCore import NIOSSL diff --git a/Sources/AsyncHTTPClient/RedirectState.swift b/Sources/AsyncHTTPClient/RedirectState.swift index 4e14712d6..9665c03d4 100644 --- a/Sources/AsyncHTTPClient/RedirectState.swift +++ b/Sources/AsyncHTTPClient/RedirectState.swift @@ -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 diff --git a/Sources/AsyncHTTPClient/RequestBag+StateMachine.swift b/Sources/AsyncHTTPClient/RequestBag+StateMachine.swift index 7accdc51a..8f5cd37ce 100644 --- a/Sources/AsyncHTTPClient/RequestBag+StateMachine.swift +++ b/Sources/AsyncHTTPClient/RequestBag+StateMachine.swift @@ -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. diff --git a/Sources/AsyncHTTPClient/RequestBag+Tracing.swift b/Sources/AsyncHTTPClient/RequestBag+Tracing.swift index 729b6256a..5551459d1 100644 --- a/Sources/AsyncHTTPClient/RequestBag+Tracing.swift +++ b/Sources/AsyncHTTPClient/RequestBag+Tracing.swift @@ -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(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 { diff --git a/Sources/AsyncHTTPClient/RequestBag.swift b/Sources/AsyncHTTPClient/RequestBag.swift index 67385e3f1..1122aa8e0 100644 --- a/Sources/AsyncHTTPClient/RequestBag.swift +++ b/Sources/AsyncHTTPClient/RequestBag.swift @@ -96,7 +96,11 @@ final class RequestBag: 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 diff --git a/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift b/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift index 43430b85c..78dec4296 100644 --- a/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift +++ b/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift @@ -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: @unchecked Sendable { diff --git a/Tests/AsyncHTTPClientTests/ConnectionPoolSizeConfigValueIsRespectedTests.swift b/Tests/AsyncHTTPClientTests/ConnectionPoolSizeConfigValueIsRespectedTests.swift index 962791334..1bdaba2e7 100644 --- a/Tests/AsyncHTTPClientTests/ConnectionPoolSizeConfigValueIsRespectedTests.swift +++ b/Tests/AsyncHTTPClientTests/ConnectionPoolSizeConfigValueIsRespectedTests.swift @@ -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 { diff --git a/Tests/AsyncHTTPClientTests/FoundationExtensionTests.swift b/Tests/AsyncHTTPClientTests/FoundationExtensionTests.swift new file mode 100644 index 000000000..4ff0d6c26 --- /dev/null +++ b/Tests/AsyncHTTPClientTests/FoundationExtensionTests.swift @@ -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))'" + ) + } +} diff --git a/Tests/AsyncHTTPClientTests/HTTP1ClientChannelHandlerTests.swift b/Tests/AsyncHTTPClientTests/HTTP1ClientChannelHandlerTests.swift index 401c14ff0..f0f278687 100644 --- a/Tests/AsyncHTTPClientTests/HTTP1ClientChannelHandlerTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTP1ClientChannelHandlerTests.swift @@ -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? + + func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + 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.size == 8 { XCTAssertEqual(ResponseAccumulator.maxByteBufferSize, Int(UInt32.max)) diff --git a/Tests/AsyncHTTPClientTests/HTTPClientBase.swift b/Tests/AsyncHTTPClientTests/HTTPClientBase.swift index 90ab12fe5..cca04bfd9 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientBase.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientBase.swift @@ -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 { diff --git a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift index 4c2d24dc4..eb43e8cd9 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift @@ -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 { diff --git a/Tests/AsyncHTTPClientTests/HTTPClientRequestTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientRequestTests.swift index 54d9e0808..9208352ef 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientRequestTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientRequestTests.swift @@ -783,10 +783,8 @@ extension Optional where Wrapped == HTTPClientRequest.Prepared.Body { } return accumulatedBuffer - #if canImport(HTTPAPIs) case .httpClientRequestBody: fatalError("TODO: Unimplemented") - #endif } } } diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift b/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift index 1e9aacf29..ea80cee6e 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift @@ -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 ?? "" diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index 959e0f939..75371d437 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -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 { @@ -47,11 +47,7 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { let request3 = try Request(url: "unix:///tmp/file") XCTAssertEqual(request3.host, "") - #if os(Linux) && compiler(<6.1) - XCTAssertEqual(request3.url.host, "") - #else XCTAssertNil(request3.url.host) - #endif XCTAssertEqual(request3.url.path, "/tmp/file") XCTAssertEqual(request3.port, 80) XCTAssertFalse(request3.useTLS) @@ -4644,6 +4640,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 { diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTracingInternalTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTracingInternalTests.swift index 53f1138ba..3b9e86c5b 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTracingInternalTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTracingInternalTests.swift @@ -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 { diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift index 047c66e6d..7e8d09d1e 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift @@ -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 { diff --git a/Tests/AsyncHTTPClientTests/IdleTimeoutNoReuseTests.swift b/Tests/AsyncHTTPClientTests/IdleTimeoutNoReuseTests.swift index e9a0d46dc..bc44012ed 100644 --- a/Tests/AsyncHTTPClientTests/IdleTimeoutNoReuseTests.swift +++ b/Tests/AsyncHTTPClientTests/IdleTimeoutNoReuseTests.swift @@ -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 { diff --git a/Tests/AsyncHTTPClientTests/LocalAddressOverrideTests.swift b/Tests/AsyncHTTPClientTests/LocalAddressOverrideTests.swift new file mode 100644 index 000000000..caf7c56bf --- /dev/null +++ b/Tests/AsyncHTTPClientTests/LocalAddressOverrideTests.swift @@ -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") + } +} diff --git a/Tests/AsyncHTTPClientTests/NoBytesSentOverBodyLimitTests.swift b/Tests/AsyncHTTPClientTests/NoBytesSentOverBodyLimitTests.swift index 026a45d4c..32e8e5f4a 100644 --- a/Tests/AsyncHTTPClientTests/NoBytesSentOverBodyLimitTests.swift +++ b/Tests/AsyncHTTPClientTests/NoBytesSentOverBodyLimitTests.swift @@ -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 { diff --git a/Tests/AsyncHTTPClientTests/RacePoolIdleConnectionsAndGetTests.swift b/Tests/AsyncHTTPClientTests/RacePoolIdleConnectionsAndGetTests.swift index 35a09c421..43c348113 100644 --- a/Tests/AsyncHTTPClientTests/RacePoolIdleConnectionsAndGetTests.swift +++ b/Tests/AsyncHTTPClientTests/RacePoolIdleConnectionsAndGetTests.swift @@ -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 { diff --git a/Tests/AsyncHTTPClientTests/RandomizedDNSResolverIntegrationTests.swift b/Tests/AsyncHTTPClientTests/RandomizedDNSResolverIntegrationTests.swift new file mode 100644 index 000000000..26b88a745 --- /dev/null +++ b/Tests/AsyncHTTPClientTests/RandomizedDNSResolverIntegrationTests.swift @@ -0,0 +1,95 @@ +//===----------------------------------------------------------------------===// +// +// 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 NIOCore +import NIOHTTP1 +import NIOPosix +import NIOSSL +import XCTest + +@testable import AsyncHTTPClient + +final class RandomizedDNSResolverIntegrationTests: XCTestCase { + + func testDefaultDNSResolverIsSystem() { + let config = HTTPClient.Configuration() + XCTAssertEqual(config.dnsResolver, .system) + } + + func testRandomizedDNSResolverCanBeSet() { + var config = HTTPClient.Configuration() + config.dnsResolver = .randomized + XCTAssertEqual(config.dnsResolver, .randomized) + } + + func testDNSResolverEquality() { + XCTAssertEqual( + HTTPClient.Configuration.DNSResolver.system, + HTTPClient.Configuration.DNSResolver.system + ) + XCTAssertEqual( + HTTPClient.Configuration.DNSResolver.randomized, + HTTPClient.Configuration.DNSResolver.randomized + ) + XCTAssertNotEqual( + HTTPClient.Configuration.DNSResolver.system, + HTTPClient.Configuration.DNSResolver.randomized + ) + } + + /// Connect over plain HTTP through the `ClientBootstrap` factory path + /// in `HTTPConnectionPool+Factory.swift`, exercising the `dnsResolver` + /// switch for both `.system` and `.randomized`. + func testResolverConnectsOverPlainHTTP() async throws { + try await self.runConnectTest(ssl: false, resolver: .system) + try await self.runConnectTest(ssl: false, resolver: .randomized) + } + + /// Connect over HTTPS through the TLS `ClientBootstrap` factory path + /// in `HTTPConnectionPool+Factory.swift`, exercising the `dnsResolver` + /// switch for both `.system` and `.randomized`. + func testResolverConnectsOverHTTPS() async throws { + try await self.runConnectTest(ssl: true, resolver: .system) + try await self.runConnectTest(ssl: true, resolver: .randomized) + } + + private func runConnectTest( + ssl: Bool, + resolver: HTTPClient.Configuration.DNSResolver + ) async throws { + let bin = HTTPBin(.http1_1(ssl: ssl, compress: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + var config = HTTPClient.Configuration() + config.dnsResolver = resolver + if ssl { + config.tlsConfiguration = .clientDefault + config.tlsConfiguration?.certificateVerification = .none + } + + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { XCTAssertNoThrow(try group.syncShutdownGracefully()) } + + let client = HTTPClient( + eventLoopGroupProvider: .shared(group), + configuration: config + ) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let scheme = ssl ? "https" : "http" + let request = HTTPClientRequest(url: "\(scheme)://localhost:\(bin.port)/get") + let response = try await client.execute(request, deadline: .now() + .seconds(5)) + XCTAssertEqual(response.status, .ok) + } +} diff --git a/Tests/AsyncHTTPClientTests/RequestBagTests.swift b/Tests/AsyncHTTPClientTests/RequestBagTests.swift index a3ae5017c..297e81704 100644 --- a/Tests/AsyncHTTPClientTests/RequestBagTests.swift +++ b/Tests/AsyncHTTPClientTests/RequestBagTests.swift @@ -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(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(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 ) } } diff --git a/Tests/AsyncHTTPClientTests/Resources/example.com.cert.pem b/Tests/AsyncHTTPClientTests/Resources/example.com.cert.pem index f16590cde..afdb60b5e 100644 --- a/Tests/AsyncHTTPClientTests/Resources/example.com.cert.pem +++ b/Tests/AsyncHTTPClientTests/Resources/example.com.cert.pem @@ -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----- \ No newline at end of file +MAoGCCqGSM49BAMDA2cAMGQCMFRSS0Ti9ndTwIt8Fg0ys9RyfuUj2JxDF4bIOF5g +hLGt1ZwPOukALbwGE5riOCk7gAIwQdOdW8y8UxnODjeWAoFAUeFDgFplhrpvJAnp +0sjx8oJH4Vd15Hvhoy7ZqeCh0O8P +-----END CERTIFICATE----- diff --git a/Tests/AsyncHTTPClientTests/Resources/example.com.private-key.pem b/Tests/AsyncHTTPClientTests/Resources/example.com.private-key.pem index 3ad9ce79e..6d5af42a0 100644 --- a/Tests/AsyncHTTPClientTests/Resources/example.com.private-key.pem +++ b/Tests/AsyncHTTPClientTests/Resources/example.com.private-key.pem @@ -1,6 +1,6 @@ -----BEGIN PRIVATE KEY----- -MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDD9v51MTOcgFIbiHbok -U+QOubosGF1u1q+D3fEUb1U2cgjCofKmPHekXTz0xu9MJi2hZANiAAQWszfO5HCW -IWgKUqyXUU0pFpYgaq01RRL69XZz1CkV6XTrxMfIvvwez2886EQDL8QXi5NpKg3q -vPgWuDjVHaj4WEJe5XMNqcujxcTufBlmaQ6o4vtoK7CIHDIDldF/HRg= ------END PRIVATE KEY----- \ No newline at end of file +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDA/iiVlk1ZaSDe6Tu7N +4Dxwa0AKR6U5OFy1U/VfvRbXs/IUJGi860oiquYLgny/eTehZANiAAT5l93hAf9R +YmRKfAE3JpbJIgsr1pHpjnUtzT99HTTLJFQpOSFqreZ7mPMzUGcrh/gW0C59HpW2 +759OpbqxkC1zUcaQLiazLDGsprjXfHpFpJ5D033eefew/PysOQ5dFKc= +-----END PRIVATE KEY----- diff --git a/Tests/AsyncHTTPClientTests/ResponseDelayGetTests.swift b/Tests/AsyncHTTPClientTests/ResponseDelayGetTests.swift index 5fd1d6720..7874a185c 100644 --- a/Tests/AsyncHTTPClientTests/ResponseDelayGetTests.swift +++ b/Tests/AsyncHTTPClientTests/ResponseDelayGetTests.swift @@ -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 { diff --git a/Tests/AsyncHTTPClientTests/StressGetHttpsTests.swift b/Tests/AsyncHTTPClientTests/StressGetHttpsTests.swift index 587e6c64c..d5b77556c 100644 --- a/Tests/AsyncHTTPClientTests/StressGetHttpsTests.swift +++ b/Tests/AsyncHTTPClientTests/StressGetHttpsTests.swift @@ -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 { diff --git a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift index 6bb796f6c..82ae705e6 100644 --- a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift +++ b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift @@ -43,6 +43,14 @@ struct HTTPClientConfigurationPropsTests { "httpVersion": "http1Only", "maximumUsesPerConnection": 100, + + "proxy.enabled": true, + "proxy.host": "proxy.example.com", + "proxy.port": 8080, + "proxy.type": "http", + "proxy.authorization.scheme": "basic", + "proxy.authorization.username": "user", + "proxy.authorization.password": "pass", ]) let configReader = ConfigReader(provider: testProvider) @@ -74,6 +82,15 @@ struct HTTPClientConfigurationPropsTests { #expect(config.httpVersion == .http1Only) #expect(config.maximumUsesPerConnection == 100) + + #expect( + config.proxy + == .server( + host: "proxy.example.com", + port: 8080, + authorization: .basic(username: "user", password: "pass") + ) + ) } @Test @@ -98,6 +115,8 @@ struct HTTPClientConfigurationPropsTests { #expect(config.httpVersion == .automatic) #expect(config.maximumUsesPerConnection == nil) + + #expect(config.proxy == nil) } @Test @@ -301,5 +320,206 @@ struct HTTPClientConfigurationPropsTests { #expect(config.dnsOverride.isEmpty) } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func proxyHTTPWithoutAuthorization() throws { + let testProvider = InMemoryProvider(values: [ + "proxy.enabled": true, + "proxy.host": "proxy.example.com", + "proxy.port": 8080, + ]) + let configReader = ConfigReader(provider: testProvider) + let config = try HTTPClient.Configuration(configReader: configReader) + + #expect(config.proxy == .server(host: "proxy.example.com", port: 8080)) + #expect(config.proxy?.authorization == nil) + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func proxyHTTPWithBasicAuthCredentials() throws { + let testProvider = InMemoryProvider(values: [ + "proxy.enabled": true, + "proxy.host": "proxy.example.com", + "proxy.port": 8080, + "proxy.authorization.scheme": "basic", + "proxy.authorization.credentials": "dXNlcjpwYXNz", + ]) + let configReader = ConfigReader(provider: testProvider) + let config = try HTTPClient.Configuration(configReader: configReader) + + #expect( + config.proxy + == .server(host: "proxy.example.com", port: 8080, authorization: .basic(credentials: "dXNlcjpwYXNz")) + ) + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func proxyHTTPWithBearerAuth() throws { + let testProvider = InMemoryProvider(values: [ + "proxy.enabled": true, + "proxy.host": "proxy.example.com", + "proxy.port": 8080, + "proxy.type": "http", + "proxy.authorization.scheme": "bearer", + "proxy.authorization.token": "abc123", + ]) + let configReader = ConfigReader(provider: testProvider) + let config = try HTTPClient.Configuration(configReader: configReader) + + #expect( + config.proxy + == .server(host: "proxy.example.com", port: 8080, authorization: .bearer(tokens: "abc123")) + ) + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func proxySOCKSWithDefaultPort() throws { + let testProvider = InMemoryProvider(values: [ + "proxy.enabled": true, + "proxy.host": "socks.example.com", + "proxy.type": "socks", + ]) + let configReader = ConfigReader(provider: testProvider) + let config = try HTTPClient.Configuration(configReader: configReader) + + #expect(config.proxy == .socksServer(host: "socks.example.com")) + #expect(config.proxy?.port == 1080) + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func proxySOCKSWithCustomPort() throws { + let testProvider = InMemoryProvider(values: [ + "proxy.enabled": true, + "proxy.host": "socks.example.com", + "proxy.port": 9050, + "proxy.type": "socks", + ]) + let configReader = ConfigReader(provider: testProvider) + let config = try HTTPClient.Configuration(configReader: configReader) + + #expect(config.proxy == .socksServer(host: "socks.example.com", port: 9050)) + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func proxyDisabledIgnoresOtherKeys() throws { + let testProvider = InMemoryProvider(values: [ + "proxy.enabled": false, + "proxy.host": "proxy.example.com", + "proxy.port": 8080, + ]) + let configReader = ConfigReader(provider: testProvider) + let config = try HTTPClient.Configuration(configReader: configReader) + + #expect(config.proxy == nil) + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func proxyEnabledWithoutHostThrowsError() throws { + let testProvider = InMemoryProvider(values: [ + "proxy.enabled": true, + "proxy.port": 8080, + ]) + let configReader = ConfigReader(provider: testProvider) + #expect(throws: (any Error).self) { + _ = try HTTPClient.Configuration(configReader: configReader) + } + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func proxyHTTPMissingPortThrowsError() throws { + let testProvider = InMemoryProvider(values: [ + "proxy.enabled": true, + "proxy.host": "proxy.example.com", + ]) + let configReader = ConfigReader(provider: testProvider) + #expect(throws: (any Error).self) { + _ = try HTTPClient.Configuration(configReader: configReader) + } + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func proxyUnknownTypeThrowsError() throws { + let testProvider = InMemoryProvider(values: [ + "proxy.enabled": true, + "proxy.host": "proxy.example.com", + "proxy.port": 8080, + "proxy.type": "unknown", + ]) + let configReader = ConfigReader(provider: testProvider) + #expect(throws: (any Error).self) { + _ = try HTTPClient.Configuration(configReader: configReader) + } + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func proxySOCKSWithAuthorizationThrowsError() throws { + let testProvider = InMemoryProvider(values: [ + "proxy.enabled": true, + "proxy.host": "socks.example.com", + "proxy.type": "socks", + "proxy.authorization.scheme": "basic", + "proxy.authorization.username": "user", + "proxy.authorization.password": "pass", + ]) + let configReader = ConfigReader(provider: testProvider) + #expect(throws: HTTPClientError.invalidProxyConfiguration) { + _ = try HTTPClient.Configuration(configReader: configReader) + } + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func proxyBasicAuthWithoutCredentialsThrowsError() throws { + let testProvider = InMemoryProvider(values: [ + "proxy.enabled": true, + "proxy.host": "proxy.example.com", + "proxy.port": 8080, + "proxy.authorization.scheme": "basic", + ]) + let configReader = ConfigReader(provider: testProvider) + #expect(throws: HTTPClientError.invalidProxyConfiguration) { + _ = try HTTPClient.Configuration(configReader: configReader) + } + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func proxyBearerAuthWithoutTokenThrowsError() throws { + let testProvider = InMemoryProvider(values: [ + "proxy.enabled": true, + "proxy.host": "proxy.example.com", + "proxy.port": 8080, + "proxy.authorization.scheme": "bearer", + ]) + let configReader = ConfigReader(provider: testProvider) + #expect(throws: HTTPClientError.invalidProxyConfiguration) { + _ = try HTTPClient.Configuration(configReader: configReader) + } + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func proxyUnknownAuthSchemeThrowsError() throws { + let testProvider = InMemoryProvider(values: [ + "proxy.enabled": true, + "proxy.host": "proxy.example.com", + "proxy.port": 8080, + "proxy.authorization.scheme": "digest", + ]) + let configReader = ConfigReader(provider: testProvider) + #expect(throws: HTTPClientError.invalidProxyConfiguration) { + _ = try HTTPClient.Configuration(configReader: configReader) + } + } } #endif diff --git a/Tests/LinkageTest/Package.swift b/Tests/LinkageTest/Package.swift new file mode 100644 index 000000000..c207514f9 --- /dev/null +++ b/Tests/LinkageTest/Package.swift @@ -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") + ] + ) + ] +) diff --git a/Tests/LinkageTest/Sources/linkageTest/main.swift b/Tests/LinkageTest/Sources/linkageTest/main.swift new file mode 100644 index 000000000..af48b95a6 --- /dev/null +++ b/Tests/LinkageTest/Sources/linkageTest/main.swift @@ -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)") diff --git a/scripts/run-linkage-test.sh b/scripts/run-linkage-test.sh new file mode 100755 index 000000000..1686adc48 --- /dev/null +++ b/scripts/run-linkage-test.sh @@ -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 \ No newline at end of file