Skip to content

Commit 461a7aa

Browse files
authored
feat: add user-agent header middleware (#87)
1 parent 4aa194c commit 461a7aa

File tree

19 files changed

+401
-13
lines changed

19 files changed

+401
-13
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.sdk.kotlin.runtime.http
7+
8+
import software.aws.clientrt.util.OsFamily
9+
import software.aws.clientrt.util.Platform
10+
11+
/**
12+
* Metadata used to populate the `User-Agent` and `x-amz-user-agent` headers
13+
*/
14+
public data class AwsUserAgentMetadata(
15+
val sdkMetadata: SdkMetadata,
16+
val apiMetadata: ApiMetadata,
17+
val osMetadata: OsMetadata,
18+
val languageMetadata: LanguageMetadata,
19+
val execEnvMetadata: ExecutionEnvMetadata? = null
20+
) {
21+
22+
public companion object {
23+
/**
24+
* Load user agent configuration data from the current environment
25+
*/
26+
public fun fromEnvironment(apiMeta: ApiMetadata): AwsUserAgentMetadata {
27+
val sdkMeta = SdkMetadata("kotlin", apiMeta.version)
28+
val osInfo = Platform.osInfo()
29+
val osMetadata = OsMetadata(osInfo.family, osInfo.version)
30+
val langMeta = platformLanguageMetadata()
31+
return AwsUserAgentMetadata(sdkMeta, apiMeta, osMetadata, langMeta, detectExecEnv())
32+
}
33+
}
34+
35+
/**
36+
* New-style user agent header value for `x-amz-user-agent`
37+
*/
38+
val xAmzUserAgent: String = buildString {
39+
/*
40+
ABNF for the user agent:
41+
ua-string =
42+
sdk-metadata RWS
43+
[api-metadata RWS]
44+
os-metadata RWS
45+
language-metadata RWS
46+
[env-metadata RWS]
47+
*(feat-metadata RWS)
48+
*(config-metadata RWS)
49+
*(framework-metadata RWS)
50+
[appId]
51+
*/
52+
append("$sdkMetadata ")
53+
append("$apiMetadata ")
54+
append("$osMetadata ")
55+
append("$languageMetadata ")
56+
execEnvMetadata?.let { append("$it") }
57+
58+
// TODO - feature metadata
59+
// TODO - config metadata
60+
// TODO - framework metadata (e.g. Amplify would be a good candidate for this data)
61+
// TODO - appId
62+
}.trimEnd()
63+
64+
/**
65+
* Legacy user agent header value for `UserAgent`
66+
*/
67+
val userAgent: String = "$sdkMetadata"
68+
}
69+
70+
/**
71+
* SDK metadata
72+
* @property name The SDK (language) name
73+
* @property version The SDK version
74+
*/
75+
public data class SdkMetadata(val name: String, val version: String) {
76+
override fun toString(): String = "aws-sdk-$name/$version"
77+
}
78+
79+
/**
80+
* API metadata
81+
* @property serviceId The service ID (sdkId) in use (e.g. "Api Gateway")
82+
* @property version The version of the client (note this may be the same as [SdkMetadata.version] for SDK's
83+
* that don't independently version clients from one another.
84+
*/
85+
public data class ApiMetadata(val serviceId: String, val version: String) {
86+
override fun toString(): String {
87+
val formattedServiceId = serviceId.replace(" ", "-").toLowerCase()
88+
return "api/$formattedServiceId/${version.encodeUaToken()}"
89+
}
90+
}
91+
92+
/**
93+
* Operating system metadata
94+
*/
95+
public data class OsMetadata(val family: OsFamily, val version: String? = null) {
96+
override fun toString(): String {
97+
// os-family = windows / linux / macos / android / ios / other
98+
val familyStr = when (family) {
99+
OsFamily.Unknown -> "other"
100+
else -> family.toString()
101+
}
102+
return if (version != null) "os/$familyStr/${version.encodeUaToken()}" else "os/$familyStr"
103+
}
104+
}
105+
106+
/**
107+
* Programming language metadata
108+
* @property version The kotlin version in use
109+
* @property extras Additional key value pairs appropriate for the language/runtime (e.g.`jvmVm=OpenJdk`, etc)
110+
*/
111+
public data class LanguageMetadata(
112+
val version: String = KotlinVersion.CURRENT.toString(),
113+
// additional metadata key/value pairs
114+
val extras: Map<String, String> = emptyMap()
115+
) {
116+
override fun toString(): String = buildString {
117+
append("lang/kotlin/$version")
118+
extras.entries.forEach { (key, value) ->
119+
append(" md/$key/${value.encodeUaToken()}")
120+
}
121+
}
122+
}
123+
124+
// provide platform specific metadata
125+
internal expect fun platformLanguageMetadata(): LanguageMetadata
126+
127+
/**
128+
* Execution environment metadata
129+
* @property name The execution environment name (e.g. "lambda")
130+
*/
131+
public data class ExecutionEnvMetadata(val name: String) {
132+
override fun toString(): String = "exec-env/${name.encodeUaToken()}"
133+
}
134+
135+
private fun detectExecEnv(): ExecutionEnvMetadata? {
136+
// see https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html#configuration-envvars-runtime
137+
return Platform.getenv("AWS_LAMBDA_FUNCTION_NAME")?.let {
138+
ExecutionEnvMetadata("lambda")
139+
}
140+
}
141+
142+
// ua-value = token
143+
// token = 1*tchar
144+
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
145+
// "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
146+
private val VALID_TCHAR = setOf(
147+
'!', '#', '$', '%', '&',
148+
'\'', '*', '+', '-', '.',
149+
'^', '_', '`', '|', '~'
150+
)
151+
152+
private fun String.encodeUaToken(): String {
153+
val str = this
154+
return buildString(str.length) {
155+
for (chr in str) {
156+
when (chr) {
157+
' ' -> append("_")
158+
in 'a'..'z', in 'A'..'Z', in '0'..'9', in VALID_TCHAR -> append(chr)
159+
else -> continue
160+
}
161+
}
162+
}
163+
}

client-runtime/protocols/http/common/src/aws/sdk/kotlin/runtime/http/ExceptionRegistry.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package aws.sdk.kotlin.runtime.http
66

77
import aws.sdk.kotlin.runtime.AwsServiceException
8+
import aws.sdk.kotlin.runtime.InternalSdkApi
89
import software.aws.clientrt.http.HttpStatusCode
910
import software.aws.clientrt.http.operation.HttpDeserialize
1011

@@ -15,11 +16,17 @@ import software.aws.clientrt.http.operation.HttpDeserialize
1516
* @property deserializer The deserializer responsible for providing a [Throwable] instance of the actual exception
1617
* @property httpStatusCode The HTTP status code the error is returned with
1718
*/
18-
public data class ExceptionMetadata(val errorCode: String, val deserializer: HttpDeserialize<*>, val httpStatusCode: HttpStatusCode? = null)
19+
@InternalSdkApi
20+
public data class ExceptionMetadata(
21+
val errorCode: String,
22+
val deserializer: HttpDeserialize<*>,
23+
val httpStatusCode: HttpStatusCode? = null
24+
)
1925

2026
/**
2127
* Container for modeled exceptions
2228
*/
29+
@InternalSdkApi
2330
public class ExceptionRegistry {
2431
// ErrorCode -> Meta
2532
private val errorsByCodeName = mutableMapOf<String, ExceptionMetadata>()

client-runtime/protocols/http/common/src/aws/sdk/kotlin/runtime/http/ResponseUtils.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,21 @@
44
*/
55
package aws.sdk.kotlin.runtime.http
66

7+
import aws.sdk.kotlin.runtime.InternalSdkApi
78
import software.aws.clientrt.http.HttpBody
89
import software.aws.clientrt.http.content.ByteArrayContent
910
import software.aws.clientrt.http.response.HttpResponse
1011

1112
/**
1213
* Default header name identifying the unique requestId
1314
*/
14-
const val X_AMZN_REQUEST_ID_HEADER = "X-Amzn-RequestId"
15+
public const val X_AMZN_REQUEST_ID_HEADER: String = "X-Amzn-RequestId"
1516

1617
/**
1718
* Return a copy of the response with a new payload set
1819
*/
19-
fun HttpResponse.withPayload(payload: ByteArray?): HttpResponse {
20+
@InternalSdkApi
21+
public fun HttpResponse.withPayload(payload: ByteArray?): HttpResponse {
2022
val newBody = if (payload != null) {
2123
ByteArrayContent(payload)
2224
} else {

client-runtime/protocols/http/common/src/aws/sdk/kotlin/runtime/http/ServiceEndpointResolver.kt renamed to client-runtime/protocols/http/common/src/aws/sdk/kotlin/runtime/http/middleware/ServiceEndpointResolver.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
* SPDX-License-Identifier: Apache-2.0.
44
*/
55

6-
package aws.sdk.kotlin.runtime.http
6+
package aws.sdk.kotlin.runtime.http.middleware
77

8+
import aws.sdk.kotlin.runtime.InternalSdkApi
89
import aws.sdk.kotlin.runtime.client.AwsClientOption
910
import aws.sdk.kotlin.runtime.endpoint.EndpointResolver
1011
import software.aws.clientrt.http.*
@@ -15,6 +16,7 @@ import software.aws.clientrt.util.get
1516
/**
1617
* Http feature for resolving the service endpoint.
1718
*/
19+
@InternalSdkApi
1820
public class ServiceEndpointResolver(
1921
config: Config
2022
) : Feature {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.sdk.kotlin.runtime.http.middleware
7+
8+
import aws.sdk.kotlin.runtime.InternalSdkApi
9+
import aws.sdk.kotlin.runtime.http.AwsUserAgentMetadata
10+
import software.aws.clientrt.http.Feature
11+
import software.aws.clientrt.http.FeatureKey
12+
import software.aws.clientrt.http.HttpClientFeatureFactory
13+
import software.aws.clientrt.http.operation.SdkHttpOperation
14+
15+
internal const val X_AMZ_USER_AGENT: String = "x-amz-user-agent"
16+
internal const val USER_AGENT: String = "User-Agent"
17+
18+
/**
19+
* Http middleware that sets the User-Agent and x-amz-user-agent headers
20+
*/
21+
@InternalSdkApi
22+
public class UserAgent(private val awsUserAgentMetadata: AwsUserAgentMetadata) : Feature {
23+
24+
public class Config {
25+
public var metadata: AwsUserAgentMetadata? = null
26+
}
27+
28+
public companion object Feature :
29+
HttpClientFeatureFactory<Config, UserAgent> {
30+
override val key: FeatureKey<UserAgent> = FeatureKey("UserAgent")
31+
32+
override fun create(block: Config.() -> Unit): UserAgent {
33+
val config = Config().apply(block)
34+
val metadata = requireNotNull(config.metadata) { "metadata is required" }
35+
return UserAgent(metadata)
36+
}
37+
}
38+
39+
override fun <I, O> install(operation: SdkHttpOperation<I, O>) {
40+
operation.execution.mutate.intercept { req, next ->
41+
req.builder.headers[USER_AGENT] = awsUserAgentMetadata.userAgent
42+
req.builder.headers[X_AMZ_USER_AGENT] = awsUserAgentMetadata.xAmzUserAgent
43+
next.call(req)
44+
}
45+
}
46+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.sdk.kotlin.runtime.http
7+
8+
import software.aws.clientrt.util.OsFamily
9+
import kotlin.test.Test
10+
import kotlin.test.assertEquals
11+
12+
class AwsUserAgentMetadataTest {
13+
14+
@Test
15+
fun testUserAgent() {
16+
val ua = AwsUserAgentMetadata.fromEnvironment(ApiMetadata("Test Service", "1.2.3"))
17+
assertEquals("aws-sdk-kotlin/1.2.3", ua.userAgent)
18+
}
19+
20+
@Test
21+
fun testXAmzUserAgent() {
22+
val apiMeta = ApiMetadata("Test Service", "1.2.3")
23+
val sdkMeta = SdkMetadata("kotlin", apiMeta.version)
24+
val osMetadata = OsMetadata(OsFamily.Linux, "ubuntu-20.04")
25+
val langMeta = LanguageMetadata("1.4.31", mapOf("jvmVersion" to "1.11"))
26+
val ua = AwsUserAgentMetadata(sdkMeta, apiMeta, osMetadata, langMeta)
27+
val expected = "aws-sdk-kotlin/1.2.3 api/test-service/1.2.3 os/linux/ubuntu-20.04 lang/kotlin/1.4.31 md/jvmVersion/1.11"
28+
assertEquals(expected, ua.xAmzUserAgent)
29+
}
30+
}

client-runtime/protocols/http/common/test/aws/sdk/kotlin/runtime/http/ServiceEndpointResolverTest.kt renamed to client-runtime/protocols/http/common/test/aws/sdk/kotlin/runtime/http/middleware/ServiceEndpointResolverTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0.
44
*/
55

6-
package aws.sdk.kotlin.runtime.http
6+
package aws.sdk.kotlin.runtime.http.middleware
77

88
import aws.sdk.kotlin.runtime.client.AwsClientOption
99
import aws.sdk.kotlin.runtime.endpoint.Endpoint
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.sdk.kotlin.runtime.http.middleware
7+
8+
import aws.sdk.kotlin.runtime.http.ApiMetadata
9+
import aws.sdk.kotlin.runtime.http.AwsUserAgentMetadata
10+
import aws.sdk.kotlin.runtime.testing.runSuspendTest
11+
import software.aws.clientrt.http.Headers
12+
import software.aws.clientrt.http.HttpBody
13+
import software.aws.clientrt.http.HttpStatusCode
14+
import software.aws.clientrt.http.engine.HttpClientEngine
15+
import software.aws.clientrt.http.operation.*
16+
import software.aws.clientrt.http.request.HttpRequestBuilder
17+
import software.aws.clientrt.http.response.HttpResponse
18+
import software.aws.clientrt.http.sdkHttpClient
19+
import kotlin.test.Test
20+
import kotlin.test.assertTrue
21+
22+
class UserAgentTest {
23+
24+
@Test
25+
fun `it sets ua headers`() = runSuspendTest {
26+
val mockEngine = object : HttpClientEngine {
27+
override suspend fun roundTrip(requestBuilder: HttpRequestBuilder): HttpResponse {
28+
return HttpResponse(HttpStatusCode.fromValue(200), Headers {}, HttpBody.Empty, requestBuilder.build())
29+
}
30+
}
31+
32+
val client = sdkHttpClient(mockEngine)
33+
34+
val op = SdkHttpOperation.build<Unit, HttpResponse> {
35+
serializer = UnitSerializer
36+
deserializer = IdentityDeserializer
37+
context {
38+
service = "Test Service"
39+
operationName = "testOperation"
40+
}
41+
}
42+
43+
op.install(UserAgent) {
44+
metadata = AwsUserAgentMetadata.fromEnvironment(ApiMetadata("Test Service", "1.2.3"))
45+
}
46+
47+
val response = op.roundTrip(client, Unit)
48+
assertTrue(response.request.headers.contains(USER_AGENT))
49+
assertTrue(response.request.headers.contains(X_AMZ_USER_AGENT))
50+
}
51+
}

0 commit comments

Comments
 (0)