Skip to content

Commit af83d7c

Browse files
authored
feat: add endpoint configuration and middleware by default (#507)
1 parent 698203f commit af83d7c

File tree

13 files changed

+304
-25
lines changed

13 files changed

+304
-25
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.smithy.kotlin.runtime.http.middleware
7+
8+
import aws.smithy.kotlin.runtime.http.*
9+
import aws.smithy.kotlin.runtime.http.operation.*
10+
import aws.smithy.kotlin.runtime.util.InternalApi
11+
12+
/**
13+
* Http feature for resolving the service endpoint.
14+
*/
15+
@InternalApi
16+
public class ResolveEndpoint(
17+
config: Config
18+
) : Feature {
19+
20+
private val resolver: EndpointResolver = requireNotNull(config.resolver) { "EndpointResolver must not be null" }
21+
22+
public class Config {
23+
/**
24+
* The resolver to use
25+
*/
26+
public var resolver: EndpointResolver? = null
27+
}
28+
29+
public companion object Feature : HttpClientFeatureFactory<Config, ResolveEndpoint> {
30+
override val key: FeatureKey<ResolveEndpoint> = FeatureKey("ResolveEndpoint")
31+
32+
override fun create(block: Config.() -> Unit): ResolveEndpoint {
33+
val config = Config().apply(block)
34+
return ResolveEndpoint(config)
35+
}
36+
}
37+
38+
override fun <I, O> install(operation: SdkHttpOperation<I, O>) {
39+
operation.execution.mutate.intercept { req, next ->
40+
val endpoint = resolver.resolve()
41+
setRequestEndpoint(req, endpoint)
42+
val logger = req.context.getLogger("ResolveEndpoint")
43+
logger.debug { "resolved endpoint: $endpoint" }
44+
next.call(req)
45+
}
46+
}
47+
}
48+
49+
/**
50+
* Populate the request URL parameters from a resolved endpoint
51+
*/
52+
@InternalApi
53+
fun setRequestEndpoint(req: SdkHttpRequest, endpoint: Endpoint) {
54+
val hostPrefix = req.context.getOrNull(HttpOperationContext.HostPrefix)
55+
val hostname = if (hostPrefix != null) "${hostPrefix}${endpoint.uri.host}" else endpoint.uri.host
56+
req.subject.url.scheme = endpoint.uri.scheme
57+
req.subject.url.host = hostname
58+
req.subject.url.port = endpoint.uri.port
59+
req.subject.headers["Host"] = hostname
60+
if (endpoint.uri.path.isNotBlank()) {
61+
val pathPrefix = endpoint.uri.path.removeSuffix("/")
62+
val original = req.subject.url.path.removePrefix("/")
63+
req.subject.url.path = "$pathPrefix/$original"
64+
}
65+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.smithy.kotlin.runtime.http.operation
7+
8+
import aws.smithy.kotlin.runtime.http.Url
9+
10+
/**
11+
* Represents the endpoint a service client should make API operation calls to.
12+
*
13+
* The SDK will automatically resolve these endpoints per API client using an internal resolver.
14+
*
15+
* @property uri The base URL endpoint clients will use to make API calls to e.g. "api.myservice.com".
16+
* NOTE: Only `scheme`, `port`, `host` and `path` are valid. Other URL elements like query parameters are ignored.
17+
18+
* @property isHostnameImmutable Flag indicating that the hostname can be modified by the SDK client.
19+
*
20+
* If the hostname is mutable the SDK clients may modify any part of the hostname based
21+
* on the requirements of the API (e.g. adding or removing content in the hostname).
22+
*
23+
* As an example Amazon S3 Client prefixing "bucketname" to the hostname or changing th hostname
24+
* service name component from "s3" to "s3-accespoint.dualstack." requires mutable hostnames.
25+
*
26+
* Care should be taken when setting this flag and providing a custom endpoint. If the hostname
27+
* is expected to be mutable and the client cannot modify the endpoint correctly, the operation
28+
* will likely fail.
29+
*/
30+
data class Endpoint(
31+
val uri: Url,
32+
val isHostnameImmutable: Boolean = false,
33+
) {
34+
constructor(uri: String) : this(Url.parse(uri))
35+
init {
36+
require(uri.parameters.isEmpty()) { "Query parameters are not currently supported by endpoint resolution" }
37+
}
38+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.smithy.kotlin.runtime.http.operation
7+
8+
/**
9+
* Resolves endpoints for a given service client
10+
*/
11+
fun interface EndpointResolver {
12+
/**
13+
* Resolve the endpoint to make requests to
14+
* @return an [Endpoint] that can be used to connect to the service
15+
*/
16+
public suspend fun resolve(): Endpoint
17+
}

runtime/protocol/http/common/src/aws/smithy/kotlin/runtime/http/operation/HttpOperationContext.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ open class HttpOperationContext {
2929

3030
/**
3131
* A prefix to prepend the resolved hostname with.
32-
* See: https://awslabs.github.io/smithy/1.0/spec/core/endpoint-traits.html#endpoint-trait
32+
* See [endpointTrait](https://awslabs.github.io/smithy/1.0/spec/core/endpoint-traits.html#endpoint-trait)
3333
*/
3434
val HostPrefix: AttributeKey<String> = AttributeKey("HostPrefix")
3535

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.smithy.kotlin.runtime.http.middleware
7+
8+
import aws.smithy.kotlin.runtime.http.*
9+
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngineBase
10+
import aws.smithy.kotlin.runtime.http.operation.Endpoint
11+
import aws.smithy.kotlin.runtime.http.operation.EndpointResolver
12+
import aws.smithy.kotlin.runtime.http.operation.HttpOperationContext
13+
import aws.smithy.kotlin.runtime.http.operation.newTestOperation
14+
import aws.smithy.kotlin.runtime.http.operation.roundTrip
15+
import aws.smithy.kotlin.runtime.http.request.HttpRequest
16+
import aws.smithy.kotlin.runtime.http.request.HttpRequestBuilder
17+
import aws.smithy.kotlin.runtime.http.response.HttpCall
18+
import aws.smithy.kotlin.runtime.http.response.HttpResponse
19+
import aws.smithy.kotlin.runtime.testing.runSuspendTest
20+
import aws.smithy.kotlin.runtime.time.Instant
21+
import aws.smithy.kotlin.runtime.util.get
22+
import kotlin.test.Test
23+
import kotlin.test.assertEquals
24+
25+
class ResolveEndpointTest {
26+
private val mockEngine = object : HttpClientEngineBase("test") {
27+
override suspend fun roundTrip(request: HttpRequest): HttpCall {
28+
val resp = HttpResponse(HttpStatusCode.OK, Headers.Empty, HttpBody.Empty)
29+
return HttpCall(request, resp, Instant.now(), Instant.now())
30+
}
31+
}
32+
private val client = sdkHttpClient(mockEngine)
33+
34+
@Test
35+
fun testHostIsSet(): Unit = runSuspendTest {
36+
val op = newTestOperation<Unit, Unit>(HttpRequestBuilder(), Unit)
37+
val endpoint = Endpoint(uri = Url.parse("https://api.test.com"))
38+
op.install(ResolveEndpoint) {
39+
resolver = EndpointResolver { endpoint }
40+
}
41+
42+
op.roundTrip(client, Unit)
43+
val actual = op.context[HttpOperationContext.HttpCallList].first().request
44+
45+
assertEquals("api.test.com", actual.url.host)
46+
assertEquals(Protocol.HTTPS, actual.url.scheme)
47+
assertEquals("api.test.com", actual.headers["Host"])
48+
}
49+
50+
@Test
51+
fun testHostWithPort(): Unit = runSuspendTest {
52+
val op = newTestOperation<Unit, Unit>(HttpRequestBuilder(), Unit)
53+
val endpoint = Endpoint(uri = Url.parse("https://api.test.com:8080"))
54+
op.install(ResolveEndpoint) {
55+
resolver = EndpointResolver { endpoint }
56+
}
57+
58+
op.roundTrip(client, Unit)
59+
val actual = op.context[HttpOperationContext.HttpCallList].first().request
60+
61+
assertEquals("api.test.com", actual.url.host)
62+
assertEquals(Protocol.HTTPS, actual.url.scheme)
63+
assertEquals(8080, actual.url.port)
64+
}
65+
66+
@Test
67+
fun testHostWithBasePath(): Unit = runSuspendTest {
68+
val op = newTestOperation<Unit, Unit>(HttpRequestBuilder().apply { url.path = "/operation" }, Unit)
69+
val endpoint = Endpoint(uri = Url.parse("https://api.test.com:8080/foo/bar"))
70+
op.install(ResolveEndpoint) {
71+
resolver = EndpointResolver { endpoint }
72+
}
73+
74+
op.roundTrip(client, Unit)
75+
val actual = op.context[HttpOperationContext.HttpCallList].first().request
76+
77+
assertEquals("api.test.com", actual.url.host)
78+
assertEquals(Protocol.HTTPS, actual.url.scheme)
79+
assertEquals(8080, actual.url.port)
80+
assertEquals("/foo/bar/operation", actual.url.path)
81+
}
82+
83+
@Test
84+
fun testHostPrefix(): Unit = runSuspendTest {
85+
val op = newTestOperation<Unit, Unit>(HttpRequestBuilder().apply { url.path = "/operation" }, Unit)
86+
val endpoint = Endpoint(uri = Url.parse("http://api.test.com"))
87+
op.install(ResolveEndpoint) {
88+
resolver = EndpointResolver { endpoint }
89+
}
90+
op.context[HttpOperationContext.HostPrefix] = "prefix."
91+
92+
op.roundTrip(client, Unit)
93+
val actual = op.context[HttpOperationContext.HttpCallList].first().request
94+
95+
assertEquals("prefix.api.test.com", actual.url.host)
96+
assertEquals(Protocol.HTTP, actual.url.scheme)
97+
assertEquals("/operation", actual.url.path)
98+
}
99+
}

smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ object RuntimeTypes {
5050
val Md5ChecksumMiddleware = runtimeSymbol("Md5Checksum", KotlinDependency.HTTP, "middleware")
5151
val MutateHeadersMiddleware = runtimeSymbol("MutateHeaders", KotlinDependency.HTTP, "middleware")
5252
val RetryFeature = runtimeSymbol("RetryFeature", KotlinDependency.HTTP, "middleware")
53+
val ResolveEndpoint = runtimeSymbol("ResolveEndpoint", KotlinDependency.HTTP, "middleware")
5354
}
5455

5556
object Operation {
@@ -60,11 +61,12 @@ object RuntimeTypes {
6061
val context = runtimeSymbol("context", KotlinDependency.HTTP, "operation")
6162
val roundTrip = runtimeSymbol("roundTrip", KotlinDependency.HTTP, "operation")
6263
val execute = runtimeSymbol("execute", KotlinDependency.HTTP, "operation")
64+
val EndpointResolver = runtimeSymbol("EndpointResolver", KotlinDependency.HTTP, "operation")
6365
}
6466

6567
object Engine {
66-
val HttpClientEngineConfig = runtimeSymbol("HttpClientEngineConfig", KotlinDependency.HTTP, "engine")
6768
val HttpClientEngine = runtimeSymbol("HttpClientEngine", KotlinDependency.HTTP, "engine")
69+
val HttpClientEngineConfig = runtimeSymbol("HttpClientEngineConfig", KotlinDependency.HTTP, "engine")
6870
}
6971
}
7072

@@ -75,6 +77,7 @@ object RuntimeTypes {
7577
val ServiceErrorMetadata = runtimeSymbol("ServiceErrorMetadata", KotlinDependency.CORE)
7678
val Instant = runtimeSymbol("Instant", KotlinDependency.CORE, "time")
7779
val TimestampFormat = runtimeSymbol("TimestampFormat", KotlinDependency.CORE, "time")
80+
val ClientException = runtimeSymbol("ClientException", KotlinDependency.CORE)
7881

7982
object Content {
8083
val ByteArrayContent = runtimeSymbol("ByteArrayContent", KotlinDependency.CORE, "content")

smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/ClientConfigGenerator.kt

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,33 +27,39 @@ class ClientConfigGenerator(
2727
vararg properties: ClientConfigProperty
2828
) {
2929

30+
companion object {
31+
/**
32+
* Attempt to detect configuration properties automatically based on the model
33+
*/
34+
fun detectDefaultProps(context: RenderingContext<ServiceShape>): List<ClientConfigProperty> {
35+
val defaultProps = mutableListOf<ClientConfigProperty>()
36+
defaultProps.add(KotlinClientRuntimeConfigProperty.SdkLogMode)
37+
if (context.protocolGenerator?.applicationProtocol?.isHttpProtocol == true) {
38+
defaultProps.add(KotlinClientRuntimeConfigProperty.HttpClientEngine)
39+
defaultProps.add(KotlinClientRuntimeConfigProperty.EndpointResolver)
40+
}
41+
if (context.shape != null && context.shape.hasIdempotentTokenMember(context.model)) {
42+
defaultProps.add(KotlinClientRuntimeConfigProperty.IdempotencyTokenProvider)
43+
}
44+
defaultProps.add(KotlinClientRuntimeConfigProperty.RetryStrategy)
45+
return defaultProps
46+
}
47+
}
48+
3049
private val props = mutableListOf<ClientConfigProperty>()
3150

3251
init {
3352
props.addAll(properties)
3453
if (detectDefaultProps) {
35-
registerDefaultProps()
54+
// register auto detected properties
55+
props.addAll(detectDefaultProps(ctx))
3656
}
3757

3858
// register properties from integrations
3959
val integrationProps = ctx.integrations.flatMap { it.additionalServiceConfigProps(ctx) }
4060
props.addAll(integrationProps)
4161
}
4262

43-
/**
44-
* Attempt to detect and register properties automatically based on the model
45-
*/
46-
private fun registerDefaultProps() {
47-
props.add(KotlinClientRuntimeConfigProperty.SdkLogMode)
48-
if (ctx.protocolGenerator?.applicationProtocol?.isHttpProtocol == true) {
49-
props.add(KotlinClientRuntimeConfigProperty.HttpClientEngine)
50-
}
51-
if (ctx.shape != null && ctx.shape.hasIdempotentTokenMember(ctx.model)) {
52-
props.add(KotlinClientRuntimeConfigProperty.IdempotencyTokenProvider)
53-
}
54-
props.add(KotlinClientRuntimeConfigProperty.RetryStrategy)
55-
}
56-
5763
fun render() {
5864
if (ctx.writer.getContext("configClass.name") == null) {
5965
// push context to be used throughout generation of the class

smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/ClientConfigProperty.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ object KotlinClientRuntimeConfigProperty {
215215
val IdempotencyTokenProvider: ClientConfigProperty
216216
val RetryStrategy: ClientConfigProperty
217217
val SdkLogMode: ClientConfigProperty
218+
val EndpointResolver: ClientConfigProperty
218219

219220
init {
220221
val httpClientConfigSymbol = buildSymbol {
@@ -299,5 +300,14 @@ object KotlinClientRuntimeConfigProperty {
299300
debug purposes.
300301
""".trimIndent()
301302
}
303+
304+
EndpointResolver = ClientConfigProperty {
305+
symbol = RuntimeTypes.Http.Operation.EndpointResolver
306+
documentation = """
307+
Set the [${symbol!!.fullName}] used to resolve service endpoints. Operation requests will be
308+
made against the endpoint returned by the resolver.
309+
""".trimIndent()
310+
propertyType = ClientConfigPropertyType.Required()
311+
}
302312
}
303313
}

smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/ServiceGenerator.kt

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class ServiceGenerator(private val ctx: RenderingContext<ServiceShape>) {
2323
/**
2424
* SectionId used when rendering the service interface companion object
2525
*/
26-
object ServiceInterfaceCompanionObject : SectionId {
26+
object SectionServiceCompanionObject : SectionId {
2727
/**
2828
* Context key for the service symbol
2929
*/
@@ -33,7 +33,12 @@ class ServiceGenerator(private val ctx: RenderingContext<ServiceShape>) {
3333
/**
3434
* SectionId used when rendering the service configuration object
3535
*/
36-
object SectionServiceInterfaceConfig : SectionId
36+
object SectionServiceConfig : SectionId {
37+
/**
38+
* The current rendering context for the service generator
39+
*/
40+
const val RenderingContext = "RenderingContext"
41+
}
3742

3843
init {
3944
require(ctx.shape is ServiceShape) { "ServiceShape is required for generating a service interface; was: ${ctx.shape}" }
@@ -64,8 +69,8 @@ class ServiceGenerator(private val ctx: RenderingContext<ServiceShape>) {
6469
// allow integrations to add additional fields to companion object or configuration
6570
writer.write("")
6671
writer.declareSection(
67-
ServiceInterfaceCompanionObject,
68-
context = mapOf(ServiceInterfaceCompanionObject.ServiceSymbol to serviceSymbol)
72+
SectionServiceCompanionObject,
73+
context = mapOf(SectionServiceCompanionObject.ServiceSymbol to serviceSymbol)
6974
) {
7075
renderCompanionObject()
7176
}
@@ -82,7 +87,10 @@ class ServiceGenerator(private val ctx: RenderingContext<ServiceShape>) {
8287
}
8388

8489
private fun renderServiceConfig() {
85-
writer.declareSection(SectionServiceInterfaceConfig) {
90+
writer.declareSection(
91+
SectionServiceConfig,
92+
context = mapOf(SectionServiceConfig.RenderingContext to ctx)
93+
) {
8694
ClientConfigGenerator(ctx).render()
8795
}
8896
}

smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/protocol/HttpBindingProtocolGenerator.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ abstract class HttpBindingProtocolGenerator : ProtocolGenerator {
6363
*/
6464
protected open fun getDefaultHttpMiddleware(ctx: ProtocolGenerator.GenerationContext): List<ProtocolMiddleware> =
6565
listOf(
66+
ResolveEndpointMiddleware(),
6667
StandardRetryMiddleware(),
6768
)
6869

0 commit comments

Comments
 (0)