Skip to content

feat: S3 Express support #1906

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

Open
wants to merge 45 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
76b7286
Add S3 codegen
jbelkins Mar 14, 2025
fec058f
Runtime & codegen changes
jbelkins Mar 14, 2025
9bf1e9f
Fix codegen tests
jbelkins Mar 14, 2025
6171042
Fix ktlint
jbelkins Mar 14, 2025
91d5131
fix Swiftlint
jbelkins Mar 14, 2025
1c23aeb
Merge branch 'main' into jbe/s3_express
jbelkins Mar 21, 2025
75d558a
Merge branch 'main' into jbe/s3_express
jbelkins Mar 24, 2025
f1c87c1
Add env var & profile config for disableS3ExpressSessionAuth
jbelkins Mar 27, 2025
a451df5
Merge branch 'main' into jbe/s3_express
jbelkins Mar 27, 2025
460a79a
Merge branch 'main' into jbe/s3_express
jbelkins Apr 7, 2025
5454f00
Merge branch 'main' into jbe/s3_express
jbelkins Apr 8, 2025
6e7e78e
Merge branch 'main' into jbe/s3_express
jbelkins Apr 11, 2025
d575d33
Merge remote-tracking branch 'origin/main' into jbe/s3_express
jbelkins Apr 11, 2025
47c4149
Merge branch 'main' into jbe/s3_express
jbelkins Apr 22, 2025
50cff85
Merge branch 'main' into jbe/s3_express
jbelkins Apr 30, 2025
d988d00
Merge branch 'main' into jbe/s3_express
jbelkins May 2, 2025
7cf6c65
Merge branch 'main' into jbe/s3_express
jbelkins May 8, 2025
151a456
Merge remote-tracking branch 'origin/main' into jbe/s3_express
jbelkins May 21, 2025
3839fbb
Merge branch 'main' into jbe/s3_express
jbelkins May 23, 2025
0228f07
Merge branch 'main' into jbe/s3_express
jbelkins Jun 2, 2025
65f2160
Add integration test
jbelkins Jun 5, 2025
7fcdae0
Merge branch 'main' into jbe/s3_express
jbelkins Jun 5, 2025
92c906a
Set client config on S3 only
jbelkins Jun 5, 2025
b629602
Fix lint, provide error types
jbelkins Jun 5, 2025
837860e
Add S3 codegen changes
jbelkins Jun 5, 2025
3ba7b6e
Merge branch 'main' into jbe/s3_express
jbelkins Jun 5, 2025
fa00696
Fix ktlint & codegen tests
jbelkins Jun 5, 2025
39ba60e
Merge remote-tracking branch 'origin/main' into jbe/s3_express
jbelkins Jun 5, 2025
4159fa0
Merge remote-tracking branch 'origin/jbe/s3_express' into jbe/s3_express
jbelkins Jun 5, 2025
d947a97
Merge branch 'main' into jbe/s3_express
jbelkins Jun 10, 2025
c03e6b2
Added integration tests
jbelkins Jun 12, 2025
75a2d96
Merge branch 'main' into jbe/s3_express
jbelkins Jun 12, 2025
477782a
Add comments, fix Linux tests
jbelkins Jun 12, 2025
1495354
Merge branch 'main' into jbe/s3_express
jbelkins Jun 12, 2025
9fa6047
Fix Swiftlint
jbelkins Jun 12, 2025
16d2404
Fix integration tests on Linux/Swift 5.9
jbelkins Jun 12, 2025
0a0f9f9
Unwrap client config before use
jbelkins Jun 13, 2025
243c5dd
Revert CRTFileBasedConfiguration change
jbelkins Jun 16, 2025
5bb1704
Merge branch 'main' into jbe/s3_express
jbelkins Jun 16, 2025
50fef3a
Merge branch 'main' into jbe/s3_express
jbelkins Jun 18, 2025
dd17b7d
Add import to fix build
jbelkins Jun 18, 2025
20d60c9
Add needed dependency to AWSSDKHTTPAuth
jbelkins Jun 18, 2025
cee061e
Merge remote-tracking branch 'origin/main' into jbe/s3_express
jbelkins Jun 18, 2025
41d61db
Merge branch 'main' into jbe/s3_express
jbelkins Jun 20, 2025
561894c
Delete unused UUID on S3 credential cache key type
jbelkins Jun 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ private var runtimeTargets: [Target] {
),
.target(
name: "AWSSDKHTTPAuth",
dependencies: [.crt, .smithy, .clientRuntime, .smithyHTTPAuth, "AWSSDKChecksums"],
dependencies: [.crt, .smithy, .clientRuntime, .smithyHTTPAuth, "AWSSDKChecksums", "AWSSDKIdentity"],
path: "Sources/Core/AWSSDKHTTPAuth/Sources"
),
.target(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import Smithy
import XCTest
import AWSS3
import ClientRuntime
import SmithyHTTPAPI
import SmithyHTTPAuthAPI
import SmithyTestUtil
import AWSSDKIdentity
import AWSSDKHTTPAuth

// These tests confirm that the disableS3ExpressSessionAuth option
// works as expected and that the SDK selects the correct auth option
// based on bucket name.
//
// This test makes no connections to S3, either for the GetObject operation
// being tested, or to obtain S3 Express credentials.
//
// Tests set up an initial S3 client config and GetObject input params.
// An interceptor is used to determine that the correct auth scheme was
// selected based on the inputs.
//
// The ProtocolTestClient is used in place of a live HTTP client
// to prevent real HTTP requests from being made.
final class S3ExpressConfigTests: XCTestCase {
let region = "us-east-1"

// This bucket name maps to a "general purpose" (i.e. non-S3 Express) bucket.
let ordinaryBucket = "testbucket"

// This bucket name fits the S3 Express / directory bucket name pattern
let s3ExpressBucket = "testbucket--use1-az1--x-s3"

var config: S3Client.S3ClientConfiguration!

override func setUp() async throws {
try await super.setUp()
self.config = try await S3Client.Config(
s3ExpressIdentityResolver: MockS3ExpressIdentityResolver(),
region: region,
httpClientEngine: ProtocolTestClient()
)
}

func test_config_usesSigV4ForGeneralPurposeBucketByDefault() async throws {
self.config.addInterceptorProvider(CheckSelectedAuthSchemeProvider(expected: SigV4AuthScheme()))
let client = S3Client(config: config)
let input = GetObjectInput(bucket: ordinaryBucket, key: "text")
do {
_ = try await client.getObject(input: input)
} catch is TestCheckError {
// no-op
}
}

func test_config_usesSigV4ForGeneralPurposeBucketWhenS3ExpressEnabled() async throws {
self.config.disableS3ExpressSessionAuth = false
self.config.addInterceptorProvider(CheckSelectedAuthSchemeProvider(expected: SigV4AuthScheme()))
let client = S3Client(config: config)
let input = GetObjectInput(bucket: ordinaryBucket, key: "text")
do {
_ = try await client.getObject(input: input)
} catch is TestCheckError {
// no-op
}
}

func test_config_enablesS3ExpressByDefaultForS3ExpressBucket() async throws {
self.config.addInterceptorProvider(CheckSelectedAuthSchemeProvider(expected: SigV4S3ExpressAuthScheme()))
let client = S3Client(config: config)
let input = GetObjectInput(bucket: s3ExpressBucket, key: "text")
do {
_ = try await client.getObject(input: input)
} catch is TestCheckError {
// no-op
}
}

func test_config_enablesS3ExpressExplicitlyForS3ExpressBucket() async throws {
self.config.disableS3ExpressSessionAuth = false
self.config.addInterceptorProvider(CheckSelectedAuthSchemeProvider(expected: SigV4S3ExpressAuthScheme()))
let client = S3Client(config: config)
let input = GetObjectInput(bucket: s3ExpressBucket, key: "text")
do {
_ = try await client.getObject(input: input)
} catch is TestCheckError {
// no-op
}
}

func test_config_disablesS3ExpressForS3ExpressBucket() async throws {
self.config.disableS3ExpressSessionAuth = true
self.config.addInterceptorProvider(CheckSelectedAuthSchemeProvider(expected: SigV4AuthScheme()))
let client = S3Client(config: config)
let input = GetObjectInput(bucket: s3ExpressBucket, key: "text")
do {
_ = try await client.getObject(input: input)
} catch is TestCheckError {
// no-op
}
}
}

class CheckSelectedAuthScheme<InputType, OutputType>: Interceptor {
typealias RequestType = HTTPRequest
typealias ResponseType = HTTPResponse

let expectedAuthScheme: AuthScheme

init(expected expectedAuthScheme: AuthScheme) {
self.expectedAuthScheme = expectedAuthScheme
}

func readBeforeSigning(context: some AfterSerialization<InputType, RequestType>) async throws {
// Get the auth scheme and check that it matches expected
guard let selectedAuthScheme = context.getAttributes().selectedAuthScheme else {
XCTFail("No auth scheme selected"); return
}
XCTAssertEqual(selectedAuthScheme.schemeID, expectedAuthScheme.schemeID)
}
}

class CheckSelectedAuthSchemeProvider: HttpInterceptorProvider {

let expectedAuthScheme: AuthScheme

init(expected expectedAuthScheme: AuthScheme) {
self.expectedAuthScheme = expectedAuthScheme
}

func create<InputType, OutputType>() -> any Interceptor<InputType, OutputType, HTTPRequest, HTTPResponse> {
return CheckSelectedAuthScheme(expected: expectedAuthScheme)
}
}

// Real S3 credentials are not needed for this test so a mock credential resolver
// is used to prevent obtaining credentials from live S3.
//
// The mock also prevents the interceptors above from needing logic to ignore the
// CreateSession call before the GetObject.
private actor MockS3ExpressIdentityResolver: S3ExpressIdentityResolver {

func getIdentity(identityProperties: Smithy.Attributes?) async throws -> AWSSDKIdentity.S3ExpressIdentity {
return S3ExpressIdentity(
accessKeyID: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
sessionToken: "abcdef",
expiration: Date().addingTimeInterval(300.0)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import XCTest
import AWSS3

final class S3ExpressIntegrationTests: S3ExpressXCTestCase {

// This test:
// - Creates multiple S3Express ("directory") buckets
// - Puts an object with sample contents to each bucket
// - Reads each object & compares its contents to the original data
// - Deletes the object from each bucket
// - Deletes each S3Express bucket
func test_s3Express_operationalTest() async throws {

// The number of buckets to create
let n = 5

// The object key & data contents to put in each bucket
let key = "text"
let originalContents = Data("Hello, World!".utf8)

// Create the S3Express-enabled directory buckets with random names,
// save the names for later use
var buckets = [String]()
for _ in 1...n {
let baseName = String(UUID().uuidString.prefix(8)).lowercased()
let newBucket = try await createS3ExpressBucket(baseName: baseName)
buckets.append(newBucket)
}

// add an object to each bucket
for bucket in buckets {
let input = PutObjectInput(body: .data(originalContents), bucket: bucket, key: key)
let _ = try await client.putObject(input: input)
}

// Get the object from each bucket, and check its contents match original
for bucket in buckets {
let input = GetObjectInput(bucket: bucket, key: key)
let output = try await client.getObject(input: input)

let retrievedContents = try await output.body!.readData()!
XCTAssertEqual(retrievedContents, originalContents)
}

// Delete the object from each bucket
for bucket in buckets {
try await deleteObject(bucket: bucket, key: key)
}

// Delete each directory bucket
for bucket in buckets {
try await deleteBucket(bucket: bucket)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import Foundation
import XCTest
import AWSS3

final class S3ExpressPresignedURLTests: S3ExpressXCTestCase {

func test_putAndGetPresignedURL() async throws {
let key = "text"
let original = Data("Hello, World!".utf8)

// Create a S3Express (directory) bucket
let bucket = try await createS3ExpressBucket()

// Presign a PutObject URL
let putObjectInput = PutObjectInput(bucket: bucket, key: key)
let putObjectURL = try await putObjectInput.presignURL(config: config, expiration: 300.0)

// Perform the S3 PutObject request
try await URLSession.perform(url: XCTUnwrap(putObjectURL), method: "PUT", body: original)

// Presign a GetObject URL
let getObjectInput = GetObjectInput(bucket: bucket, key: key)
let getObjectURL = try await getObjectInput.presignURL(config: config, expiration: 300.0)

// Perform the S3 GetObject request & keep the response data
let retrieved = try await URLSession.perform(url: XCTUnwrap(getObjectURL))

// Compare GetObject response to PutObject request
XCTAssertEqual(original, retrieved)

// Delete the object
try await deleteObject(bucket: bucket, key: key)

// Delete the bucket
try await deleteBucket(bucket: bucket)
}
}

// Made this helper because URLSession's `data(for: URLRequest)` async method
// isn't available on Swift 5.9 for some reason. So I made my own URLSession-based
// async interface.
private extension URLSession {

@discardableResult
static func perform(url: URL, method: String = "GET", body: Data? = nil) async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = method
urlRequest.httpBody = body
let dataTask = shared.dataTask(with: urlRequest) { data, _, error in
if let error {
continuation.resume(throwing: error)
} else {
// Empty data is returned if no data was received
// This works for the purposes of the tests we're running
continuation.resume(returning: data ?? Data())
}
}
dataTask.resume()
}
}
}
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 AWSS3

class S3ExpressXCTestCase: XCTestCase {
// Region in which to run the test
var region: String { "us-west-2" }

// Availability zone where the buckets should be created
var azID: String { "usw2-az1" }

// The S3 client config object
var config: S3Client.Config!

// The S3 client
var client: S3Client!

override func setUp() async throws {
try await super.setUp()
self.config = try await S3Client.Config(region: region)
self.client = S3Client(config: config)
}

@discardableResult
func createS3ExpressBucket(
baseName: String = String(UUID().uuidString.prefix(8)).lowercased()
) async throws -> String {
let bucket = bucket(baseName: baseName)
let input = CreateBucketInput(
bucket: bucket,
createBucketConfiguration: .init(
bucket: .init(dataRedundancy: .singleavailabilityzone, type: .directory),
location: .init(name: azID, type: .availabilityzone)
)
)
let _ = try await client.createBucket(input: input)
return bucket
}

func deleteObject(bucket: String, key: String) async throws {
let deleteObjectInput = DeleteObjectInput(bucket: bucket, key: key)
_ = try await client.deleteObject(input: deleteObjectInput)
}

func deleteBucket(bucket: String) async throws {
let deleteBucketInput = DeleteBucketInput(bucket: bucket)
_ = try await client.deleteBucket(input: deleteBucketInput)
}

// Helper method to create a S3Express-compliant bucket name
func bucket(baseName: String) -> String {
"a\(baseName)--\(azID)--x-s3"
}
}
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ private var runtimeTargets: [Target] {
),
.target(
name: "AWSSDKHTTPAuth",
dependencies: [.crt, .smithy, .clientRuntime, .smithyHTTPAuth, "AWSSDKChecksums"],
dependencies: [.crt, .smithy, .clientRuntime, .smithyHTTPAuth, "AWSSDKChecksums", "AWSSDKIdentity"],
path: "Sources/Core/AWSSDKHTTPAuth/Sources"
),
.target(
Expand Down
Loading
Loading