Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: UserAgent v2.1 #1690

Merged
merged 9 commits into from
Aug 26, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,34 @@ public struct UserAgentMiddleware<OperationStackInput, OperationStackOutput> {
private let X_AMZ_USER_AGENT: String = "x-amz-user-agent"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delay when the AWSUserAgentMetadata is initialized, from middleware init to middleware execution. This ensures we have the latest and all values we need from context to populate the user agent header.

private let USER_AGENT: String = "User-Agent"

let metadata: AWSUserAgentMetadata

public init(metadata: AWSUserAgentMetadata) {
self.metadata = metadata
}

private func addHeader(builder: HTTPRequestBuilder) {
builder.withHeader(name: USER_AGENT, value: metadata.userAgent)
let serviceID: String
let version: String
let config: DefaultClientConfiguration & AWSDefaultClientConfiguration

public init(
serviceID: String,
version: String,
config: DefaultClientConfiguration & AWSDefaultClientConfiguration
) {
self.serviceID = serviceID
self.version = version
self.config = config
}
}

extension UserAgentMiddleware: HttpInterceptor {
public typealias InputType = OperationStackInput
public typealias OutputType = OperationStackOutput

public func modifyBeforeRetryLoop(context: some MutableRequest<Self.InputType, HTTPRequest>) async throws {
public func modifyBeforeTransmit(context: some MutableRequest<Self.InputType, HTTPRequest>) async throws {
let awsUserAgentString = AWSUserAgentMetadata.fromConfigAndContext(
serviceID: serviceID,
version: version,
config: UserAgentValuesFromConfig(config: config),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserAgentValuesFromConfig is a container for subset of config values in the original config. This makes testing much easier + small benefit of not passing entire config object again.

context: context.getAttributes()
).userAgent
let builder = context.getRequest().toBuilder()
addHeader(builder: builder)
builder.withHeader(name: USER_AGENT, value: awsUserAgentString)
context.updateRequest(updated: builder.build())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace configMetadata and featureMetadata with the new businessMetrics.


import ClientRuntime
import class Smithy.Context

public struct AWSUserAgentMetadata {
let sdkMetadata: SDKMetadata
Expand All @@ -15,9 +16,8 @@ public struct AWSUserAgentMetadata {
let osMetadata: OSMetadata
let languageMetadata: LanguageMetadata
let executionEnvMetadata: ExecutionEnvMetadata?
let configMetadata: [ConfigMetadata]
let businessMetrics: BusinessMetrics?
let appIDMetadata: AppIDMetadata?
let featureMetadata: [FeatureMetadata]
let frameworkMetadata: [FrameworkMetadata]

/// ABNF for the user agent:
Expand All @@ -29,9 +29,8 @@ public struct AWSUserAgentMetadata {
/// language-metadata RWS
/// [env-metadata RWS]
/// ; ordering is not strictly required in the following section
/// *(config-metadata RWS)
/// [business-metrics]
/// [appId]
/// *(feat-metadata RWS)
/// *(framework-metadata RWS)
var userAgent: String {
return [
Expand All @@ -42,9 +41,8 @@ public struct AWSUserAgentMetadata {
[osMetadata.description],
[languageMetadata.description],
[executionEnvMetadata?.description],
configMetadata.map(\.description) as [String?],
[businessMetrics?.description],
[appIDMetadata?.description],
featureMetadata.map(\.description) as [String?],
frameworkMetadata.map(\.description) as [String?]
].flatMap { $0 }.compactMap { $0 }.joined(separator: " ")
}
Expand All @@ -56,9 +54,8 @@ public struct AWSUserAgentMetadata {
osMetadata: OSMetadata,
languageMetadata: LanguageMetadata,
executionEnvMetadata: ExecutionEnvMetadata? = nil,
configMetadata: [ConfigMetadata] = [],
businessMetrics: BusinessMetrics? = nil,
appIDMetadata: AppIDMetadata? = nil,
featureMetadata: [FeatureMetadata] = [],
frameworkMetadata: [FrameworkMetadata] = []
) {
self.sdkMetadata = sdkMetadata
Expand All @@ -67,16 +64,16 @@ public struct AWSUserAgentMetadata {
self.osMetadata = osMetadata
self.languageMetadata = languageMetadata
self.executionEnvMetadata = executionEnvMetadata
self.configMetadata = configMetadata
self.businessMetrics = businessMetrics
self.appIDMetadata = appIDMetadata
self.featureMetadata = featureMetadata
self.frameworkMetadata = frameworkMetadata
}

public static func fromConfig(
public static func fromConfigAndContext(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now we need context as well, since business metrics get set in context by middlewares (interceptors).

serviceID: String,
version: String,
config: DefaultClientConfiguration & AWSDefaultClientConfiguration
config: UserAgentValuesFromConfig,
context: Context
) -> AWSUserAgentMetadata {
let apiMetadata = APIMetadata(serviceID: serviceID, version: version)
let sdkMetadata = SDKMetadata(version: apiMetadata.version)
Expand All @@ -85,7 +82,7 @@ public struct AWSUserAgentMetadata {
let osVersion = PlatformOperationSystemVersion.operatingSystemVersion()
let osMetadata = OSMetadata(family: currentOS, version: osVersion)
let languageMetadata = LanguageMetadata(version: swiftVersion)
let configMetadata = [ConfigMetadata(type: .retry(config.awsRetryMode))]
let businessMetrics = BusinessMetrics(config: config, context: context)
let appIDMetadata = AppIDMetadata(name: config.appID)
let frameworkMetadata = [FrameworkMetadata]()
return AWSUserAgentMetadata(
Expand All @@ -95,10 +92,27 @@ public struct AWSUserAgentMetadata {
osMetadata: osMetadata,
languageMetadata: languageMetadata,
executionEnvMetadata: ExecutionEnvMetadata.detectExecEnv(),
configMetadata: configMetadata,
businessMetrics: businessMetrics,
appIDMetadata: appIDMetadata,
featureMetadata: [], // Feature metadata will be supplied when features are implemented
frameworkMetadata: frameworkMetadata
)
}
}

public class UserAgentValuesFromConfig {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a container for config values that we need for user agent header.

var appID: String?
var endpoint: String?
var awsRetryMode: AWSRetryMode

public init(appID: String?, endpoint: String?, awsRetryMode: AWSRetryMode) {
self.endpoint = endpoint
self.awsRetryMode = awsRetryMode
self.appID = appID
}

public init(config: DefaultClientConfiguration & AWSDefaultClientConfiguration) {
self.appID = config.appID
self.endpoint = config.endpoint
self.awsRetryMode = config.awsRetryMode
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import ClientRuntime
import class Smithy.Context
import struct Smithy.AttributeKey

struct BusinessMetrics {
// Mapping of human readable feature ID to the corresponding metric value
let features: [String: String]

init(
config: UserAgentValuesFromConfig,
context: Context
) {
setFlagsIntoContext(config: config, context: context)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks at values in config and context, and saves flags to context.businessMetrics.

self.features = context.businessMetrics
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Takes the updated context.businessMetrics from line directly above this and sets it to features.

}
}

extension BusinessMetrics: CustomStringConvertible {
var description: String {
var commaSeparatedMetricValues = features.values.sorted().joined(separator: ",")
// Cut last metric value from string until the
// comma-separated list of metric values are at or below 1024 bytes in size
if commaSeparatedMetricValues.lengthOfBytes(using: .ascii) > 1024 {
while commaSeparatedMetricValues.lengthOfBytes(using: .ascii) > 1024 {
commaSeparatedMetricValues = commaSeparatedMetricValues.substringBeforeLast(",")
}
}
Comment on lines +30 to +34
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEP states that the string value of business metrics (comma-separated metric values without spaces) cannot exceed 1024 bytes, and last value in the string must be removed until size requirement is met.

return "m/\(commaSeparatedMetricValues)"
}
}

private extension String {
func substringBeforeLast(_ separator: String) -> String {
if let range = self.range(of: separator, options: .backwards) {
return String(self[..<range.lowerBound])
} else {
return self
}
}
}

public extension Context {
var businessMetrics: Dictionary<String, String> {
get { attributes.get(key: businessMetricsKey) ?? [:] }
set(newPair) {
var combined = businessMetrics
combined.merge(newPair) { (current, new) in new }

Check warning on line 54 in Sources/Core/AWSClientRuntime/Sources/AWSClientRuntime/UserAgent/BusinessMetrics.swift

View workflow job for this annotation

GitHub Actions / swiftlint

Unused parameter in a closure should be replaced with _ (unused_closure_parameter)
attributes.set(key: businessMetricsKey, value: combined)
Comment on lines +53 to +55
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds a new entry to existing dictionary, overriding with new entry's value if existing dictionary already has an entry with the same key

}
}
}

public let businessMetricsKey = AttributeKey<Dictionary<String, String>>(name: "BusinessMetrics")

/* List of readable "feature ID" to "metric value"; last updated on 08/19/2024
[Feature ID] [Metric Value] [Flag Supported]
"RESOURCE_MODEL" : "A" :
"WAITER" : "B" :
"PAGINATOR" : "C" :
"RETRY_MODE_LEGACY" : "D" : Y
"RETRY_MODE_STANDARD" : "E" : Y
"RETRY_MODE_ADAPTIVE" : "F" : Y
"S3_TRANSFER" : "G" :
"S3_CRYPTO_V1N" : "H" :
"S3_CRYPTO_V2" : "I" :
"S3_EXPRESS_BUCKET" : "J" :
"S3_ACCESS_GRANTS" : "K" :
"GZIP_REQUEST_COMPRESSION" : "L" :
"PROTOCOL_RPC_V2_CBOR" : "M" :
"ENDPOINT_OVERRIDE" : "N" : Y
"ACCOUNT_ID_ENDPOINT" : "O" :
"ACCOUNT_ID_MODE_PREFERRED" : "P" :
"ACCOUNT_ID_MODE_DISABLED" : "Q" :
"ACCOUNT_ID_MODE_REQUIRED" : "R" :
"SIGV4A_SIGNING" : "S" : Y
"RESOLVED_ACCOUNT_ID" : "T" :
*/
Comment on lines +62 to +84
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to be updated each time we support new flags

fileprivate func setFlagsIntoContext(

Check warning on line 85 in Sources/Core/AWSClientRuntime/Sources/AWSClientRuntime/UserAgent/BusinessMetrics.swift

View workflow job for this annotation

GitHub Actions / swiftlint

Prefer `private` over `fileprivate` declarations (private_over_fileprivate)
config: UserAgentValuesFromConfig,
context: Context
) {
// Handle D, E, F
switch config.awsRetryMode {
case .legacy:
context.businessMetrics = ["RETRY_MODE_LEGACY": "D"]
case .standard:
context.businessMetrics = ["RETRY_MODE_STANDARD": "E"]
case .adaptive:
context.businessMetrics = ["RETRY_MODE_ADAPTIVE": "F"]
}
// Handle N
if let endpoint = config.endpoint, !endpoint.isEmpty {
context.businessMetrics = ["ENDPOINT_OVERRIDE": "N"]
}
// Handle S
if context.selectedAuthScheme?.schemeID == "aws.auth#sigv4a" {
context.businessMetrics = ["SIGV4A_SIGNING": "S"]
}
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import XCTest
import ClientRuntime
@testable import AWSClientRuntime
import SmithyRetriesAPI
import SmithyHTTPAuthAPI
import SmithyIdentity
import SmithyRetriesAPI
import Smithy

class BusinessMetricsTests: XCTestCase {
var context: Context!

override func setUp() async throws {
context = Context(attributes: Attributes())
}

func test_business_metrics_section_truncation() {
context.businessMetrics = ["SHORT_FILLER": "A"]
let longMetricValue = String(repeating: "F", count: 1025)
context.businessMetrics = ["LONG_FILLER": longMetricValue]
let userAgent = AWSUserAgentMetadata.fromConfigAndContext(
serviceID: "test",
version: "1.0",
config: UserAgentValuesFromConfig(appID: nil, endpoint: nil, awsRetryMode: .standard),
context: context
)
// Assert values in context match with values assigned to user agent
XCTAssertEqual(userAgent.businessMetrics?.features, context.businessMetrics)
// Assert string gets truncated successfully
let expectedTruncatedString = "m/A,E"
XCTAssertEqual(userAgent.businessMetrics?.description, expectedTruncatedString)
}

func test_multiple_flags_in_context() {
context.businessMetrics = ["FIRST": "A"]
context.businessMetrics = ["SECOND": "B"]
context.setSelectedAuthScheme(SelectedAuthScheme( // S
schemeID: "aws.auth#sigv4a",
identity: nil,
signingProperties: nil,
signer: nil
))
let userAgent = AWSUserAgentMetadata.fromConfigAndContext(
serviceID: "test",
version: "1.0",
config: UserAgentValuesFromConfig(appID: nil, endpoint: "test-endpoint", awsRetryMode: .adaptive),
context: context
)
// F comes from retry mode being adaptive & N comes from endpoint override
let expectedString = "m/A,B,F,N,S"
XCTAssertEqual(userAgent.businessMetrics?.description, expectedString)
}
}
Loading
Loading