From 1a08982c1f669d0c0a26df6de738ab89e5932677 Mon Sep 17 00:00:00 2001 From: Hamzah Malik Date: Thu, 8 Jan 2026 15:56:15 +0000 Subject: [PATCH 1/3] Add swift-configuration support --- Package.swift | 5 +- Package@swift-6.0.swift | 124 +++++++++ Package@swift-6.1.swift | 124 +++++++++ Sources/AsyncHTTPClient/HTTPClient.swift | 16 +- ...ientConfiguration+SwiftConfiguration.swift | 88 +++++++ .../SwiftConfigurationTests.swift | 245 ++++++++++++++++++ 6 files changed, 599 insertions(+), 3 deletions(-) create mode 100644 Package@swift-6.0.swift create mode 100644 Package@swift-6.1.swift create mode 100644 Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift create mode 100644 Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift diff --git a/Package.swift b/Package.swift index aad0c1c53..330890a89 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.0 +// swift-tools-version:6.2 //===----------------------------------------------------------------------===// // // This source file is part of the AsyncHTTPClient open source project @@ -44,6 +44,7 @@ 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"), ], targets: [ .target( @@ -69,6 +70,7 @@ let package = Package( .product(name: "NIOTransportServices", package: "swift-nio-transport-services"), .product(name: "Atomics", package: "swift-atomics"), .product(name: "Algorithms", package: "swift-algorithms"), + .product(name: "Configuration", package: "swift-configuration"), // Observability support .product(name: "Logging", package: "swift-log"), .product(name: "Tracing", package: "swift-distributed-tracing"), @@ -90,6 +92,7 @@ let package = Package( .product(name: "NIOSOCKS", package: "swift-nio-extras"), .product(name: "Atomics", package: "swift-atomics"), .product(name: "Algorithms", package: "swift-algorithms"), + .product(name: "Configuration", package: "swift-configuration"), // Observability support .product(name: "Logging", package: "swift-log"), .product(name: "InMemoryLogging", package: "swift-log"), diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift new file mode 100644 index 000000000..aad0c1c53 --- /dev/null +++ b/Package@swift-6.0.swift @@ -0,0 +1,124 @@ +// 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 new file mode 100644 index 000000000..aad0c1c53 --- /dev/null +++ b/Package@swift-6.1.swift @@ -0,0 +1,124 @@ +// 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/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 80df3b946..cb4a2c460 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1265,7 +1265,7 @@ extension HTTPClient.Configuration { /// Specifies redirect processing settings. public struct RedirectConfiguration: Sendable { - enum Mode { + enum Mode: Hashable { /// Redirects are not followed. case disallow /// Redirects are followed with a specified limit. @@ -1340,7 +1340,7 @@ extension HTTPClient.Configuration { } public struct HTTPVersion: Sendable, Hashable { - enum Configuration { + enum Configuration: String { case http1Only case automatic } @@ -1394,6 +1394,8 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { case deadlineExceeded case httpEndReceivedAfterHeadWith1xx case shutdownUnsupported + case invalidRedirectConfiguration + case invalidHTTPVersionConfiguration } private var code: Code @@ -1479,6 +1481,10 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { return "HTTP end received after head with 1xx" case .shutdownUnsupported: return "The global singleton HTTP client cannot be shut down" + case .invalidRedirectConfiguration: + return "The redirect mode specified in the configuration is not a valid value" + case .invalidHTTPVersionConfiguration: + return "The HTTP version specified in the configuration is not a valid value" } } @@ -1570,6 +1576,12 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { /// - Tasks are not processed fast enough on the existing connections, to process all waiters in time public static let getConnectionFromPoolTimeout = HTTPClientError(code: .getConnectionFromPoolTimeout) + /// The redirect mode specified in the configuration is not a valid value + public static let invalidRedirectConfiguration = HTTPClientError(code: .invalidRedirectConfiguration) + + /// The http version specified in the configuration is not a valid value. + public static let invalidHTTPVersionConfiguration = HTTPClientError(code: .invalidHTTPVersionConfiguration) + @available( *, deprecated, diff --git a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift new file mode 100644 index 000000000..ffcf2b576 --- /dev/null +++ b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift @@ -0,0 +1,88 @@ +import Configuration +import NIOCore + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension HTTPClient.Configuration { + public init(configReader: ConfigReader) throws { + self.init() + + // Each entry in the list should be a colon separated pair e.g. localhost:127.0.0.1 or localhost:::1 + if let dnsOverridesList = configReader.stringArray(forKey: "dnsOverrides") { + for entry in dnsOverridesList { + guard let separatorIndex = entry.firstIndex(of: ":") else { + continue + } + let key = entry.prefix(upTo: separatorIndex) + let value = entry.suffix(from: entry.index(after: separatorIndex)) + self.dnsOverride[String(key)] = String(value) + } + } + + 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 version = try HTTPVersion(configReader: configReader) { + self.httpVersion = version + } + self.maximumUsesPerConnection = configReader.int(forKey: "maximumUsesPerConnection") + } +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension HTTPClient.Configuration.RedirectConfiguration { + fileprivate init(configReader: ConfigReader) throws { + guard let mode = configReader.string(forKey: "mode") else { + // default + self = .follow(max: 5, allowCycles: false) + return + } + if mode == "follow" { + let maxRedirects = configReader.int(forKey: "maxRedirects", default: 5) + let allowCycles = configReader.bool(forKey: "allowCycles", default: false) + self = .follow(max: maxRedirects, allowCycles: allowCycles) + } else if mode == "disallow" { + self = .disallow + } else { + throw HTTPClientError.invalidRedirectConfiguration + } + } +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension HTTPClient.Configuration.Timeout { + fileprivate init(configReader: ConfigReader) { + self.init() + self.connect = configReader.int(forKey: "connectionMs").map { TimeAmount.milliseconds(Int64($0)) } + self.read = configReader.int(forKey: "readMs").map { TimeAmount.milliseconds(Int64($0)) } + self.write = configReader.int(forKey: "writeMs").map { TimeAmount.milliseconds(Int64($0)) } + } +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension HTTPClient.Configuration.ConnectionPool { + fileprivate init(configReader: ConfigReader) { + self.init() + self.idleTimeout = TimeAmount.milliseconds(Int64(configReader.int(forKey: "idleTimeoutMs", default: 60000))) + self.concurrentHTTP1ConnectionsPerHostSoftLimit = configReader.int( + forKey: "concurrentHTTP1ConnectionsPerHostSoftLimit", + default: 8 + ) + self.retryConnectionEstablishment = configReader.bool(forKey: "retryConnectionEstablishment", default: true) + self.preWarmedHTTP1ConnectionCount = configReader.int(forKey: "preWarmedHTTP1ConnectionCount", default: 0) + } +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension HTTPClient.Configuration.HTTPVersion { + fileprivate init?(configReader: ConfigReader) throws { + guard let rawValue = configReader.string(forKey: "httpVersion") else { + // Unspecified is not an error. It's an optional prop. + return nil + } + // Specified but invalid IS an error + guard let base = Self.Configuration(rawValue: rawValue) else { + throw HTTPClientError.invalidHTTPVersionConfiguration + } + self = .init(configuration: base) + } +} diff --git a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift new file mode 100644 index 000000000..884d4729e --- /dev/null +++ b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift @@ -0,0 +1,245 @@ +import Configuration +import Foundation +import NIOCore +import Testing + +@testable import AsyncHTTPClient + +struct HTTPClientConfigurationPropsTests { + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func allPropertiesAreSetFromConfig() throws { + let testProvider = InMemoryProvider(values: [ + "dnsOverrides": .init(.stringArray(["localhost:127.0.0.1", "example.com:192.168.1.1"]), isSecret: false), + "redirect.mode": "follow", + "redirect.maxRedirects": 10, + "redirect.allowCycles": true, + + "timeout.connectionMs": 5000, + "timeout.readMs": 30000, + "timeout.writeMs": 15000, + + "connectionPool.idleTimeoutMs": 120_000, + "connectionPool.concurrentHTTP1ConnectionsPerHostSoftLimit": 16, + "connectionPool.retryConnectionEstablishment": false, + "connectionPool.preWarmedHTTP1ConnectionCount": 5, + + "httpVersion": "http1Only", + "maximumUsesPerConnection": 100, + ]) + + let configReader = ConfigReader(provider: testProvider) + + let config = try HTTPClient.Configuration(configReader: configReader) + + #expect(config.dnsOverride["localhost"] == "127.0.0.1") + #expect(config.dnsOverride["example.com"] == "192.168.1.1") + + switch config.redirectConfiguration.mode { + case .follow(let max, let allowCycles): + #expect(max == 10) + #expect(allowCycles) + case .disallow: + Issue.record("Unexpected value") + } + + #expect(config.timeout.connect == .milliseconds(5000)) + #expect(config.timeout.read == .milliseconds(30000)) + #expect(config.timeout.write == .milliseconds(15000)) + + #expect(config.connectionPool.idleTimeout == .milliseconds(120000)) + #expect(config.connectionPool.concurrentHTTP1ConnectionsPerHostSoftLimit == 16) + #expect(config.connectionPool.retryConnectionEstablishment == false) + #expect(config.connectionPool.preWarmedHTTP1ConnectionCount == 5) + + #expect(config.httpVersion == .http1Only) + + #expect(config.maximumUsesPerConnection == 100) + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func defaultsAreUsedWhenConfigIsEmpty() throws { + let testProvider = InMemoryProvider(values: [:]) + let configReader = ConfigReader(provider: testProvider) + + let config = try HTTPClient.Configuration(configReader: configReader) + + #expect(config.dnsOverride.isEmpty) + + #expect(config.timeout.connect == nil) + #expect(config.timeout.read == nil) + #expect(config.timeout.write == nil) + + #expect(config.connectionPool.idleTimeout == .seconds(60)) + #expect(config.connectionPool.concurrentHTTP1ConnectionsPerHostSoftLimit == 8) + #expect(config.connectionPool.retryConnectionEstablishment == true) + #expect(config.connectionPool.preWarmedHTTP1ConnectionCount == 0) + + #expect(config.httpVersion == .automatic) + + #expect(config.maximumUsesPerConnection == nil) + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func redirectConfigurationDisallow() throws { + let testProvider = InMemoryProvider(values: ["redirect.mode": "disallow"]) + let configReader = ConfigReader(provider: testProvider) + + let config = try HTTPClient.Configuration(configReader: configReader) + switch config.redirectConfiguration.mode { + case .disallow: + break + case .follow: + Issue.record("Unexpected value") + } + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func redirectConfigurationInvalidModeThrowsError() throws { + let testProvider = InMemoryProvider(values: ["redirect.mode": "invalid"]) + let configReader = ConfigReader(provider: testProvider) + #expect(throws: HTTPClientError.invalidRedirectConfiguration) { + _ = try HTTPClient.Configuration(configReader: configReader) + } + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func httpVersionAutomatic() throws { + let testProvider = InMemoryProvider(values: ["httpVersion": "automatic"]) + let configReader = ConfigReader(provider: testProvider) + + let config = try HTTPClient.Configuration(configReader: configReader) + + #expect(config.httpVersion == .automatic) + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func httpVersionInvalidThrowsError() throws { + let testProvider = InMemoryProvider(values: ["httpVersion": "http3"]) + let configReader = ConfigReader(provider: testProvider) + #expect(throws: HTTPClientError.invalidHTTPVersionConfiguration) { + try HTTPClient.Configuration(configReader: configReader) + } + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func dnsOverridesWithIPv6() throws { + let testProvider = InMemoryProvider(values: [ + "dnsOverrides": .init(.stringArray(["localhost:::1", "example.com:2001:db8::1"]), isSecret: false) + ]) + let configReader = ConfigReader(provider: testProvider) + + let config = try HTTPClient.Configuration(configReader: configReader) + + #expect(config.dnsOverride["localhost"] == "::1") + #expect(config.dnsOverride["example.com"] == "2001:db8::1") + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func dnsOverridesWithInvalidFormatIgnored() throws { + let testProvider = InMemoryProvider(values: [ + "dnsOverrides": .init(.stringArray(["invalidentry", "localhost:127.0.0.1"]), isSecret: false) + ]) + let configReader = ConfigReader(provider: testProvider) + let config = try HTTPClient.Configuration(configReader: configReader) + + // Invalid entry should be ignored, valid one should be processed + #expect(config.dnsOverride["localhost"] == "127.0.0.1") + #expect(config.dnsOverride.count == 1) + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func timeoutConfigurationPartial() throws { + let testProvider = InMemoryProvider(values: [ + "timeout.connectionMs": 1000, + "timeout.readMs": 2000, + ]) + let configReader = ConfigReader(provider: testProvider) + let config = try HTTPClient.Configuration(configReader: configReader) + + #expect(config.timeout.connect == .milliseconds(1000)) + #expect(config.timeout.read == .milliseconds(2000)) + #expect(config.timeout.write == nil) + + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func connectionPoolConfigurationPartial() throws { + let testProvider = InMemoryProvider(values: [ + "connectionPool.idleTimeoutMs": 90000, + "connectionPool.concurrentHTTP1ConnectionsPerHostSoftLimit": 12, + ]) + + let configReader = ConfigReader(provider: testProvider) + let config = try HTTPClient.Configuration(configReader: configReader) + + #expect(config.connectionPool.idleTimeout == .milliseconds(90000)) + #expect(config.connectionPool.concurrentHTTP1ConnectionsPerHostSoftLimit == 12) + // These should use defaults + #expect(config.connectionPool.retryConnectionEstablishment) + #expect(config.connectionPool.preWarmedHTTP1ConnectionCount == 0) + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func redirectConfigurationWithDefaults() throws { + let testProvider = InMemoryProvider(values: [ + "redirect.mode": "follow" + ]) + + let configReader = ConfigReader(provider: testProvider) + let config = try HTTPClient.Configuration(configReader: configReader) + #expect(config.redirectConfiguration.mode == .follow(max: 5, allowCycles: false)) + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func redirectConfigurationCustomValues() throws { + let testProvider = InMemoryProvider(values: [ + "redirect.mode": "follow", + "redirect.maxRedirects": 3, + "redirect.allowCycles": true, + ]) + + let configReader = ConfigReader(provider: testProvider) + + let config = try HTTPClient.Configuration(configReader: configReader) + + #expect(config.redirectConfiguration.mode == .follow(max: 3, allowCycles: true)) + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func emptyDnsOverridesArray() throws { + let testProvider = InMemoryProvider(values: [ + "dnsOverrides": "[]" + ]) + let configReader = ConfigReader(provider: testProvider) + let config = try HTTPClient.Configuration(configReader: configReader) + + #expect(config.dnsOverride.isEmpty) + } + + @Test + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func singleDnsOverride() throws { + let testProvider = InMemoryProvider(values: [ + "dnsOverrides": .init(.stringArray(["api.example.com:10.0.0.1"]), isSecret: false) + ]) + + let configReader = ConfigReader(provider: testProvider) + let config = try HTTPClient.Configuration(configReader: configReader) + + #expect(config.dnsOverride["api.example.com"] == "10.0.0.1") + #expect(config.dnsOverride.count == 1) + } +} From 16f81b6d75e632b0e007b02b12b51126d8890d34 Mon Sep 17 00:00:00 2001 From: Hamzah Malik Date: Fri, 9 Jan 2026 15:30:02 +0000 Subject: [PATCH 2/3] Add docs --- Sources/AsyncHTTPClient/HTTPClient.swift | 2 +- ...PClientConfiguration+SwiftConfiguration.swift | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index cb4a2c460..faa7ba40f 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1576,7 +1576,7 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { /// - Tasks are not processed fast enough on the existing connections, to process all waiters in time public static let getConnectionFromPoolTimeout = HTTPClientError(code: .getConnectionFromPoolTimeout) - /// The redirect mode specified in the configuration is not a valid value + /// The redirect mode specified in the configuration is not a valid value. public static let invalidRedirectConfiguration = HTTPClientError(code: .invalidRedirectConfiguration) /// The http version specified in the configuration is not a valid value. diff --git a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift index ffcf2b576..0a42e2bb4 100644 --- a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift +++ b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift @@ -3,6 +3,22 @@ import NIOCore @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) extension HTTPClient.Configuration { + /// Initializes HTTPClient configuration from a ConfigReader. + /// + /// ## Configuration keys: + /// - `dnsOverrides` (string array, optional): Colon-separated host:IP pairs for DNS overrides (e.g., "localhost:127.0.0.1"). + /// - `redirect.mode` (string, optional, default: "follow"): Redirect handling mode ("follow" or "disallow"). + /// - `redirect.maxRedirects` (int, optional, default: 5): Maximum allowed redirects (used when mode is "follow"). + /// - `redirect.allowCycles` (bool, optional, default: false): Allow cyclic redirects (used when mode is "follow"). + /// - `timeout.connectionMs` (int, optional): Connection timeout in milliseconds. + /// - `timeout.readMs` (int, optional): Read timeout in milliseconds. + /// - `timeout.writeMs` (int, optional): Write timeout in milliseconds. + /// - `connectionPool.idleTimeoutMs` (int, optional, default: 60000): Connection idle timeout in milliseconds. + /// - `connectionPool.concurrentHTTP1ConnectionsPerHostSoftLimit` (int, optional, default: 8): Soft limit for concurrent HTTP/1.1 connections per host. + /// - `connectionPool.retryConnectionEstablishment` (bool, optional, default: true): Retry failed connection establishment. + /// - `connectionPool.preWarmedHTTP1ConnectionCount` (int, optional, default: 0): Number of pre-warmed HTTP/1.1 connections per host. + /// - `httpVersion` (string, optional): HTTP version to use ( "automatic" or "http1Only"). + /// - `maximumUsesPerConnection` (int, optional): Maximum uses per connection. public init(configReader: ConfigReader) throws { self.init() From 4fd162a16107c593c388116499dd10fb201c0aaa Mon Sep 17 00:00:00 2001 From: Hamzah Malik Date: Mon, 12 Jan 2026 08:59:51 +0000 Subject: [PATCH 3/3] code review --- ...ientConfiguration+SwiftConfiguration.swift | 47 +++++++++++++------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift index 0a42e2bb4..fde27902c 100644 --- a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift +++ b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift @@ -7,18 +7,14 @@ extension HTTPClient.Configuration { /// /// ## Configuration keys: /// - `dnsOverrides` (string array, optional): Colon-separated host:IP pairs for DNS overrides (e.g., "localhost:127.0.0.1"). - /// - `redirect.mode` (string, optional, default: "follow"): Redirect handling mode ("follow" or "disallow"). - /// - `redirect.maxRedirects` (int, optional, default: 5): Maximum allowed redirects (used when mode is "follow"). - /// - `redirect.allowCycles` (bool, optional, default: false): Allow cyclic redirects (used when mode is "follow"). - /// - `timeout.connectionMs` (int, optional): Connection timeout in milliseconds. - /// - `timeout.readMs` (int, optional): Read timeout in milliseconds. - /// - `timeout.writeMs` (int, optional): Write timeout in milliseconds. - /// - `connectionPool.idleTimeoutMs` (int, optional, default: 60000): Connection idle timeout in milliseconds. - /// - `connectionPool.concurrentHTTP1ConnectionsPerHostSoftLimit` (int, optional, default: 8): Soft limit for concurrent HTTP/1.1 connections per host. - /// - `connectionPool.retryConnectionEstablishment` (bool, optional, default: true): Retry failed connection establishment. - /// - `connectionPool.preWarmedHTTP1ConnectionCount` (int, optional, default: 0): Number of pre-warmed HTTP/1.1 connections per host. - /// - `httpVersion` (string, optional): HTTP version to use ( "automatic" or "http1Only"). - /// - `maximumUsesPerConnection` (int, optional): Maximum uses per connection. + /// - `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:)``. + /// - `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. public init(configReader: ConfigReader) throws { self.init() @@ -46,7 +42,15 @@ extension HTTPClient.Configuration { @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) extension HTTPClient.Configuration.RedirectConfiguration { - fileprivate init(configReader: ConfigReader) throws { + /// Initializes redirect configuration from a ConfigReader. + /// + /// ## Configuration keys: + /// - `mode` (string, optional, default: "follow"): Redirect handling mode ("follow" or "disallow"). + /// - `maxRedirects` (int, optional, default: 5): Maximum allowed redirects when mode is "follow". + /// - `allowCycles` (bool, optional, default: false): Allow cyclic redirects when mode is "follow". + /// + /// - Throws: `HTTPClientError.invalidRedirectConfiguration` if mode is specified but invalid. + public init(configReader: ConfigReader) throws { guard let mode = configReader.string(forKey: "mode") else { // default self = .follow(max: 5, allowCycles: false) @@ -66,7 +70,13 @@ extension HTTPClient.Configuration.RedirectConfiguration { @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) extension HTTPClient.Configuration.Timeout { - fileprivate init(configReader: ConfigReader) { + /// Initializes timeout configuration from a ConfigReader. + /// + /// ## Configuration keys: + /// - `connectionMs` (int, optional, default: 10,000): Connection timeout in milliseconds. + /// - `readMs` (int, optional): Read timeout in milliseconds. + /// - `writeMs` (int, optional): Write timeout in milliseconds. + public init(configReader: ConfigReader) { self.init() self.connect = configReader.int(forKey: "connectionMs").map { TimeAmount.milliseconds(Int64($0)) } self.read = configReader.int(forKey: "readMs").map { TimeAmount.milliseconds(Int64($0)) } @@ -76,7 +86,14 @@ extension HTTPClient.Configuration.Timeout { @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) extension HTTPClient.Configuration.ConnectionPool { - fileprivate init(configReader: ConfigReader) { + /// Initializes connection pool configuration from a ConfigReader. + /// + /// ## Configuration keys: + /// - `idleTimeoutMs` (int, optional, default: 60,000): Connection idle timeout in milliseconds. + /// - `concurrentHTTP1ConnectionsPerHostSoftLimit` (int, optional, default: 8): Soft limit for concurrent HTTP/1.1 connections per host. + /// - `retryConnectionEstablishment` (bool, optional, default: true): Retry failed connection establishment. + /// - `preWarmedHTTP1ConnectionCount` (int, optional, default: 0): Number of pre-warmed HTTP/1.1 connections per host. + public init(configReader: ConfigReader) { self.init() self.idleTimeout = TimeAmount.milliseconds(Int64(configReader.int(forKey: "idleTimeoutMs", default: 60000))) self.concurrentHTTP1ConnectionsPerHostSoftLimit = configReader.int(