Skip to content

Commit e19b69c

Browse files
authored
feat(storage): Support pre-signed upload URLs (#6673)
1 parent c07c8f1 commit e19b69c

6 files changed

Lines changed: 410 additions & 3 deletions

File tree

packages/storage/amplify_storage_s3/example/integration_test/get_url_test.dart

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,126 @@ void main() {
109109
});
110110
});
111111

112+
group('presigned URL upload (method: PUT)', () {
113+
testWidgets('can upload via PUT presigned URL and verify content', (
114+
_,
115+
) async {
116+
final uploadPath = 'public/put-url-upload-${uuid()}';
117+
final uploadData = 'uploaded via presigned PUT URL'.codeUnits;
118+
addTearDownPath(StoragePath.fromString(uploadPath));
119+
120+
// Generate a PUT presigned URL
121+
final putUrlResult = await Amplify.Storage.getUrl(
122+
path: StoragePath.fromString(uploadPath),
123+
options: const StorageGetUrlOptions(
124+
pluginOptions: S3GetUrlPluginOptions(
125+
method: StorageAccessMethod.put,
126+
expiresIn: Duration(minutes: 5),
127+
),
128+
),
129+
).result;
130+
131+
// Upload via HTTP PUT using the presigned URL
132+
final putResponse = await put(
133+
putUrlResult.url,
134+
body: uploadData,
135+
headers: {'Content-Type': 'application/octet-stream'},
136+
);
137+
expect(putResponse.statusCode, 200);
138+
139+
// Verify the upload by downloading with a GET presigned URL
140+
final getUrlResult = await Amplify.Storage.getUrl(
141+
path: StoragePath.fromString(uploadPath),
142+
).result;
143+
final actualData = await readData(getUrlResult.url);
144+
expect(actualData, uploadData);
145+
});
146+
147+
testWidgets('PUT presigned URL with text content type', (_) async {
148+
final uploadPath = 'public/put-url-text-${uuid()}';
149+
const uploadContent = 'Hello from presigned PUT URL!';
150+
addTearDownPath(StoragePath.fromString(uploadPath));
151+
152+
final putUrlResult = await Amplify.Storage.getUrl(
153+
path: StoragePath.fromString(uploadPath),
154+
options: const StorageGetUrlOptions(
155+
pluginOptions: S3GetUrlPluginOptions(
156+
method: StorageAccessMethod.put,
157+
),
158+
),
159+
).result;
160+
161+
final putResponse = await put(
162+
putUrlResult.url,
163+
body: uploadContent,
164+
headers: {'Content-Type': 'text/plain'},
165+
);
166+
expect(putResponse.statusCode, 200);
167+
168+
// Verify content
169+
final getUrlResult = await Amplify.Storage.getUrl(
170+
path: StoragePath.fromString(uploadPath),
171+
).result;
172+
final downloadedContent = await read(getUrlResult.url);
173+
expect(downloadedContent, uploadContent);
174+
});
175+
176+
testWidgets('PUT presigned URL with useAccelerateEndpoint', (_) async {
177+
final uploadPath = 'public/put-url-accelerate-${uuid()}';
178+
final uploadData = 'accelerated upload'.codeUnits;
179+
addTearDownPath(StoragePath.fromString(uploadPath));
180+
181+
final putUrlResult = await Amplify.Storage.getUrl(
182+
path: StoragePath.fromString(uploadPath),
183+
options: const StorageGetUrlOptions(
184+
pluginOptions: S3GetUrlPluginOptions(
185+
method: StorageAccessMethod.put,
186+
useAccelerateEndpoint: true,
187+
),
188+
),
189+
).result;
190+
191+
expect(putUrlResult.url.host, contains('.s3-accelerate.'));
192+
193+
final putResponse = await put(putUrlResult.url, body: uploadData);
194+
expect(putResponse.statusCode, 200);
195+
196+
// Verify
197+
final getUrlResult = await Amplify.Storage.getUrl(
198+
path: StoragePath.fromString(uploadPath),
199+
).result;
200+
final actualData = await readData(getUrlResult.url);
201+
expect(actualData, uploadData);
202+
});
203+
204+
testWidgets('default method is GET (backward compatibility)', (
205+
_,
206+
) async {
207+
// Ensure that getUrl without method still works as a GET URL
208+
final result = await Amplify.Storage.getUrl(
209+
path: StoragePath.fromString(path),
210+
options: const StorageGetUrlOptions(
211+
pluginOptions: S3GetUrlPluginOptions(),
212+
),
213+
).result;
214+
final actualData = await readData(result.url);
215+
expect(actualData, data);
216+
});
217+
218+
testWidgets('explicit method GET works the same as default', (_) async {
219+
final result = await Amplify.Storage.getUrl(
220+
path: StoragePath.fromString(path),
221+
options: const StorageGetUrlOptions(
222+
pluginOptions: S3GetUrlPluginOptions(
223+
method: StorageAccessMethod.get,
224+
),
225+
),
226+
).result;
227+
final actualData = await readData(result.url);
228+
expect(actualData, data);
229+
});
230+
});
231+
112232
group('with options', () {
113233
testWidgets('expiresIn', (_) async {
114234
const duration = Duration(seconds: 10);

packages/storage/amplify_storage_s3_dart/lib/src/model/s3_get_url_plugin_options.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ import 'package:amplify_core/amplify_core.dart';
55

66
part 's3_get_url_plugin_options.g.dart';
77

8+
/// The HTTP method type for the presigned URL.
9+
enum StorageAccessMethod {
10+
/// Generate a presigned URL for downloading (GET) an object.
11+
get,
12+
13+
/// Generate a presigned URL for uploading (PUT) an object.
14+
put,
15+
}
16+
817
/// {@template storage.amplify_storage_s3.get_url_plugin_options}
918
/// The configurable parameters invoking the Storage S3 plugin `getUrl`
1019
/// API.
@@ -16,16 +25,19 @@ class S3GetUrlPluginOptions extends StorageGetUrlPluginOptions {
1625
Duration expiresIn = const Duration(minutes: 15),
1726
bool validateObjectExistence = false,
1827
bool useAccelerateEndpoint = false,
28+
StorageAccessMethod method = StorageAccessMethod.get,
1929
}) : this._(
2030
expiresIn: expiresIn,
2131
validateObjectExistence: validateObjectExistence,
2232
useAccelerateEndpoint: useAccelerateEndpoint,
33+
method: method,
2334
);
2435

2536
const S3GetUrlPluginOptions._({
2637
this.expiresIn = const Duration(minutes: 15),
2738
this.validateObjectExistence = false,
2839
this.useAccelerateEndpoint = false,
40+
this.method = StorageAccessMethod.get,
2941
});
3042

3143
/// {@macro storage.amplify_storage_s3.get_url_plugin_options}
@@ -42,11 +54,21 @@ class S3GetUrlPluginOptions extends StorageGetUrlPluginOptions {
4254
/// {@macro storage.amplify_storage_s3.transfer_acceleration}
4355
final bool useAccelerateEndpoint;
4456

57+
/// The HTTP method type for the presigned URL.
58+
///
59+
/// - [StorageAccessMethod.get]: Generate URL for downloading objects
60+
/// (default).
61+
/// - [StorageAccessMethod.put]: Generate URL for uploading objects.
62+
///
63+
/// Defaults to [StorageAccessMethod.get].
64+
final StorageAccessMethod method;
65+
4566
@override
4667
List<Object?> get props => [
4768
expiresIn,
4869
validateObjectExistence,
4970
useAccelerateEndpoint,
71+
method,
5072
];
5173

5274
@override

packages/storage/amplify_storage_s3_dart/lib/src/model/s3_get_url_plugin_options.g.dart

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/storage/amplify_storage_s3_dart/lib/src/storage_s3_service/service/storage_s3_service_impl.dart

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,12 @@ class StorageS3Service {
212212
}
213213

214214
/// Takes in input from [AmplifyStorageS3Dart.getUrl] API to create a
215-
/// `GET` [AWSHttpRequest], then `presign` it with [sigv4.AWSSigV4Signer], and
216-
/// returns a [S3GetUrlResult] containing the presigned [Uri].
215+
/// presigned [AWSHttpRequest] (GET or PUT), then `presign` it with
216+
/// [sigv4.AWSSigV4Signer], and returns a [S3GetUrlResult] containing the
217+
/// presigned [Uri].
218+
///
219+
/// When [S3GetUrlPluginOptions.method] is [StorageAccessMethod.put], the
220+
/// generated presigned URL can be used to upload objects via HTTP PUT.
217221
///
218222
/// {@macro storage.s3_service.throw_exception_unknown_smithy_exception}
219223
Future<S3GetUrlResult> getUrl({
@@ -253,8 +257,13 @@ class StorageS3Service {
253257
.replaceFirst(RegExp(r'\.s3\.'), '.s3-accelerate.');
254258
}
255259

260+
// Determine HTTP method based on the StorageAccessMethod option
261+
final httpMethod = s3PluginOptions.method == StorageAccessMethod.put
262+
? AWSHttpMethod.put
263+
: AWSHttpMethod.get;
264+
256265
final urlRequest = AWSHttpRequest.raw(
257-
method: AWSHttpMethod.get,
266+
method: httpMethod,
258267
host: host,
259268
path: '/$resolvedPath',
260269
);

packages/storage/amplify_storage_s3_dart/test/amplify_storage_s3_dart_test.dart

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,77 @@ void main() {
318318
final result = await getUrlOperation.result;
319319
expect(result, testResult);
320320
});
321+
322+
test(
323+
'should forward options with method PUT to StorageS3Service.getUrl() API',
324+
() async {
325+
const testOptions = StorageGetUrlOptions(
326+
pluginOptions: S3GetUrlPluginOptions(
327+
expiresIn: Duration(minutes: 5),
328+
method: StorageAccessMethod.put,
329+
),
330+
);
331+
332+
when(
333+
() => storageS3Service.getUrl(
334+
path: testPath,
335+
options: any(named: 'options'),
336+
),
337+
).thenAnswer((_) async => testResult);
338+
339+
final getUrlOperation = storageS3Plugin.getUrl(
340+
path: testPath,
341+
options: testOptions,
342+
);
343+
344+
final capturedOptions = verify(
345+
() => storageS3Service.getUrl(
346+
path: testPath,
347+
options: captureAny<StorageGetUrlOptions>(named: 'options'),
348+
),
349+
).captured.last;
350+
351+
expect(capturedOptions, isA<StorageGetUrlOptions>());
352+
final options = capturedOptions as StorageGetUrlOptions;
353+
expect(options.pluginOptions, isA<S3GetUrlPluginOptions>());
354+
final pluginOptions = options.pluginOptions! as S3GetUrlPluginOptions;
355+
expect(pluginOptions.method, StorageAccessMethod.put);
356+
expect(pluginOptions.expiresIn, const Duration(minutes: 5));
357+
358+
final result = await getUrlOperation.result;
359+
expect(result, testResult);
360+
},
361+
);
362+
363+
test(
364+
'should default method to GET when not specified in plugin options',
365+
() async {
366+
const testOptions = StorageGetUrlOptions(
367+
pluginOptions: S3GetUrlPluginOptions(),
368+
);
369+
370+
when(
371+
() => storageS3Service.getUrl(
372+
path: testPath,
373+
options: any(named: 'options'),
374+
),
375+
).thenAnswer((_) async => testResult);
376+
377+
storageS3Plugin.getUrl(path: testPath, options: testOptions);
378+
379+
final capturedOptions = verify(
380+
() => storageS3Service.getUrl(
381+
path: testPath,
382+
options: captureAny<StorageGetUrlOptions>(named: 'options'),
383+
),
384+
).captured.last;
385+
386+
expect(capturedOptions, isA<StorageGetUrlOptions>());
387+
final options = capturedOptions as StorageGetUrlOptions;
388+
final pluginOptions = options.pluginOptions! as S3GetUrlPluginOptions;
389+
expect(pluginOptions.method, StorageAccessMethod.get);
390+
},
391+
);
321392
});
322393

323394
group('downloadData() API', () {

0 commit comments

Comments
 (0)