Skip to content

Commit a8b38b8

Browse files
committed
Add support for S3 express endpoints
1 parent 52c6392 commit a8b38b8

File tree

3 files changed

+115
-51
lines changed

3 files changed

+115
-51
lines changed

Sources/SotoCore/HTTP/AWSHTTPBody.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ public struct AWSHTTPBody: Sendable {
7474
}
7575
}
7676

77+
public var isEmpty: Bool {
78+
switch self.storage {
79+
case .byteBuffer(let buffer):
80+
return buffer.readableBytes == 0
81+
case .asyncSequence(_, let length):
82+
return length == 0
83+
}
84+
}
85+
7786
public var isStreaming: Bool {
7887
switch self.storage {
7988
case .byteBuffer:

Sources/SotoCore/Middleware/Middleware/S3Middleware.swift

Lines changed: 94 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -68,60 +68,51 @@ public struct S3Middleware: AWSMiddlewareProtocol {
6868
if request.url.path.hasPrefix("/arn:") {
6969
return try await handleARNBucket(request, context: context, next: next)
7070
}
71-
/// process URL into form ${bucket}.s3.amazon.com
71+
/// split path into components. Drop first as it is an empty string
7272
let paths = request.url.path.split(separator: "/", maxSplits: 2, omittingEmptySubsequences: false).dropFirst()
7373
guard let bucket = paths.first, var host = request.url.host else { return try await next(request, context) }
74+
let path = paths.dropFirst().first.flatMap { String($0) } ?? ""
7475

7576
if let port = request.url.port {
7677
host = "\(host):\(port)"
7778
}
7879
var urlPath: String
7980
var urlHost: String
80-
let isAmazonUrl = host.hasSuffix("amazonaws.com")
81+
let isAmazonUrl = host.hasSuffix(context.serviceConfig.region.partition.dnsSuffix)
8182

82-
var hostComponents = host.split(separator: ".")
83-
if isAmazonUrl, context.serviceConfig.options.contains(.s3UseTransferAcceleratedEndpoint) {
84-
if let s3Index = hostComponents.firstIndex(where: { $0 == "s3" }) {
85-
var s3 = "s3"
86-
s3 += "-accelerate"
87-
// assume next host component is region
88-
let regionIndex = s3Index + 1
89-
hostComponents.remove(at: regionIndex)
90-
hostComponents[s3Index] = Substring(s3)
91-
host = hostComponents.joined(separator: ".")
92-
}
93-
}
83+
// if bucket has suffix "-x-s3" then it must be an s3 express directory bucket and the endpoint needs set up
84+
// to use s3express
85+
if bucket.hasSuffix("--x-s3"), let host = getS3ExpressBucketEndpoint(bucket: bucket, path: path, context: context) {
86+
urlHost = host
87+
urlPath = path
88+
} else {
9489

95-
// Is bucket an ARN
96-
if bucket.hasPrefix("arn:") {
97-
guard let arn = ARN(string: bucket),
98-
let resourceType = arn.resourceType,
99-
let region = arn.region,
100-
let accountId = arn.accountId
101-
else {
102-
throw AWSClient.ClientError.invalidARN
103-
}
104-
guard resourceType == "accesspoint", arn.service == "s3-object-lambda" || arn.service == "s3-outposts" else {
105-
throw AWSClient.ClientError.invalidARN
90+
// Should we use accelerated endpoint
91+
var hostComponents = host.split(separator: ".")
92+
if isAmazonUrl, context.serviceConfig.options.contains(.s3UseTransferAcceleratedEndpoint) {
93+
if let s3Index = hostComponents.firstIndex(where: { $0 == "s3" }) {
94+
var s3 = "s3"
95+
s3 += "-accelerate"
96+
// assume next host component is region
97+
let regionIndex = s3Index + 1
98+
hostComponents.remove(at: regionIndex)
99+
hostComponents[s3Index] = Substring(s3)
100+
host = hostComponents.joined(separator: ".")
101+
}
106102
}
107-
urlPath = "/"
108-
// https://tutorial-object-lambda-accesspoint-123456789012.s3-object-lambda.us-west-2.amazonaws.com:443
109-
urlHost = "\(arn.resourceId)-\(resourceType)-\(accountId).\(arn.service).\(region).amazonaws.com"
110103

111-
// if host name contains amazonaws.com and bucket name doesn't contain a period do virtual address look up
112-
} else if isAmazonUrl || context.serviceConfig.options.contains(.s3ForceVirtualHost), !bucket.contains(".") {
113-
let pathsWithoutBucket = paths.dropFirst() // bucket
114-
urlPath = pathsWithoutBucket.first.flatMap { String($0) } ?? ""
115-
116-
if hostComponents.first == bucket {
117-
// Bucket name is part of host. No need to append bucket
118-
urlHost = host
104+
if isAmazonUrl || context.serviceConfig.options.contains(.s3ForceVirtualHost), !bucket.contains(".") {
105+
urlPath = path
106+
if hostComponents.first == bucket {
107+
// Bucket name is part of host. No need to append bucket
108+
urlHost = host
109+
} else {
110+
urlHost = "\(bucket).\(host)"
111+
}
119112
} else {
120-
urlHost = "\(bucket).\(host)"
113+
urlPath = paths.joined(separator: "/")
114+
urlHost = host
121115
}
122-
} else {
123-
urlPath = paths.joined(separator: "/")
124-
urlHost = host
125116
}
126117
let request = Self.updateRequestURL(request, host: urlHost, path: urlPath)
127118
return try await next(request, context)
@@ -155,7 +146,7 @@ public struct S3Middleware: AWSMiddlewareProtocol {
155146
let path = String(resourceIDSplit.dropFirst().first ?? "")
156147
let service = String(arn.service)
157148
let serviceIdentifier = service != "s3" ? service : "s3-accesspoint"
158-
let urlHost = "\(bucket)-\(accountId).\(serviceIdentifier).\(region).amazonaws.com"
149+
let urlHost = "\(bucket)-\(accountId).\(serviceIdentifier).\(region).\(context.serviceConfig.region.partition.dnsSuffix)"
159150
let request = Self.updateRequestURL(request, host: urlHost, path: path)
160151

161152
var context = context
@@ -177,11 +168,29 @@ public struct S3Middleware: AWSMiddlewareProtocol {
177168
return try await next(request, context)
178169
}
179170

171+
/// Handle bucket names in the form of an ARN
172+
/// - Parameters:
173+
/// - request: request
174+
/// - context: request context
175+
/// - next: next handler
176+
/// - Returns: returns response from next handler
177+
func getS3ExpressBucketEndpoint(
178+
bucket: Substring,
179+
path: String,
180+
context: AWSMiddlewareContext
181+
) -> String? {
182+
// Note this uses my own version of split (as the Swift one requires macOS 13)
183+
let split = bucket.split(separator: "--")
184+
guard split.count > 2, split.last == "x-s3" else { return nil }
185+
let zone = split[split.count - 2]
186+
return "\(bucket).s3express-\(zone).\(context.serviceConfig.region).\(context.serviceConfig.region.partition.dnsSuffix)"
187+
}
188+
180189
/// Update request with new host and path
181190
/// - Parameters:
182191
/// - request: request
183192
/// - host: new host name
184-
/// - path: new path
193+
/// - path: new path (without forward slash prefix)
185194
/// - Returns: new request
186195
static func updateRequestURL(_ request: AWSHTTPRequest, host: some StringProtocol, path: String) -> AWSHTTPRequest {
187196
var path = path
@@ -210,16 +219,18 @@ public struct S3Middleware: AWSMiddlewareProtocol {
210219
switch context.operation {
211220
// fixup CreateBucket to include location
212221
case "CreateBucket":
213-
var xml = ""
214-
if context.serviceConfig.region != .useast1 {
215-
xml += "<CreateBucketConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">"
216-
xml += "<LocationConstraint>"
217-
xml += context.serviceConfig.region.rawValue
218-
xml += "</LocationConstraint>"
219-
xml += "</CreateBucketConfiguration>"
222+
if request.body.isEmpty {
223+
var xml = ""
224+
if context.serviceConfig.region != .useast1 {
225+
xml += "<CreateBucketConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">"
226+
xml += "<LocationConstraint>"
227+
xml += context.serviceConfig.region.rawValue
228+
xml += "</LocationConstraint>"
229+
xml += "</CreateBucketConfiguration>"
230+
}
231+
// TODO: pass service config down so we can use the ByteBufferAllocator
232+
request.body = .init(string: xml)
220233
}
221-
// TODO: pass service config down so we can use the ByteBufferAllocator
222-
request.body = .init(string: xml)
223234

224235
default:
225236
break
@@ -263,3 +274,35 @@ public struct S3Middleware: AWSMiddlewareProtocol {
263274
return error
264275
}
265276
}
277+
278+
extension StringProtocol {
279+
func split(separator: some StringProtocol) -> [Self.SubSequence] {
280+
var split: [Self.SubSequence] = []
281+
var index: Self.Index = self.startIndex
282+
var startSplit: Self.Index = self.startIndex
283+
while index != self.endIndex {
284+
if let end = self[index...].prefixEnd(separator) {
285+
split.append(self[startSplit..<index])
286+
startSplit = end
287+
index = end
288+
} else {
289+
index = self.index(after: index)
290+
}
291+
}
292+
split.append(self[startSplit..<index])
293+
return split
294+
}
295+
296+
func prefixEnd(_ prefix: some StringProtocol) -> Self.Index? {
297+
var prefixIndex = prefix.startIndex
298+
var index = self.startIndex
299+
while prefixIndex != prefix.endIndex {
300+
if self[index] != prefix[prefixIndex] {
301+
return nil
302+
}
303+
index = self.index(after: index)
304+
prefixIndex = prefix.index(after: prefixIndex)
305+
}
306+
return index
307+
}
308+
}

Tests/SotoCoreTests/MiddlewareTests.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,18 @@ class MiddlewareTests: XCTestCase {
218218
}
219219
}
220220

221+
func testS3MiddlewareS3ExpressEndpoint() async throws {
222+
// Test virual address
223+
try await self.testMiddleware(
224+
S3Middleware(),
225+
serviceName: "s3",
226+
serviceOptions: .s3UseTransferAcceleratedEndpoint,
227+
uri: "/s3express--bucket--use1-az6--x-s3/file"
228+
) { request, _ in
229+
XCTAssertEqual(request.url.absoluteString, "https://s3express--bucket--use1-az6--x-s3.s3express-use1-az6.us-east-1.amazonaws.com/file")
230+
}
231+
}
232+
221233
// create a buffer of random values. Will always create the same given you supply the same z and w values
222234
// Random number generator from https://www.codeproject.com/Articles/25172/Simple-Random-Number-Generation
223235
func createRandomBuffer(_ w: UInt, _ z: UInt, size: Int) -> [UInt8] {

0 commit comments

Comments
 (0)