Skip to content

Commit 18f0524

Browse files
authored
feat(rt): add support for HTTP proxies (#665)
1 parent 4e8c875 commit 18f0524

File tree

22 files changed

+686
-24
lines changed

22 files changed

+686
-24
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"id": "32235989-e284-4cec-aafc-0f73b941cfa1",
3+
"type": "feature",
4+
"description": "Add support for HTTP proxies",
5+
"issues": [
6+
"awslabs/smithy-kotlin#494"
7+
]
8+
}

gradle.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ kotestVersion=5.3.0
3333
kotlinCompileTestingVersion=1.4.8
3434
jacocoVersion=0.8.8
3535
kotlinxBenchmarkVersion=0.4.2
36+
testContainersVersion=1.17.2
3637

3738
# serialization
3839
kamlVersion=0.36.0

runtime/protocol/http-client-engines/http-client-engine-crt/common/src/aws/smithy/kotlin/runtime/http/engine/crt/AbstractBufferedReadChannel.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@ internal abstract class AbstractBufferedReadChannel(
223223

224224
val segment = newReadableSegment(bytesIn)
225225
val result = segments.trySend(segment)
226+
227+
// nothing to do, channel is closed no more data is expected
228+
if (result.isClosed) return
229+
226230
check(result.isSuccess) { "failed to queue segment" }
227231

228232
// advertise bytes available

runtime/protocol/http-client-engines/http-client-engine-crt/common/src/aws/smithy/kotlin/runtime/http/engine/crt/CrtHttpEngine.kt

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import aws.smithy.kotlin.runtime.client.ExecutionContext
1212
import aws.smithy.kotlin.runtime.crt.SdkDefaultIO
1313
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngine
1414
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngineBase
15+
import aws.smithy.kotlin.runtime.http.engine.ProxyConfig
1516
import aws.smithy.kotlin.runtime.http.engine.callContext
1617
import aws.smithy.kotlin.runtime.http.operation.withContext
1718
import aws.smithy.kotlin.runtime.http.request.HttpRequest
@@ -76,7 +77,9 @@ public class CrtHttpEngine(public val config: CrtHttpEngineConfig) : HttpClientE
7677
override suspend fun roundTrip(context: ExecutionContext, request: HttpRequest): HttpCall {
7778
val callContext = callContext()
7879
val reqLogger = logger.withContext(context)
79-
val manager = getManagerForUri(request.uri)
80+
81+
val proxyConfig = config.proxySelector.select(request.url)
82+
val manager = getManagerForUri(request.uri, proxyConfig)
8083

8184
// LIFETIME: connection will be released back to the pool/manager when
8285
// the response completes OR on exception (both handled by the completion handler registered on the stream
@@ -110,9 +113,22 @@ public class CrtHttpEngine(public val config: CrtHttpEngineConfig) : HttpClientE
110113
customTlsContext?.close()
111114
}
112115

113-
private suspend fun getManagerForUri(uri: Uri): HttpClientConnectionManager = mutex.withLock {
116+
private suspend fun getManagerForUri(uri: Uri, proxyConfig: ProxyConfig): HttpClientConnectionManager = mutex.withLock {
114117
connManagers.getOrPut(uri.authority) {
115-
HttpClientConnectionManager(options.apply { this.uri = uri }.build())
118+
val connOpts = options.apply {
119+
this.uri = uri
120+
proxyOptions = when (proxyConfig) {
121+
is ProxyConfig.Http -> HttpProxyOptions(
122+
proxyConfig.url.host,
123+
proxyConfig.url.port,
124+
authUsername = proxyConfig.url.userInfo?.username,
125+
authPassword = proxyConfig.url.userInfo?.password,
126+
authType = if (proxyConfig.url.userInfo != null) HttpProxyAuthorizationType.Basic else HttpProxyAuthorizationType.None
127+
)
128+
else -> null
129+
}
130+
}.build()
131+
HttpClientConnectionManager(connOpts)
116132
}
117133
}
118134
}

runtime/protocol/http-client-engines/http-client-engine-okhttp/jvm/src/aws/smithy/kotlin/runtime/http/engine/okhttp/HttpEngineEventListener.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ internal class HttpEngineEventListener(
8787
}
8888

8989
override fun proxySelectEnd(call: Call, url: HttpUrl, proxies: List<Proxy>) {
90-
traceCall(call) { "proxy select end: url=$url" }
90+
traceCall(call) { "proxy select end: url=$url; proxies=$proxies" }
9191
}
9292

9393
override fun requestBodyStart(call: Call) {

runtime/protocol/http-client-engines/http-client-engine-okhttp/jvm/src/aws/smithy/kotlin/runtime/http/engine/okhttp/OkHttpEngine.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,8 @@ private fun OkHttpEngineConfig.buildClient(): OkHttpClient {
9898
}
9999
protocols(protocols)
100100
}
101+
102+
proxySelector(OkHttpProxySelector(config.proxySelector))
103+
proxyAuthenticator(OkHttpProxyAuthenticator(config.proxySelector))
101104
}.build()
102105
}

runtime/protocol/http-client-engines/http-client-engine-okhttp/jvm/src/aws/smithy/kotlin/runtime/http/engine/okhttp/OkHttpUtils.kt

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,23 @@
66
package aws.smithy.kotlin.runtime.http.engine.okhttp
77

88
import aws.smithy.kotlin.runtime.client.ExecutionContext
9-
import aws.smithy.kotlin.runtime.http.HttpBody
10-
import aws.smithy.kotlin.runtime.http.HttpStatusCode
9+
import aws.smithy.kotlin.runtime.http.*
10+
import aws.smithy.kotlin.runtime.http.engine.ProxyConfig
1111
import aws.smithy.kotlin.runtime.http.request.HttpRequest
1212
import aws.smithy.kotlin.runtime.http.response.HttpResponse
1313
import aws.smithy.kotlin.runtime.io.SdkByteChannel
1414
import aws.smithy.kotlin.runtime.io.SdkByteReadChannel
15+
import aws.smithy.kotlin.runtime.logging.Logger
1516
import kotlinx.coroutines.*
17+
import okhttp3.Authenticator
18+
import okhttp3.Credentials
1619
import okhttp3.RequestBody.Companion.toRequestBody
20+
import okhttp3.Route
1721
import okhttp3.internal.http.HttpMethod
22+
import java.io.IOException
23+
import java.net.*
1824
import kotlin.coroutines.CoroutineContext
25+
import aws.smithy.kotlin.runtime.http.engine.ProxySelector as SdkProxySelector
1926
import okhttp3.Request as OkHttpRequest
2027
import okhttp3.Response as OkHttpResponse
2128

@@ -112,3 +119,59 @@ internal fun CoroutineContext.derivedName(name: String): CoroutineName {
112119
val existing = get(CoroutineName)?.name ?: return CoroutineName(name)
113120
return CoroutineName("$existing:$name")
114121
}
122+
123+
internal class OkHttpProxyAuthenticator(
124+
private val selector: SdkProxySelector,
125+
) : Authenticator {
126+
override fun authenticate(route: Route?, response: okhttp3.Response): okhttp3.Request? {
127+
if (response.request.header("Proxy-Authorization") != null) {
128+
// Give up, we've already failed to authenticate.
129+
return null
130+
}
131+
132+
val url = response.request.url.let {
133+
Url(scheme = Protocol(it.scheme, it.port), host = it.host, port = it.port)
134+
}
135+
136+
// NOTE: We will end up querying the proxy selector twice. We do this to allow
137+
// the url.userInfo be used for Basic auth scheme. Supporting other auth schemes
138+
// will require defining dedicated proxy auth configuration APIs that work
139+
// on a per/request basis (much like the okhttp interface we are implementing here...)
140+
val userInfo = when (val proxyConfig = selector.select(url)) {
141+
is ProxyConfig.Http -> proxyConfig.url.userInfo
142+
else -> null
143+
} ?: return null
144+
145+
for (challenge in response.challenges()) {
146+
if (challenge.scheme.lowercase() == "okhttp-preemptive" || challenge.scheme == "Basic") {
147+
return response.request.newBuilder()
148+
.header("Proxy-Authorization", Credentials.basic(userInfo.username, userInfo.password))
149+
.build()
150+
}
151+
}
152+
153+
return null
154+
}
155+
}
156+
157+
internal class OkHttpProxySelector(
158+
private val sdkSelector: SdkProxySelector
159+
) : ProxySelector() {
160+
override fun select(uri: URI?): List<Proxy> {
161+
if (uri == null) return emptyList()
162+
val url = uri.toUrl()
163+
164+
return when (val proxyConfig = sdkSelector.select(url)) {
165+
is ProxyConfig.Http -> {
166+
val okProxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(proxyConfig.url.host, proxyConfig.url.port))
167+
return listOf(okProxy)
168+
}
169+
else -> emptyList()
170+
}
171+
}
172+
173+
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
174+
val logger = Logger.getLogger<OkHttpProxySelector>()
175+
logger.error { "failed to connect to proxy: uri=$uri; socketAddress: $sa; exception: $ioe" }
176+
}
177+
}

runtime/protocol/http-client-engines/test-suite/build.gradle.kts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ extra["skipPublish"] = true
1313
val coroutinesVersion: String by project
1414
val ktorVersion: String by project
1515
val slf4jVersion: String by project
16+
val testContainersVersion: String by project
1617

1718
kotlin {
1819
sourceSets {
@@ -40,6 +41,13 @@ kotlin {
4041
}
4142
}
4243

44+
jvmTest {
45+
dependencies {
46+
implementation("org.testcontainers:testcontainers:$testContainersVersion")
47+
implementation("org.testcontainers:junit-jupiter:$testContainersVersion")
48+
}
49+
}
50+
4351
all {
4452
languageSettings.optIn("aws.smithy.kotlin.runtime.util.InternalApi")
4553
}
@@ -99,6 +107,13 @@ val testTasks = listOf("allTests", "jvmTest")
99107
}
100108
}
101109

110+
tasks.jvmTest {
111+
// set test environment for proxy tests
112+
systemProperty("MITM_PROXY_SCRIPTS_ROOT", projectDir.resolve("proxy-scripts").absolutePath)
113+
val enableProxyTestsProp = "aws.test.http.enableProxyTests"
114+
systemProperty(enableProxyTestsProp, System.getProperties().getOrDefault(enableProxyTestsProp, "true"))
115+
}
116+
102117
gradle.buildFinished {
103118
startTestServer.stop()
104119
}

runtime/protocol/http-client-engines/test-suite/common/src/aws/smithy/kotlin/runtime/http/test/util/AbstractEngineTest.kt

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package aws.smithy.kotlin.runtime.http.test.util
88
import aws.smithy.kotlin.runtime.http.SdkHttpClient
99
import aws.smithy.kotlin.runtime.http.Url
1010
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngine
11+
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngineConfig
1112
import aws.smithy.kotlin.runtime.http.request.HttpRequestBuilder
1213
import aws.smithy.kotlin.runtime.http.request.url
1314
import aws.smithy.kotlin.runtime.http.sdkHttpClient
@@ -24,28 +25,32 @@ private val TEST_SERVER = Url.parse("http://127.0.0.1:8082")
2425
/**
2526
* Abstract base class that all engine test suite test classes should inherit from.
2627
*/
27-
abstract class AbstractEngineTest() {
28+
abstract class AbstractEngineTest {
2829

2930
/**
3031
* Build a test that will run against each engine in the test suite.
3132
*
3233
* Concrete implementations for each KMP target are responsible for loading the engines
3334
* supported by that platform and executing the test
3435
*/
35-
fun testEngines(block: EngineTestBuilder.() -> Unit) {
36-
engines().forEach { engine ->
37-
sdkHttpClient(engine, manageEngine = true).use { client ->
38-
testWithClient(client, block = block)
36+
fun testEngines(skipEngines: Set<String> = emptySet(), block: EngineTestBuilder.() -> Unit) {
37+
val builder = EngineTestBuilder().apply(block)
38+
engineFactories()
39+
.filter { it.name !in skipEngines }
40+
.forEach { engineFactory ->
41+
val engine = engineFactory.create(builder.engineConfig)
42+
sdkHttpClient(engine, manageEngine = true).use { client ->
43+
testWithClient(client, builder = builder)
44+
}
3945
}
40-
}
4146
}
4247
}
4348

4449
/**
4550
* Concrete implementations for each KMP target are responsible for loading the engines
4651
* supported by that platform and executing the test
4752
*/
48-
internal expect fun engines(): List<HttpClientEngine>
53+
internal expect fun engineFactories(): List<TestEngineFactory>
4954

5055
/**
5156
* Container for current engine test environment
@@ -60,10 +65,16 @@ data class TestEnvironment(val testServer: Url, val coroutineId: Int, val attemp
6065
* Configure the test
6166
*/
6267
class EngineTestBuilder {
68+
/**
69+
* Lambda function invoked to configure the [HttpClientEngineConfig] to use for the test. If not specified
70+
* [HttpClientEngineConfig.Default] is used
71+
*/
72+
var engineConfig: HttpClientEngineConfig.Builder.() -> Unit = {}
73+
6374
/**
6475
* Lambda function that is invoked with the current test environment and an [SdkHttpClient]
6576
* configured with an engine loaded by [AbstractEngineTest]. Invoke calls against test routes and make
66-
* assertions here
77+
* assertions here. This will potentially be invoked multiple times (once for each engine supported by a platform).
6778
*/
6879
var test: (suspend (env: TestEnvironment, client: SdkHttpClient) -> Unit) = { _, _ -> error("engine test not configured") }
6980

@@ -84,9 +95,8 @@ class EngineTestBuilder {
8495
fun testWithClient(
8596
client: SdkHttpClient,
8697
timeout: Duration = 60.seconds,
87-
block: suspend EngineTestBuilder.() -> Unit
98+
builder: EngineTestBuilder
8899
): Unit = runBlockingTest(timeout = timeout) {
89-
val builder = EngineTestBuilder().apply { block() }
90100
runConcurrently(builder.concurrency) { coroutineId ->
91101
repeat(builder.repeat) { attempt ->
92102
val env = TestEnvironment(TEST_SERVER, coroutineId, attempt)
@@ -121,3 +131,20 @@ fun HttpRequestBuilder.testSetup(env: TestEnvironment) {
121131
url(env.testServer)
122132
headers.append("Host", "${env.testServer.host}:${env.testServer.port}")
123133
}
134+
135+
fun EngineTestBuilder.engineConfig(block: HttpClientEngineConfig.Builder.() -> Unit) {
136+
engineConfig = block
137+
}
138+
139+
internal data class TestEngineFactory(
140+
/**
141+
* Unique name for the engine
142+
*/
143+
val name: String,
144+
/**
145+
* Configure a new [HttpClientEngine] instance and return it
146+
*/
147+
val configure: (HttpClientEngineConfig.Builder.() -> Unit) -> HttpClientEngine
148+
) {
149+
fun create(block: HttpClientEngineConfig.Builder.() -> Unit): HttpClientEngine = configure(block)
150+
}

runtime/protocol/http-client-engines/test-suite/common/test/aws/smithy/kotlin/runtime/http/test/AsyncStressTest.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ package aws.smithy.kotlin.runtime.http.test
88
import aws.smithy.kotlin.runtime.http.HttpStatusCode
99
import aws.smithy.kotlin.runtime.http.readAll
1010
import aws.smithy.kotlin.runtime.http.request.HttpRequest
11-
import aws.smithy.kotlin.runtime.http.request.url
1211
import aws.smithy.kotlin.runtime.http.response.complete
1312
import aws.smithy.kotlin.runtime.http.test.util.AbstractEngineTest
1413
import aws.smithy.kotlin.runtime.http.test.util.test

0 commit comments

Comments
 (0)