Skip to content

Commit 5cd02f8

Browse files
committed
feat(storage): Add pagination support.
1 parent f648f41 commit 5cd02f8

File tree

8 files changed

+203
-7
lines changed

8 files changed

+203
-7
lines changed

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

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,39 +5,84 @@
55
// SPDX-License-Identifier: Apache-2.0
66
//
77

8+
/// - Tag: StorageListRequest
89
public struct StorageListRequest: AmplifyOperationRequest {
10+
911
/// Options to adjust the behavior of this request, including plugin-options
12+
/// - Tag: StorageListRequest
1013
public let options: Options
1114

15+
/// - Tag: StorageListRequest.init
1216
public init(options: Options) {
1317
self.options = options
1418
}
1519
}
1620

1721
public extension StorageListRequest {
18-
/// Options to adjust the behavior of this request, including plugin-options
22+
23+
/// Options available to callers of
24+
/// [StorageCategoryBehavior.list](x-source-tag://StorageCategoryBehavior.list).
25+
///
26+
/// Tag: StorageListRequestOptions
1927
struct Options {
28+
2029
/// Access level of the storage system. Defaults to `public`
30+
///
31+
/// - Tag: StorageListRequestOptions.accessLevel
2132
public let accessLevel: StorageAccessLevel
2233

2334
/// Target user to apply the action on
35+
///
36+
/// - Tag: StorageListRequestOptions.targetIdentityId
2437
public let targetIdentityId: String?
2538

2639
/// Path to the keys
40+
///
41+
/// - Tag: StorageListRequestOptions.path
2742
public let path: String?
2843

44+
/// Number between 1 and 1,000 that indicates the limit of how many entries to fetch when
45+
/// retreiving file lists from the server.
46+
///
47+
/// NOTE: Plugins may decide to throw or perform normalization when encoutering vaues outside
48+
/// the specified range.
49+
///
50+
/// - SeeAlso:
51+
/// [StorageListRequestOptions.nextToken](x-source-tag://StorageListRequestOptions.nextToken)
52+
/// [StorageListResult.nextToken](x-source-tag://StorageListResult.nextToken)
53+
///
54+
/// - Tag: StorageListRequestOptions.pageSize
55+
public let pageSize: UInt
56+
57+
/// Opaque string indicating the page offset at which to resume a listing. This is usually a copy of
58+
/// the value from [StorageListResult.nextToken](x-source-tag://StorageListResult.nextToken).
59+
///
60+
/// - SeeAlso:
61+
/// [StorageListRequestOptions.pageSize](x-source-tag://StorageListRequestOptions.pageSize)
62+
/// [StorageListResult.nextToken](x-source-tag://StorageListResult.nextToken)
63+
///
64+
/// - Tag: StorageListRequestOptions.nextToken
65+
public let nextToken: String?
66+
2967
/// Extra plugin specific options, only used in special circumstances when the existing options do not provide
3068
/// a way to utilize the underlying storage system's functionality. See plugin documentation for expected
3169
/// key/values
70+
///
71+
/// - Tag: StorageListRequestOptions.pluginOptions
3272
public let pluginOptions: Any?
3373

74+
/// - Tag: StorageListRequestOptions.init
3475
public init(accessLevel: StorageAccessLevel = .guest,
3576
targetIdentityId: String? = nil,
3677
path: String? = nil,
78+
pageSize: UInt = 1000,
79+
nextToken: String? = nil,
3780
pluginOptions: Any? = nil) {
3881
self.accessLevel = accessLevel
3982
self.targetIdentityId = targetIdentityId
4083
self.path = path
84+
self.pageSize = pageSize
85+
self.nextToken = nextToken
4186
self.pluginOptions = pluginOptions
4287
}
4388
}

Amplify/Categories/Storage/Result/StorageListResult.swift

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,35 +7,71 @@
77

88
import Foundation
99

10+
/// Represents the output of a call to
11+
/// [StorageCategoryBehavior.list](x-source-tag://StorageCategoryBehavior.list)
12+
///
13+
/// - Tag: StorageListResult
1014
public struct StorageListResult {
11-
public init(items: [Item]) {
15+
16+
/// This is meant to be called by plugins implementing
17+
/// [StorageCategoryBehavior.list](x-source-tag://StorageCategoryBehavior.list).
18+
///
19+
/// - Tag: StorageListResult.init
20+
public init(items: [Item], nextToken: String? = nil) {
1221
self.items = items
22+
self.nextToken = nextToken
1323
}
1424

15-
// Array of Items in the Result
25+
/// Array of Items in the Result
26+
///
27+
/// - Tag: StorageListResult.items
1628
public var items: [Item]
29+
30+
/// Opaque string indicating the page offset at which to resume a listing. This value is usually copied to
31+
/// [StorageListRequestOptions.nextToken](x-source-tag://StorageListRequestOptions.nextToken).
32+
///
33+
/// - SeeAlso:
34+
/// [StorageListRequestOptions.nextToken](x-source-tag://StorageListRequestOptions.nextToken)
35+
///
36+
/// - Tag: StorageListResult.nextToken
37+
public let nextToken: String?
1738
}
1839

1940
extension StorageListResult {
2041

42+
/// - Tag: StorageListResultItem
2143
public struct Item {
2244

2345
/// The unique identifier of the object in storage.
46+
///
47+
/// - Tag: StorageListResultItem.key
2448
public let key: String
2549

2650
/// Size in bytes of the object
51+
///
52+
/// - Tag: StorageListResultItem.size
2753
public let size: Int?
2854

2955
/// The date the Object was Last Modified
56+
///
57+
/// - Tag: StorageListResultItem.lastModified
3058
public let lastModified: Date?
3159

3260
/// The entity tag is an MD5 hash of the object.
3361
/// ETag reflects only changes to the contents of an object, not its metadata.
62+
///
63+
/// - Tag: StorageListResultItem.eTag
3464
public let eTag: String?
3565

3666
/// Additional results specific to the plugin.
67+
///
68+
/// - Tag: StorageListResultItem.pluginResults
3769
public let pluginResults: Any?
3870

71+
/// This is meant to be called by plugins implementing
72+
/// [StorageCategoryBehavior.list](x-source-tag://StorageCategoryBehavior.list).
73+
///
74+
/// - Tag: StorageListResultItem.init
3975
public init(key: String,
4076
size: Int? = nil,
4177
eTag: String? = nil,

Amplify/Categories/Storage/StorageCategoryBehavior.swift

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77

88
import Foundation
99

10-
/// Behavior of the Storage category that clients will use
10+
/// Behavior of the Storage category used though `Amplify.Storage.*`. Plugin implementations
11+
/// conform to this protocol indirectly though the
12+
/// [StorageCategoryPlugin](x-source-tag://StorageCategoryPlugin) protocol.
13+
///
14+
/// - Tag: StorageCategoryBehavior
1115
public protocol StorageCategoryBehavior {
1216

1317
/// Retrieve the remote URL for the object from storage.
@@ -16,6 +20,8 @@ public protocol StorageCategoryBehavior {
1620
/// - key: The unique identifier for the object in storage.
1721
/// - options: Parameters to specific plugin behavior
1822
/// - Returns: requested Get URL
23+
///
24+
/// - Tag: StorageCategoryBehavior.getURL
1925
@discardableResult
2026
func getURL(key: String,
2127
options: StorageGetURLOperation.Request.Options?) async throws -> URL
@@ -26,6 +32,8 @@ public protocol StorageCategoryBehavior {
2632
/// - key: The unique identifier for the object in storage
2733
/// - options: Options to adjust the behavior of this request, including plugin-options
2834
/// - Returns: A task that provides progress updates and the key which was used to download
35+
///
36+
/// - Tag: StorageCategoryBehavior.downloadData
2937
@discardableResult
3038
func downloadData(key: String,
3139
options: StorageDownloadDataOperation.Request.Options?) -> StorageDownloadDataTask
@@ -37,6 +45,8 @@ public protocol StorageCategoryBehavior {
3745
/// - local: The local file to download destination
3846
/// - options: Parameters to specific plugin behavior
3947
/// - Returns: A task that provides progress updates and the key which was used to download
48+
///
49+
/// - Tag: StorageCategoryBehavior.downloadFile
4050
@discardableResult
4151
func downloadFile(key: String,
4252
local: URL,
@@ -49,6 +59,8 @@ public protocol StorageCategoryBehavior {
4959
/// - data: The data in memory to be uploaded
5060
/// - options: Parameters to specific plugin behavior
5161
/// - Returns: A task that provides progress updates and the key which was used to upload
62+
///
63+
/// - Tag: StorageCategoryBehavior.uploadData
5264
@discardableResult
5365
func uploadData(key: String,
5466
data: Data,
@@ -61,6 +73,8 @@ public protocol StorageCategoryBehavior {
6173
/// - local: The path to a local file.
6274
/// - options: Parameters to specific plugin behavior
6375
/// - Returns: A task that provides progress updates and the key which was used to upload
76+
///
77+
/// - Tag: StorageCategoryBehavior.uploadFile
6478
@discardableResult
6579
func uploadFile(key: String,
6680
local: URL,
@@ -72,6 +86,8 @@ public protocol StorageCategoryBehavior {
7286
/// - key: The unique identifier of the object in storage.
7387
/// - options: Parameters to specific plugin behavior
7488
/// - Returns: An operation object that provides notifications and actions related to the execution of the work
89+
///
90+
/// - Tag: StorageCategoryBehavior.remove
7591
@discardableResult
7692
func remove(key: String,
7793
options: StorageRemoveOperation.Request.Options?) async throws -> String
@@ -82,12 +98,16 @@ public protocol StorageCategoryBehavior {
8298
/// - options: Parameters to specific plugin behavior
8399
/// - resultListener: Triggered when the list is complete
84100
/// - Returns: An operation object that provides notifications and actions related to the execution of the work
101+
///
102+
/// - Tag: StorageCategoryBehavior.list
85103
@discardableResult
86104
func list(options: StorageListOperation.Request.Options?) async throws -> StorageListResult
87105

88106
/// Handles background events which are related to URLSession
89107
/// - Parameter identifier: identifier
90108
/// - Returns: returns true if the identifier is handled by Amplify
109+
///
110+
/// - Tag: StorageCategoryBehavior.handleBackgroundEvents
91111
func handleBackgroundEvents(identifier: String) async -> Bool
92112

93113
}

Amplify/Categories/Storage/StorageCategoryPlugin.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// SPDX-License-Identifier: Apache-2.0
66
//
77

8+
/// - Tag: StorageCategoryPlugin
89
public protocol StorageCategoryPlugin: Plugin, StorageCategoryBehavior { }
910

1011
public extension StorageCategoryPlugin {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ extension AWSS3StorageService {
3030
finalPrefix = prefix
3131
}
3232
let input = ListObjectsV2Input(bucket: bucket,
33-
continuationToken: nil,
33+
continuationToken: options.nextToken,
3434
delimiter: nil,
35-
maxKeys: 1_000,
35+
maxKeys: Int(options.pageSize),
3636
prefix: finalPrefix,
3737
startAfter: nil)
3838
do {
@@ -41,7 +41,7 @@ extension AWSS3StorageService {
4141
let items = try contents.map {
4242
try StorageListResult.Item(s3Object: $0, prefix: prefix)
4343
}
44-
return StorageListResult(items: items)
44+
return StorageListResult(items: items, nextToken: response.nextContinuationToken)
4545
} catch let error as SdkError<ListObjectsV2OutputError> {
4646
throw error.storageError
4747
} catch {

AmplifyPlugins/Storage/Tests/AWSS3StoragePluginTests/Request/AWSS3StorageListRequestTests.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ class StorageListRequestTests: XCTestCase {
2727
XCTAssertNil(storageErrorOptional)
2828
}
2929

30+
/// - Given: A an options parameter containing pagination options
31+
/// - When: The containing request is validated
32+
/// - Then: No errors are raised
33+
func testValidateWithPaginationOptions() {
34+
let pageSizeOnly = StorageListRequest(options: StorageListRequest.Options(pageSize: UInt.random(in: 1..<1_000)))
35+
XCTAssertNil(pageSizeOnly.validate())
36+
37+
let nextTokenOnly = StorageListRequest(options: StorageListRequest.Options(nextToken: UUID().uuidString))
38+
XCTAssertNil(nextTokenOnly.validate())
39+
40+
let pageSizeAndNextToken = StorageListRequest(options: StorageListRequest.Options(
41+
pageSize: UInt.random(in: 1..<1_000),
42+
nextToken: UUID().uuidString
43+
))
44+
XCTAssertNil(pageSizeAndNextToken.validate())
45+
}
46+
3047
func testValidateEmptyTargetIdentityIdError() {
3148
let options = StorageListRequest.Options(accessLevel: .protected,
3249
targetIdentityId: "",

AmplifyPlugins/Storage/Tests/AWSS3StoragePluginTests/Service/Storage/AWSS3StorageServiceListTests.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,25 @@ final class AWSS3StorageServiceListTests: XCTestCase {
5050
systemUnderTest = nil
5151
}
5252

53+
/// Given: Any S3 bucket (client)
54+
/// When: A listing of it is requested using pagination options
55+
/// Then: The service propagates the pagination options to its underlying S3 client.
56+
func testPaginationOptionsPropagation() async throws {
57+
var inputs: [ListObjectsV2Input] = []
58+
client.listObjectsV2Handler = { input in
59+
inputs.append(input)
60+
return .init(contents: [])
61+
}
62+
let pageSize: UInt = UInt.random(in: 1..<1_000)
63+
let nextToken = UUID().uuidString
64+
let options = StorageListRequest.Options(pageSize: pageSize,
65+
nextToken: nextToken)
66+
let listing = try await systemUnderTest.list(prefix: prefix, options: options)
67+
XCTAssertEqual(listing.items.map { $0.key }, [])
68+
XCTAssertEqual(inputs.map { $0.continuationToken }, [nextToken])
69+
XCTAssertEqual(inputs.map { $0.maxKeys }, [Int(pageSize)])
70+
}
71+
5372
/// Given: A empty S3 bucket (client)
5473
/// When: A listing of it is requested using typical parameters
5574
/// Then: The service returns an empty list of StorageListResult.Item

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@ import CryptoKit
1212

1313
class AWSS3StoragePluginBasicIntegrationTests: AWSS3StoragePluginTestBase {
1414

15+
var uploadedKeys: [String]!
16+
17+
override func setUp() async throws {
18+
try await super.setUp()
19+
uploadedKeys = []
20+
}
21+
22+
override func tearDown() async throws {
23+
for key in uploadedKeys {
24+
await self.remove(key: key)
25+
}
26+
uploadedKeys = nil
27+
try await super.tearDown()
28+
}
29+
1530
/// Given: An data object
1631
/// When: Upload the data
1732
/// Then: The operation completes successfully
@@ -203,6 +218,49 @@ class AWSS3StoragePluginBasicIntegrationTests: AWSS3StoragePluginTestBase {
203218
await remove(key: key)
204219
}
205220

221+
/// Given: A collection of objects in storage numbering `objectCount`.
222+
/// When: The list API is invoked twice using a pageSize of `((objectCount/2) - 1)` and its
223+
/// corresponding token options.
224+
/// Then: All objects are listed.
225+
func testListTwoPages() async throws {
226+
let objectCount = UInt.random(in: 16..<32)
227+
// One more than half in order to ensure there are only two pages
228+
let pageSize = UInt(objectCount/2) + 1
229+
let path = "pagination-\(UUID().uuidString)"
230+
for i in 0..<objectCount {
231+
let key = "\(path)/\(i).txt"
232+
let data = Data("\(i)".utf8)
233+
await uploadData(key: key, data: data)
234+
uploadedKeys.append(key)
235+
}
236+
237+
// First half of listing
238+
let firstResult = try await Amplify.Storage.list(options: .init(
239+
accessLevel: .guest,
240+
path: path,
241+
pageSize: pageSize
242+
))
243+
let firstPage = try XCTUnwrap(firstResult.items)
244+
XCTAssertEqual(firstPage.count, Int(pageSize))
245+
let firstNextToken = try XCTUnwrap(firstResult.nextToken)
246+
247+
// Second half of listing
248+
let secondResult = try await Amplify.Storage.list(options: .init(
249+
accessLevel: .guest,
250+
path: path,
251+
pageSize: pageSize,
252+
nextToken: firstNextToken
253+
))
254+
let secondPage = try XCTUnwrap(secondResult.items)
255+
XCTAssertEqual(secondPage.count, Int(objectCount - pageSize))
256+
XCTAssertNil(secondResult.nextToken)
257+
258+
XCTAssertEqual(
259+
uploadedKeys.sorted(),
260+
Array((firstPage + secondPage).map { $0.key }).sorted()
261+
)
262+
}
263+
206264
/// Given: No object in storage for the key
207265
/// When: Call the list API
208266
/// Then: The operation completes successfully with empty list of keys returned

0 commit comments

Comments
 (0)