Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Sources/AsyncHTTPClient/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1477,6 +1477,7 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
case invalidHTTPVersionConfiguration
case invalidDNSOverridesConfiguration
case invalidLocalAddress
case invalidProxyConfiguration
case internalStateFailure(file: String, line: UInt)
}

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
}
Expand Down Expand Up @@ -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
220 changes: 220 additions & 0 deletions Tests/AsyncHTTPClientTests/SwiftConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -98,6 +115,8 @@ struct HTTPClientConfigurationPropsTests {
#expect(config.httpVersion == .automatic)

#expect(config.maximumUsesPerConnection == nil)

#expect(config.proxy == nil)
}

@Test
Expand Down Expand Up @@ -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
Loading