-
Notifications
You must be signed in to change notification settings - Fork 80
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
feat: UserAgent v2.1 #1690
Changes from all commits
d414fec
6a90ea1
82bf9ac
013064d
c01aa9c
3b49e11
e9e88a6
7865945
1c2f199
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,24 +15,34 @@ public struct UserAgentMiddleware<OperationStackInput, OperationStackOutput> { | |
private let X_AMZ_USER_AGENT: String = "x-amz-user-agent" | ||
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), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
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 |
---|---|---|
|
@@ -6,6 +6,7 @@ | |
// | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replace |
||
|
||
import ClientRuntime | ||
import class Smithy.Context | ||
|
||
public struct AWSUserAgentMetadata { | ||
let sdkMetadata: SDKMetadata | ||
|
@@ -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: | ||
|
@@ -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 [ | ||
|
@@ -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: " ") | ||
} | ||
|
@@ -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 | ||
|
@@ -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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
@@ -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( | ||
|
@@ -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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks at values in config and context, and saves flags to |
||
self.features = context.businessMetrics | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Takes the updated |
||
} | ||
} | ||
|
||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 } | ||
attributes.set(key: businessMetricsKey, value: combined) | ||
Comment on lines
+53
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
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) | ||
} | ||
} |
There was a problem hiding this comment.
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.