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
100 changes: 93 additions & 7 deletions Sources/RequestDL/Properties/Sources/Session/Proxy/Proxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// See LICENSE for this package's licensing information.
//

import RequestDLInternals

/// `Proxy` is a struct that defines a proxy configuration for network requests.
///
/// To create an instance of `Proxy`, initialize it with the host, port, connection protocol, and optionally, authorization credentials.
Expand All @@ -22,23 +24,40 @@
/// )
/// }
/// ```
import RequestDLInternals

public struct Proxy: Property {
///
/// A HTTP proxy can also carry extra headers on its `CONNECT` request — sent only while
/// establishing the tunnel, never on the request the tunnel then carries — by composing them
/// the same way ``RequestDL/Form`` composes per-part headers, with any `Property` that resolves
/// to headers (``RequestDL/CustomHeader``, ``RequestDL/HeaderGroup``, etc):
///
/// ```swift
/// DataTask {
/// BaseURL("example.com")
/// Proxy(host: "proxy.example.com", port: 8080) {
/// CustomHeader(name: "X-Proxy-Token", value: "abc123")
/// }
/// }
/// ```
///
/// > Note: The `Headers` generic parameter represents the type of the `CONNECT` headers
/// composition. If none are needed, the default is `EmptyProperty`.
public struct Proxy<Headers: Property>: Property {

private struct Node: PropertyNode {

let host: String
let port: Int
let connectionProtocol: Internals.Proxy.ConnectionProtocol
let authorization: Internals.Proxy.Authorization?
let connectHeaders: RequestDL.HTTPHeaders

func make(_ make: inout Make) async throws {
make.sessionConfiguration.proxy = .init(
host: host,
port: port,
connection: connectionProtocol,
authorization: authorization
authorization: authorization,
connectHeaders: connectHeaders.build()
)
}
}
Expand All @@ -56,6 +75,7 @@ public struct Proxy: Property {
let port: Int
let connectionProtocol: ConnectionProtocol
let authorization: Authorization?
let connectHeaders: Headers

// MARK: - Inits

Expand All @@ -69,11 +89,14 @@ public struct Proxy: Property {
///
/// - Returns: A new instance of `Proxy`.
///
public init(host: String, port: Int, authorization: Authorization) {
/// > Note: This initializer is available when `Headers` is `EmptyProperty`.
///
public init(host: String, port: Int, authorization: Authorization) where Headers == EmptyProperty {
self.host = host
self.port = port
self.connectionProtocol = .http
self.authorization = authorization
self.connectHeaders = EmptyProperty()
}

///
Expand All @@ -88,11 +111,47 @@ public struct Proxy: Property {
///
/// - Returns: A new instance of `Proxy`.
///
public init(host: String, port: Int, connection connectionProtocol: ConnectionProtocol) {
/// > Note: This initializer is available when `Headers` is `EmptyProperty`.
///
public init(
host: String,
port: Int,
connection connectionProtocol: ConnectionProtocol
) where Headers == EmptyProperty {
self.host = host
self.port = port
self.connectionProtocol = connectionProtocol
self.authorization = nil
self.connectHeaders = EmptyProperty()
}

///
/// Initializes a new instance of HTTP `Proxy` with extra headers on its `CONNECT` request.
///
/// - Parameters:
/// - host: The hostname or IP address of the proxy server.
/// - port: The port number on which the proxy server is listening.
/// - authorization: Optional credentials for authenticating with the proxy server.
/// - connectHeaders: A closure that returns the headers sent only on the `CONNECT`
/// request used to establish the tunnel. `host` and `proxy-authorization` are always set
/// by the underlying transport and cannot be overridden here.
///
/// - Returns: A new instance of `Proxy`.
///
/// > Note: SOCKS has no `CONNECT` phase, so `connectHeaders` is only reachable through the
/// HTTP proxy initializers.
///
public init(
host: String,
port: Int,
authorization: Authorization? = nil,
@PropertyBuilder connectHeaders: () -> Headers
) {
self.host = host
self.port = port
self.connectionProtocol = .http
self.authorization = authorization
self.connectHeaders = connectHeaders()
}

// MARK: - Public static methods
Expand All @@ -103,13 +162,40 @@ public struct Proxy: Property {
inputs: _PropertyInputs
) async throws -> _PropertyOutputs {
property.assertPathway()

let connectHeaders = try await connectHeaders(
property: property,
inputs: inputs
)

return .leaf(
Node(
host: property.host,
port: property.port,
connectionProtocol: property.connectionProtocol.build(),
authorization: property.authorization?.build()
authorization: property.authorization?.build(),
connectHeaders: connectHeaders
)
)
}

// MARK: - Private static methods

private static func connectHeaders(
property: _GraphValue<Proxy<Headers>>,
inputs: _PropertyInputs
) async throws -> RequestDL.HTTPHeaders {
let output = try await Headers._makeProperty(
property: property.connectHeaders,
inputs: inputs
)

var headers = RequestDL.HTTPHeaders()

for header in output.node.search(for: HeaderNode.self) {
header.makeHeadersClosure(&headers)
}

return headers
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
//

import AsyncHTTPClient
import NIOHTTP1

extension Internals {

package struct Proxy: Sendable, Hashable {
package struct Proxy: Sendable {

package enum Authorization: Sendable, Hashable {

Expand Down Expand Up @@ -36,33 +37,57 @@ extension Internals {
package let connectionProtocol: ConnectionProtocol
package let authorization: Authorization?

/// Extra headers sent only on the HTTP `CONNECT` request to an `.http` proxy.
///
/// Ignored for `.socks`, which has no `CONNECT` phase. Excluded from `Hashable`, same as
/// upstream's own `HTTPClient.Configuration.Proxy` — `NIOHTTP1.HTTPHeaders` isn't `Hashable`.
package let connectHeaders: HTTPHeaders

package init(
host: String,
port: Int,
connection connectionProtocol: ConnectionProtocol,
authorization: Authorization?
authorization: Authorization?,
connectHeaders: HTTPHeaders = [:]
) {
self.host = host
self.port = port
self.connectionProtocol = connectionProtocol
self.authorization = authorization
self.connectHeaders = connectHeaders
}

package func build() -> HTTPClient.Configuration.Proxy {
switch connectionProtocol {
case .http:
if let authorization {
return .server(
host: host,
port: port,
authorization: authorization.build()
)
} else {
return .server(host: host, port: port)
}
return .server(
host: host,
port: port,
authorization: authorization?.build(),
connectHeaders: connectHeaders
)
case .socks:
return .socksServer(host: host, port: port)
}
}
}
}

// MARK: - Hashable

extension Internals.Proxy: Hashable {

package static func == (_ lhs: Self, _ rhs: Self) -> Bool {
lhs.host == rhs.host
&& lhs.port == rhs.port
&& lhs.connectionProtocol == rhs.connectionProtocol
&& lhs.authorization == rhs.authorization
}

package func hash(into hasher: inout Hasher) {
hasher.combine(host)
hasher.combine(port)
hasher.combine(connectionProtocol)
hasher.combine(authorization)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// See LICENSE for this package's licensing information.
//

import NIOHTTP1
import Testing

@testable import RequestDLInternals
Expand Down Expand Up @@ -84,4 +85,55 @@ struct InternalsProxyTests {
#expect(resolved.proxy?.host == host)
#expect(resolved.proxy?.port == port)
}

@Test
func proxy_whenHTTPConnectionWithConnectHeaders() async throws {
// Given
var configuration = Internals.Session.Configuration()

let host = UUID().uuidString
let port = 1_090

var connectHeaders = HTTPHeaders()
connectHeaders.add(name: "X-Proxy-Token", value: "first")
connectHeaders.add(name: "X-Proxy-Token", value: "second")

// When
configuration.proxy = .init(
host: host,
port: port,
connection: .http,
authorization: nil,
connectHeaders: connectHeaders
)

let resolved = try configuration.build()

// Then
#expect(resolved.proxy?.connectHeaders["X-Proxy-Token"] == ["first", "second"])
}

@Test
func proxy_whenConnectHeadersDiffer_shouldStillBeEqualAndHashEqual() {
// Given
let host = UUID().uuidString
let port = 1_090

var connectHeaders = HTTPHeaders()
connectHeaders.add(name: "X-Proxy-Token", value: "abc123")

// When
let lhs = Internals.Proxy(host: host, port: port, connection: .http, authorization: nil)
let rhs = Internals.Proxy(
host: host,
port: port,
connection: .http,
authorization: nil,
connectHeaders: connectHeaders
)

// Then
#expect(lhs == rhs)
#expect(lhs.hashValue == rhs.hashValue)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,48 @@ struct ProxyTests {
#expect(resolved.session.configuration.proxy?.authorization == .bearer(tokens: tokens))
}

@Test
func proxy_whenHTTPConnectionWithConnectHeaders() async throws {
// Given
let host = UUID().uuidString
let port = 1_090
let name = UUID().uuidString
let value = UUID().uuidString

// When
let resolved = try await resolve(
Proxy(host: host, port: port) {
CustomHeader(name: name, value: value)
}
)

// Then
#expect(resolved.session.configuration.proxy?.host == host)
#expect(resolved.session.configuration.proxy?.port == port)
#expect(resolved.session.configuration.proxy?.connectHeaders.first(name: name) == value)
}

@Test
func proxy_whenHTTPConnectionWithAuthorizationAndConnectHeaders() async throws {
// Given
let host = UUID().uuidString
let port = 1_090
let credentials = UUID().uuidString
let name = UUID().uuidString
let value = UUID().uuidString

// When
let resolved = try await resolve(
Proxy(host: host, port: port, authorization: .basic(credentials: credentials)) {
CustomHeader(name: name, value: value)
}
)

// Then
#expect(resolved.session.configuration.proxy?.authorization == .basicRawCredentials(credentials))
#expect(resolved.session.configuration.proxy?.connectHeaders.first(name: name) == value)
}

@Test
func proxy_whenSOCKSConnectionWithoutAuthorization() async throws {
// Given
Expand Down
Loading