Add swift-configuration support (#878)

Users should be able to use swift-configuration to create the http
client configuration object

Changes
- duplicate package.swift to create a separate version for 6.0 and 6.1, 
since Configuration is 6.2+
- add helper to create http client configuration using ConfigReader
This commit is contained in:
hamzahrmalik
2026-02-05 11:12:14 +00:00
committed by GitHub
parent e2ab0d176f
commit 986dc47c11
6 changed files with 692 additions and 3 deletions
+4 -1
View File
@@ -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"),
+124
View File
@@ -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 --- //
+124
View File
@@ -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 --- //
+21 -2
View File
@@ -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,9 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
case deadlineExceeded
case httpEndReceivedAfterHeadWith1xx
case shutdownUnsupported
case invalidRedirectConfiguration
case invalidHTTPVersionConfiguration
case invalidDNSOverridesConfiguration
case internalStateFailure(file: String, line: UInt)
}
@@ -1480,6 +1483,13 @@ 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"
case .invalidDNSOverridesConfiguration:
return
"The DNS overrides specified in the configuration are not valid. Please specify in the format hostname1:ip1,hostname2:ip2"
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"
@@ -1574,6 +1584,15 @@ 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)
/// The DNS overrides specified in the configuration are not valid.
public static let invalidDNSOverridesConfiguration = HTTPClientError(code: .invalidDNSOverridesConfiguration)
/// 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))
@@ -0,0 +1,140 @@
//===----------------------------------------------------------------------===//
//
// 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
//
//===----------------------------------------------------------------------===//
#if compiler(>=6.2)
import Configuration
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` (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()
// 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 {
throw HTTPClientError.invalidDNSOverridesConfiguration
}
let key = entry.prefix(upTo: separatorIndex)
let value = entry.suffix(from: entry.index(after: separatorIndex))
if key.isEmpty || value.isEmpty {
throw HTTPClientError.invalidDNSOverridesConfiguration
}
self.dnsOverride[String(key)] = String(value.filter { !$0.isWhitespace })
}
}
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 {
/// 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)
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 {
/// 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)) }
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 {
/// 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(
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)
}
}
#endif
@@ -0,0 +1,279 @@
//===----------------------------------------------------------------------===//
//
// 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
//
//===----------------------------------------------------------------------===//
#if compiler(>=6.2)
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 dnsOverridesWithInvalidFormat() throws {
let testProvider = InMemoryProvider(values: [
"dnsOverrides": .init(.stringArray(["invalidentry", "localhost:127.0.0.1"]), isSecret: false)
])
let configReader = ConfigReader(provider: testProvider)
#expect(throws: HTTPClientError.invalidDNSOverridesConfiguration) {
_ = try HTTPClient.Configuration(configReader: configReader)
}
}
@Test
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
func dnsOverridesWithBlankValue() throws {
let testProvider = InMemoryProvider(values: [
"dnsOverrides": .init(.stringArray(["localhost:"]), isSecret: false)
])
let configReader = ConfigReader(provider: testProvider)
#expect(throws: HTTPClientError.invalidDNSOverridesConfiguration) {
_ = try HTTPClient.Configuration(configReader: configReader)
}
}
@Test
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
func dnsOverridesWithBlankKey() throws {
let testProvider = InMemoryProvider(values: [
"dnsOverrides": .init(.stringArray([":127.0.0.1"]), isSecret: false)
])
let configReader = ConfigReader(provider: testProvider)
#expect(throws: HTTPClientError.invalidDNSOverridesConfiguration) {
_ = try HTTPClient.Configuration(configReader: configReader)
}
}
@Test
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
func dnsOverridesWithSpaces() throws {
let testProvider = InMemoryProvider(values: [
"dnsOverrides": .init(.stringArray(["test.com: 127.0.0.1"]), isSecret: false)
])
let configReader = ConfigReader(provider: testProvider)
let config = try HTTPClient.Configuration(configReader: configReader)
#expect(config.dnsOverride == ["test.com": "127.0.0.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)
}
}
#endif