From 2994e338445bdda607ec56e200c4d8766212d9e5 Mon Sep 17 00:00:00 2001 From: Hamzah Malik Date: Tue, 19 May 2026 13:10:43 +0100 Subject: [PATCH 1/3] Extend swift-configuration support to proxy configuration --- Sources/AsyncHTTPClient/HTTPClient.swift | 6 + ...ientConfiguration+SwiftConfiguration.swift | 84 +++++++ .../SwiftConfigurationTests.swift | 220 ++++++++++++++++++ 3 files changed, 310 insertions(+) diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index dbc40984f..21626163e 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1477,6 +1477,7 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { case invalidHTTPVersionConfiguration case invalidDNSOverridesConfiguration case invalidLocalAddress + case invalidProxyConfiguration case internalStateFailure(file: String, line: UInt) } @@ -1572,6 +1573,8 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { "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" @@ -1678,6 +1681,9 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { /// 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..9a4f0ee7c 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,83 @@ 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. + public init?(configReader: ConfigReader) throws { + guard configReader.bool(forKey: "enabled", default: false) else { + return nil + } + guard let host = configReader.string(forKey: "host") else { + throw HTTPClientError.invalidProxyConfiguration + } + let type = configReader.string(forKey: "type", default: "http") + let authorization = try HTTPClient.Authorization(configReader: configReader.scoped(to: "authorization")) + switch type { + case "http": + guard let port = configReader.int(forKey: "port") else { + throw HTTPClientError.invalidProxyConfiguration + } + 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/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift index 6bb796f6c..5d72ab902 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: 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 proxyHTTPMissingPortThrowsError() throws { + let testProvider = InMemoryProvider(values: [ + "proxy.enabled": true, + "proxy.host": "proxy.example.com", + ]) + 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 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: 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 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 From 043646ce1ced03b263f5c0c94a3686f120ca50c0 Mon Sep 17 00:00:00 2001 From: Hamzah Malik Date: Tue, 26 May 2026 11:43:34 +0100 Subject: [PATCH 2/3] code review --- .../HTTPClientConfiguration+SwiftConfiguration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift index 9a4f0ee7c..a00c38c4d 100644 --- a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift +++ b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift @@ -167,7 +167,7 @@ extension HTTPClient.Configuration.Proxy { /// 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. + /// `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 From 36a66ca0591ea5eaba8431571601d14d95f0f740 Mon Sep 17 00:00:00 2001 From: Hamzah Malik Date: Fri, 29 May 2026 11:44:21 +0100 Subject: [PATCH 3/3] code review --- .../HTTPClientConfiguration+SwiftConfiguration.swift | 8 ++------ Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift | 6 +++--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift index a00c38c4d..02c9726ff 100644 --- a/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift +++ b/Sources/AsyncHTTPClient/HTTPClientConfiguration+SwiftConfiguration.swift @@ -172,16 +172,12 @@ extension HTTPClient.Configuration.Proxy { guard configReader.bool(forKey: "enabled", default: false) else { return nil } - guard let host = configReader.string(forKey: "host") else { - throw HTTPClientError.invalidProxyConfiguration - } + 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": - guard let port = configReader.int(forKey: "port") else { - throw HTTPClientError.invalidProxyConfiguration - } + let port = try configReader.requiredInt(forKey: "port") self = .server(host: host, port: port, authorization: authorization) case "socks": if authorization != nil { diff --git a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift index 5d72ab902..82ae705e6 100644 --- a/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift +++ b/Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift @@ -427,7 +427,7 @@ struct HTTPClientConfigurationPropsTests { "proxy.port": 8080, ]) let configReader = ConfigReader(provider: testProvider) - #expect(throws: HTTPClientError.invalidProxyConfiguration) { + #expect(throws: (any Error).self) { _ = try HTTPClient.Configuration(configReader: configReader) } } @@ -440,7 +440,7 @@ struct HTTPClientConfigurationPropsTests { "proxy.host": "proxy.example.com", ]) let configReader = ConfigReader(provider: testProvider) - #expect(throws: HTTPClientError.invalidProxyConfiguration) { + #expect(throws: (any Error).self) { _ = try HTTPClient.Configuration(configReader: configReader) } } @@ -455,7 +455,7 @@ struct HTTPClientConfigurationPropsTests { "proxy.type": "unknown", ]) let configReader = ConfigReader(provider: testProvider) - #expect(throws: HTTPClientError.invalidProxyConfiguration) { + #expect(throws: (any Error).self) { _ = try HTTPClient.Configuration(configReader: configReader) } }