Skip to content

Commit bd5e6bf

Browse files
authored
feat(Storage): Adding subpath strategy to the List operation (#3775)
1 parent a8beec8 commit bd5e6bf

File tree

8 files changed

+253
-6
lines changed

8 files changed

+253
-6
lines changed

Amplify/Categories/Storage/Operation/Request/StorageListRequest.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ public extension StorageListRequest {
6060
@available(*, deprecated, message: "Use `path` in Storage API instead of `Options`")
6161
public let path: String?
6262

63+
/// The strategy to use when listing contents from subpaths. Defaults to [`.include`](x-source-tag://SubpathStrategy.include)
64+
///
65+
/// - Tag: StorageListRequestOptions.subpathStrategy
66+
public let subpathStrategy: SubpathStrategy
67+
6368
/// Number between 1 and 1,000 that indicates the limit of how many entries to fetch when
6469
/// retreiving file lists from the server.
6570
///
@@ -94,15 +99,47 @@ public extension StorageListRequest {
9499
public init(accessLevel: StorageAccessLevel = .guest,
95100
targetIdentityId: String? = nil,
96101
path: String? = nil,
102+
subpathStrategy: SubpathStrategy = .include,
97103
pageSize: UInt = 1000,
98104
nextToken: String? = nil,
99105
pluginOptions: Any? = nil) {
100106
self.accessLevel = accessLevel
101107
self.targetIdentityId = targetIdentityId
102108
self.path = path
109+
self.subpathStrategy = subpathStrategy
103110
self.pageSize = pageSize
104111
self.nextToken = nextToken
105112
self.pluginOptions = pluginOptions
106113
}
107114
}
108115
}
116+
117+
public extension StorageListRequest.Options {
118+
/// Represents the strategy used when listing contents from subpaths relative to the given path.
119+
///
120+
/// - Tag: StorageListRequestOptions.SubpathStrategy
121+
enum SubpathStrategy {
122+
/// Items from nested subpaths are included in the results
123+
///
124+
/// - Tag: SubpathStrategy.include
125+
case include
126+
127+
/// Items from nested subpaths are not included in the results. Their subpaths are instead grouped under [`StorageListResult.excludedSubpaths`](StorageListResult.excludedSubpaths).
128+
///
129+
/// - Parameter delimitedBy: The delimiter used to determine subpaths. Defaults to `"/"`
130+
///
131+
/// - SeeAlso: [`StorageListResult.excludedSubpaths`](x-source-tag://StorageListResult.excludedSubpaths)
132+
///
133+
/// - Tag: SubpathStrategy.excludeWithDelimiter
134+
case exclude(delimitedBy: String = "/")
135+
136+
/// Items from nested subpaths are not included in the results. Their subpaths are instead grouped under [`StorageListResult.excludedSubpaths`](StorageListResult.excludedSubpaths).
137+
///
138+
/// - SeeAlso: [`StorageListResult.excludedSubpaths`](x-source-tag://StorageListResult.excludedSubpaths)
139+
///
140+
/// - Tag: SubpathStrategy.exclude
141+
public static var exclude: SubpathStrategy {
142+
return .exclude()
143+
}
144+
}
145+
}

Amplify/Categories/Storage/Result/StorageListResult.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ public struct StorageListResult {
1717
/// [StorageCategoryBehavior.list](x-source-tag://StorageCategoryBehavior.list).
1818
///
1919
/// - Tag: StorageListResult.init
20-
public init(items: [Item], nextToken: String? = nil) {
20+
public init(
21+
items: [Item],
22+
excludedSubpaths: [String] = [],
23+
nextToken: String? = nil
24+
) {
2125
self.items = items
26+
self.excludedSubpaths = excludedSubpaths
2227
self.nextToken = nextToken
2328
}
2429

@@ -27,6 +32,13 @@ public struct StorageListResult {
2732
/// - Tag: StorageListResult.items
2833
public var items: [Item]
2934

35+
36+
/// Array of excluded subpaths in the Result.
37+
/// This field is only populated when [`StorageListRequest.Options.subpathStrategy`](x-source-tag://StorageListRequestOptions.subpathStragegy) is set to [`.exclude()`](x-source-tag://SubpathStrategy.exclude).
38+
///
39+
/// - Tag: StorageListResult.excludedSubpaths
40+
public var excludedSubpaths: [String]
41+
3042
/// Opaque string indicating the page offset at which to resume a listing. This value is usually copied to
3143
/// [StorageListRequestOptions.nextToken](x-source-tag://StorageListRequestOptions.nextToken).
3244
///

AmplifyPlugins/Storage/Sources/AWSS3StoragePlugin/Service/Storage/AWSS3StorageService+ListBehavior.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ extension AWSS3StorageService {
3131
}
3232
let input = ListObjectsV2Input(bucket: bucket,
3333
continuationToken: options.nextToken,
34-
delimiter: nil,
34+
delimiter: options.subpathStrategy.delimiter,
3535
maxKeys: Int(options.pageSize),
3636
prefix: finalPrefix,
3737
startAfter: nil)
@@ -41,7 +41,20 @@ extension AWSS3StorageService {
4141
let items = try contents.map {
4242
try StorageListResult.Item(s3Object: $0, prefix: prefix)
4343
}
44-
return StorageListResult(items: items, nextToken: response.nextContinuationToken)
44+
45+
let commonPrefixes = response.commonPrefixes ?? []
46+
let excludedSubpaths: [String] = commonPrefixes.compactMap {
47+
guard let commonPrefix = $0.prefix else {
48+
return nil
49+
}
50+
return String(commonPrefix.dropFirst(prefix.count))
51+
}
52+
53+
return StorageListResult(
54+
items: items,
55+
excludedSubpaths: excludedSubpaths,
56+
nextToken: response.nextContinuationToken
57+
)
4558
} catch let error as StorageErrorConvertible {
4659
throw error.storageError
4760
} catch {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import Amplify
9+
10+
extension StorageListRequest.Options.SubpathStrategy {
11+
/// The delimiter for this strategy
12+
var delimiter: String? {
13+
switch self {
14+
case .exclude(let delimiter):
15+
return delimiter
16+
case .include:
17+
return nil
18+
}
19+
}
20+
}

AmplifyPlugins/Storage/Sources/AWSS3StoragePlugin/Tasks/AWSS3StorageListObjectsTask.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class AWSS3StorageListObjectsTask: StorageListObjectsTask, DefaultLogger {
4343
}
4444
let input = ListObjectsV2Input(bucket: storageBehaviour.bucket,
4545
continuationToken: request.options.nextToken,
46-
delimiter: nil,
46+
delimiter: request.options.subpathStrategy.delimiter,
4747
maxKeys: Int(request.options.pageSize),
4848
prefix: path,
4949
startAfter: nil)
@@ -57,9 +57,15 @@ class AWSS3StorageListObjectsTask: StorageListObjectsTask, DefaultLogger {
5757
return StorageListResult.Item(
5858
path: path,
5959
eTag: s3Object.eTag,
60-
lastModified: s3Object.lastModified)
60+
lastModified: s3Object.lastModified
61+
)
6162
}
62-
return StorageListResult(items: items, nextToken: response.nextContinuationToken)
63+
let commonPrefixes = response.commonPrefixes ?? []
64+
return StorageListResult(
65+
items: items,
66+
excludedSubpaths: commonPrefixes.compactMap { $0.prefix },
67+
nextToken: response.nextContinuationToken
68+
)
6369
} catch let error as StorageErrorConvertible {
6470
throw error.storageError
6571
} catch {

AmplifyPlugins/Storage/Tests/AWSS3StoragePluginTests/AWSS3StoragePluginAsyncBehaviorTests.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,22 @@ class AWSS3StoragePluginAsyncBehaviorTests: XCTestCase {
114114
XCTAssertEqual(1, storageService.interactions.count)
115115
}
116116

117+
/// - Given: A plugin configured with a mocked service
118+
/// - When: The list API is invoked with subpathStrategy set to .exclude
119+
/// - Then: The list of excluded subpaths and the list of items should be populated
120+
func testPluginListWithCommonPrefixesAsync() async throws {
121+
storageService.listHandler = { (_, _) in
122+
return .init(
123+
items: [.init(path: "path")],
124+
excludedSubpaths: ["subpath1", "subpath2"]
125+
)
126+
}
127+
let output = try await storagePlugin.list(options: .init(subpathStrategy: .exclude))
128+
XCTAssertEqual(1, output.items.count, String(describing: output))
129+
XCTAssertEqual("path", output.items.first?.path)
130+
XCTAssertEqual(2, output.excludedSubpaths.count)
131+
XCTAssertEqual("subpath1", output.excludedSubpaths[0])
132+
XCTAssertEqual("subpath2", output.excludedSubpaths[1])
133+
XCTAssertEqual(1, storageService.interactions.count)
134+
}
117135
}

AmplifyPlugins/Storage/Tests/AWSS3StoragePluginTests/Tasks/AWSS3StorageListObjectsTaskTests.swift

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class AWSS3StorageListObjectsTaskTests: XCTestCase {
3838
storageBehaviour: serviceMock)
3939
let value = try await task.value
4040
XCTAssertEqual(value.items.count, 2)
41+
XCTAssertTrue(value.excludedSubpaths.isEmpty)
4142
XCTAssertEqual(value.nextToken, "continuationToken")
4243
XCTAssertEqual(value.items[0].eTag, "tag")
4344
XCTAssertEqual(value.items[0].key, "key")
@@ -130,4 +131,81 @@ class AWSS3StorageListObjectsTaskTests: XCTestCase {
130131
XCTAssertEqual(field, "path", "Field in error should be `path`")
131132
}
132133
}
134+
135+
/// - Given: A configured Storage List Objects Task with mocked service
136+
/// - When: AWSS3StorageListObjectsTask value is invoked with subpathStrategy set to .exclude
137+
/// - Then: The delimiter should be set, the list of excluded subpaths and the list of items should be populated
138+
func testListObjectsTask_withSubpathStrategyExclude_shouldSucceed() async throws {
139+
let serviceMock = MockAWSS3StorageService()
140+
let client = serviceMock.client as! MockS3Client
141+
client.listObjectsV2Handler = { input in
142+
XCTAssertNotNil(input.delimiter, "Expected delimiter to be set")
143+
return .init(
144+
commonPrefixes: [
145+
.init(prefix: "path/subpath1/"),
146+
.init(prefix: "path/subpath2/")
147+
],
148+
contents: [
149+
.init(eTag: "tag", key: "path/result", lastModified: Date())
150+
],
151+
nextContinuationToken: "continuationToken"
152+
)
153+
}
154+
155+
let request = StorageListRequest(
156+
path: StringStoragePath.fromString("path/"),
157+
options: .init(
158+
subpathStrategy: .exclude
159+
)
160+
)
161+
let task = AWSS3StorageListObjectsTask(
162+
request,
163+
storageConfiguration: AWSS3StoragePluginConfiguration(),
164+
storageBehaviour: serviceMock
165+
)
166+
let value = try await task.value
167+
XCTAssertEqual(value.items.count, 1)
168+
XCTAssertEqual(value.items[0].eTag, "tag")
169+
XCTAssertEqual(value.items[0].path, "path/result")
170+
XCTAssertNotNil(value.items[0].lastModified)
171+
XCTAssertEqual(value.excludedSubpaths.count, 2)
172+
XCTAssertEqual(value.excludedSubpaths[0], "path/subpath1/")
173+
XCTAssertEqual(value.excludedSubpaths[1], "path/subpath2/")
174+
XCTAssertEqual(value.nextToken, "continuationToken")
175+
}
176+
177+
/// - Given: A configured Storage List Objects Task with mocked service
178+
/// - When: AWSS3StorageListObjectsTask value is invoked with subpathStrategy set to .include
179+
/// - Then: The delimiter should not be set, the list of excluded subpaths should be empty and the list of items should be populated
180+
func testListObjectsTask_withSubpathStrategyInclude_shouldSucceed() async throws {
181+
let serviceMock = MockAWSS3StorageService()
182+
let client = serviceMock.client as! MockS3Client
183+
client.listObjectsV2Handler = { input in
184+
XCTAssertNil(input.delimiter, "Expected delimiter to be nil")
185+
return .init(
186+
contents: [
187+
.init(eTag: "tag", key: "path", lastModified: Date()),
188+
],
189+
nextContinuationToken: "continuationToken"
190+
)
191+
}
192+
193+
let request = StorageListRequest(
194+
path: StringStoragePath.fromString("path"),
195+
options: .init(
196+
subpathStrategy: .include
197+
)
198+
)
199+
let task = AWSS3StorageListObjectsTask(
200+
request,
201+
storageConfiguration: AWSS3StoragePluginConfiguration(),
202+
storageBehaviour: serviceMock)
203+
let value = try await task.value
204+
XCTAssertEqual(value.items.count, 1)
205+
XCTAssertEqual(value.items[0].eTag, "tag")
206+
XCTAssertEqual(value.items[0].path, "path")
207+
XCTAssertNotNil(value.items[0].lastModified)
208+
XCTAssertTrue(value.excludedSubpaths.isEmpty)
209+
XCTAssertEqual(value.nextToken, "continuationToken")
210+
}
133211
}

AmplifyPlugins/Storage/Tests/StorageHostApp/AWSS3StoragePluginIntegrationTests/AWSS3StoragePluginListObjectsIntegrationTests.swift

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,67 @@ class AWSS3StoragePluginListObjectsIntegrationTests: AWSS3StoragePluginTestBase
189189
}
190190
}
191191

192+
/// Given: Multiple objects uploaded to a public path
193+
/// When: `Amplify.Storage.list` is invoked with `subpathStrategy: .exclude`
194+
/// Then: The API should execute successfully and list objects for the given path, without including contens from its subpaths
195+
func testList_withSubpathStrategyExclude_shouldExcludeSubpaths() async throws {
196+
let path = UUID().uuidString
197+
let data = Data(path.utf8)
198+
let uniqueStringPath = "public/\(path)"
199+
200+
// Upload data
201+
_ = try await Amplify.Storage.uploadData(path: .fromString(uniqueStringPath + "/test1"), data: data, options: nil).value
202+
_ = try await Amplify.Storage.uploadData(path: .fromString(uniqueStringPath + "/test2"), data: data, options: nil).value
203+
_ = try await Amplify.Storage.uploadData(path: .fromString(uniqueStringPath + "/subpath1/test"), data: data, options: nil).value
204+
_ = try await Amplify.Storage.uploadData(path: .fromString(uniqueStringPath + "/subpath2/test"), data: data, options: nil).value
205+
206+
let result = try await Amplify.Storage.list(
207+
path: .fromString("\(uniqueStringPath)/"),
208+
options: .init(
209+
subpathStrategy: .exclude
210+
)
211+
)
212+
213+
// Validate result
214+
XCTAssertEqual(result.items.count, 2)
215+
XCTAssertTrue(result.items.contains(where: { $0.path.hasPrefix("\(uniqueStringPath)/test") }), "Unexpected item")
216+
XCTAssertEqual(result.excludedSubpaths.count, 2)
217+
XCTAssertTrue(result.excludedSubpaths.contains(where: { $0.hasPrefix("\(uniqueStringPath)/subpath") }), "Unexpected excluded subpath")
218+
219+
// Clean up
220+
_ = try await Amplify.Storage.remove(path: .fromString(uniqueStringPath + "/test1"))
221+
_ = try await Amplify.Storage.remove(path: .fromString(uniqueStringPath + "/test2"))
222+
_ = try await Amplify.Storage.remove(path: .fromString(uniqueStringPath + "/subpath1/test"))
223+
_ = try await Amplify.Storage.remove(path: .fromString(uniqueStringPath + "/subpath2/test"))
224+
}
225+
226+
/// Given: Multiple objects uploaded to a public path
227+
/// When: `Amplify.Storage.list` is invoked with `subpathStrategy: .exclude(delimitedBy:)`
228+
/// Then: The API should execute successfully and list objects for the given path, without including contents from any subpath that is determined by the given delimiter
229+
func testList_withSubpathStrategyExclude_andCustomDelimiter_shouldExcludeSubpaths() async throws {
230+
let path = UUID().uuidString
231+
let data = Data(path.utf8)
232+
let uniqueStringPath = "public/\(path)"
233+
234+
// Upload data
235+
_ = try await Amplify.Storage.uploadData(path: .fromString(uniqueStringPath + "-test"), data: data, options: nil).value
236+
_ = try await Amplify.Storage.uploadData(path: .fromString(uniqueStringPath + "-subpath-test"), data: data, options: nil).value
237+
238+
let result = try await Amplify.Storage.list(
239+
path: .fromString("\(uniqueStringPath)-"),
240+
options: .init(
241+
subpathStrategy: .exclude(delimitedBy: "-")
242+
)
243+
)
244+
245+
// Validate result
246+
XCTAssertEqual(result.items.count, 1)
247+
XCTAssertEqual(result.items.first?.path, "\(uniqueStringPath)-test")
248+
XCTAssertEqual(result.excludedSubpaths.count, 1)
249+
XCTAssertEqual(result.excludedSubpaths.first, "\(uniqueStringPath)-subpath-")
250+
251+
// Clean up
252+
_ = try await Amplify.Storage.remove(path: .fromString(uniqueStringPath + "-test"))
253+
_ = try await Amplify.Storage.remove(path: .fromString(uniqueStringPath + "-subpath-test"))
254+
}
192255
}

0 commit comments

Comments
 (0)